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:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user