Add consulting slots display with Join Meet button

- Member OrderDetail page: Shows consulting slots with date/time and Join Meet button
- Admin Orders dialog: Shows consulting slots with meet link access
- Meet button only visible when payment_status is 'paid'
- Both pages show slot status (confirmed/pending)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-23 16:45:48 +07:00
parent ce531c8d46
commit 9d7d76b04d
3 changed files with 195 additions and 19 deletions

View File

@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { formatIDR, formatDateTime } from "@/lib/format";
import { Eye, CheckCircle, XCircle } from "lucide-react";
import { Eye, CheckCircle, XCircle, Video, ExternalLink } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface Order {
@@ -27,11 +27,20 @@ interface Order {
interface OrderItem {
id: string;
product: { title: string };
product: { title: string; type?: string };
unit_price: number;
quantity: number;
}
interface ConsultingSlot {
id: string;
date: string;
start_time: string;
end_time: string;
status: string;
meet_link?: string;
}
export default function AdminOrders() {
const { user, isAdmin, loading: authLoading } = useAuth();
const navigate = useNavigate();
@@ -39,6 +48,7 @@ export default function AdminOrders() {
const [loading, setLoading] = useState(true);
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
@@ -60,8 +70,22 @@ export default function AdminOrders() {
const viewOrderDetails = async (order: Order) => {
setSelectedOrder(order);
const { data } = await supabase.from("order_items").select("*, product:products(title)").eq("order_id", order.id);
setOrderItems((data as unknown as OrderItem[]) || []);
const { data: itemsData } = await supabase.from("order_items").select("*, product:products(title, type)").eq("order_id", order.id);
setOrderItems((itemsData as unknown as OrderItem[]) || []);
// Check if any item is a consulting product and fetch slots
const hasConsulting = (itemsData as unknown as OrderItem[])?.some(item => item.product?.type === "consulting");
if (hasConsulting) {
const { data: slotsData } = await supabase
.from("consulting_slots")
.select("*")
.eq("order_id", order.id)
.order("date", { ascending: true });
setConsultingSlots((slotsData as ConsultingSlot[]) || []);
} else {
setConsultingSlots([]);
}
setDialogOpen(true);
};
@@ -199,6 +223,56 @@ export default function AdminOrders() {
<span className="font-bold">{formatIDR(selectedOrder.total_amount)}</span>
</div>
</div>
{/* Consulting Slots */}
{consultingSlots.length > 0 && (
<div className="border-t border-border pt-4">
<p className="font-medium mb-3 flex items-center gap-2">
<Video className="w-4 h-4" />
Jadwal Konsultasi
</p>
<div className="space-y-2">
{consultingSlots.map((slot) => (
<div key={slot.id} className="border-2 border-border rounded-lg p-3 bg-background">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Badge variant={slot.status === "confirmed" ? "default" : "secondary"} className="text-xs">
{slot.status === "confirmed" ? "Terkonfirmasi" : slot.status}
</Badge>
</div>
<p className="text-sm font-medium">
{new Date(slot.date).toLocaleDateString("id-ID", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric"
})}
</p>
<p className="text-xs text-muted-foreground">
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} WIB
</p>
</div>
{slot.meet_link && (
<Button asChild variant="outline" size="sm" className="gap-1">
<a
href={slot.meet_link}
target="_blank"
rel="noopener noreferrer"
>
<Video className="w-3 h-3" />
Meet
<ExternalLink className="w-3 h-3" />
</a>
</Button>
)}
</div>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-4">
{selectedOrder.payment_status !== "paid" && (
<Button onClick={() => updateOrderStatus(selectedOrder.id, "paid")} className="flex-1">

View File

@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { formatIDR, formatDate } from "@/lib/format";
import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle } from "lucide-react";
import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle, Video } from "lucide-react";
interface OrderItem {
id: string;
@@ -37,11 +37,21 @@ interface Order {
order_items: OrderItem[];
}
interface ConsultingSlot {
id: string;
date: string;
start_time: string;
end_time: string;
status: string;
meet_link?: string;
}
export default function OrderDetail() {
const { id } = useParams<{ id: string }>();
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null);
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -60,7 +70,7 @@ export default function OrderDetail() {
const fetchOrder = async () => {
if (!user || !id) return;
setLoading(true);
setError(null);
@@ -92,8 +102,25 @@ export default function OrderDetail() {
setLoading(false);
return;
}
setOrder(data as Order);
// Fetch consulting slots if this is a consulting order
const hasConsultingProduct = data.order_items.some(
(item: OrderItem) => item.products.type === "consulting"
);
if (hasConsultingProduct) {
const { data: slots } = await supabase
.from("consulting_slots")
.select("*")
.eq("order_id", id)
.order("date", { ascending: true });
if (slots) {
setConsultingSlots(slots as ConsultingSlot[]);
}
}
} catch (err) {
console.error("Unexpected error:", err);
setError("Terjadi kesalahan");
@@ -293,6 +320,68 @@ export default function OrderDetail() {
</CardContent>
</Card>
{/* Consulting Slots */}
{consultingSlots.length > 0 && (
<Card className="border-2 border-primary bg-primary/5">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Video className="w-5 h-5" />
Jadwal Konsultasi
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{consultingSlots.map((slot) => (
<div key={slot.id} className="border-2 border-border rounded-lg p-4 bg-background">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge variant={slot.status === "confirmed" ? "default" : "secondary"}>
{slot.status === "confirmed" ? "Terkonfirmasi" : slot.status}
</Badge>
</div>
<p className="font-medium">
{new Date(slot.date).toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
})}
</p>
<p className="text-sm text-muted-foreground">
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} WIB
</p>
</div>
{slot.meet_link && order.payment_status === "paid" && (
<Button asChild className="shadow-sm">
<a
href={slot.meet_link}
target="_blank"
rel="noopener noreferrer"
>
<Video className="w-4 h-4 mr-2" />
Join Meet
</a>
</Button>
)}
{slot.meet_link && order.payment_status !== "paid" && (
<p className="text-sm text-muted-foreground">
Link tersedia setelah pembayaran
</p>
)}
{!slot.meet_link && (
<p className="text-sm text-muted-foreground">
Link akan dikirim via email
</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Access Info */}
{order.payment_status === "paid" && (
<Card className="border-2 border-primary bg-primary/5">

View File

@@ -229,18 +229,31 @@ serve(async (req: Request): Promise<Response> => {
console.log("Creating event in calendar:", calendarId);
console.log("Event data:", JSON.stringify(eventData, null, 2));
// Create event via Google Calendar API
const calendarResponse = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?conferenceDataVersion=1`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(eventData),
}
);
// Create event via Google Calendar API with better error handling
let calendarResponse: Response;
try {
calendarResponse = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?conferenceDataVersion=1`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "Deno/1.0 (Supabase Edge Function)",
},
body: JSON.stringify(eventData),
}
);
} catch (fetchError: any) {
console.error("Network error calling Google Calendar API:", fetchError);
return new Response(
JSON.stringify({
success: false,
message: "Network error calling Google Calendar API: " + fetchError.message
}),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
console.log("Calendar API response status:", calendarResponse.status);