From fb24e77e42d9b69a3bbec396745a3529579a5d64 Mon Sep 17 00:00:00 2001 From: dwindown Date: Wed, 24 Dec 2025 11:42:20 +0700 Subject: [PATCH] Implement post-implementation refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features implemented: 1. Expired QRIS order handling with dual-path approach - Product orders: QR regeneration button - Consulting orders: Immediate cancellation with slot release 2. Standardized status badge wording to "Pending" 3. Fixed TypeScript error in MemberDashboard 4. Dynamic badge colors from branding settings 5. Dynamic page title from branding settings 6. Logo/favicon file upload with auto-delete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- index.html | 15 +- src/components/UnpaidOrderAlert.tsx | 58 ++++ src/components/admin/settings/BrandingTab.tsx | 269 +++++++++++++++--- src/components/reviews/ConsultingHistory.tsx | 4 +- src/components/ui/badge.tsx | 2 +- src/hooks/useBranding.tsx | 5 + src/index.css | 7 + src/pages/Checkout.tsx | 38 +-- src/pages/ConsultingBooking.tsx | 191 ++++++++++--- src/pages/Dashboard.tsx | 4 +- src/pages/admin/AdminDashboard.tsx | 2 +- src/pages/admin/AdminOrders.tsx | 2 +- src/pages/member/MemberDashboard.tsx | 70 ++++- src/pages/member/MemberOrders.tsx | 4 +- src/pages/member/OrderDetail.tsx | 257 ++++++++++++++--- 15 files changed, 779 insertions(+), 149 deletions(-) create mode 100644 src/components/UnpaidOrderAlert.tsx diff --git a/index.html b/index.html index 38a5fa7..10f0d74 100644 --- a/index.html +++ b/index.html @@ -3,19 +3,18 @@ - - Lovable App - - + + Loading... + + - - - + + - + diff --git a/src/components/UnpaidOrderAlert.tsx b/src/components/UnpaidOrderAlert.tsx new file mode 100644 index 0000000..ccad565 --- /dev/null +++ b/src/components/UnpaidOrderAlert.tsx @@ -0,0 +1,58 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { AlertCircle } from "lucide-react"; +import { Link } from "react-router-dom"; + +interface UnpaidOrderAlertProps { + orderId: string; + expiresAt: string; // ISO timestamp +} + +export function UnpaidOrderAlert({ orderId, expiresAt }: UnpaidOrderAlertProps) { + // Non-dismissable alert - NO onDismiss prop + // Alert will auto-hide when QR expires via Dashboard logic + + const formatExpiryTime = (isoString: string) => { + try { + return new Date(isoString).toLocaleTimeString('id-ID', { + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return isoString; + } + }; + + return ( + +
+
+ +
+ +
+

+ Pembayaran Belum Selesai + + Segera + +

+ + Anda memiliki pesanan konsultasi yang menunggu pembayaran. QRIS kode akan kedaluwarsa pada{" "} + {formatExpiryTime(expiresAt)}. + + + +
+
+
+ ); +} diff --git a/src/components/admin/settings/BrandingTab.tsx b/src/components/admin/settings/BrandingTab.tsx index b54d7d8..ab3290e 100644 --- a/src/components/admin/settings/BrandingTab.tsx +++ b/src/components/admin/settings/BrandingTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/hooks/use-toast'; -import { Palette, Image, Mail, Home, Plus, Trash2 } from 'lucide-react'; +import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X } from 'lucide-react'; interface HomepageFeature { icon: string; @@ -51,6 +51,11 @@ export function BrandingTab() { const [settings, setSettings] = useState(emptySettings); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [uploadingLogo, setUploadingLogo] = useState(false); + const [uploadingFavicon, setUploadingFavicon] = useState(false); + + const logoInputRef = useRef(null); + const faviconInputRef = useRef(null); useEffect(() => { fetchSettings(); @@ -150,6 +155,108 @@ export function BrandingTab() { setSettings({ ...settings, homepage_features: newFeatures }); }; + // Handle logo upload with auto-delete + const handleLogoUpload = async (file: File) => { + setUploadingLogo(true); + try { + const fileExt = file.name.split('.').pop(); + const filePath = `brand-assets/logo/logo-current.${fileExt}`; + + // Step 1: Delete old logo if exists + const { data: existingFiles } = await supabase.storage + .from('content') + .list('brand-assets/logo/'); + + if (existingFiles?.length > 0) { + const oldFile = existingFiles.find(f => f.name.startsWith('logo-current')); + if (oldFile) { + await supabase.storage.from('content').remove([`brand-assets/logo/${oldFile.name}`]); + } + } + + // Step 2: Upload new logo + const { data, error } = await supabase.storage + .from('content') + .upload(filePath, file, { + cacheControl: '3600', + upsert: true, + }); + + if (error) throw error; + + // Step 3: Get public URL and update settings + const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath); + setSettings({ ...settings, brand_logo_url: urlData.publicUrl }); + + toast({ title: 'Berhasil', description: 'Logo berhasil diupload' }); + } catch (error) { + console.error('Logo upload error:', error); + toast({ title: 'Error', description: 'Gagal upload logo', variant: 'destructive' }); + } finally { + setUploadingLogo(false); + } + }; + + // Handle favicon upload with auto-delete + const handleFaviconUpload = async (file: File) => { + setUploadingFavicon(true); + try { + const fileExt = file.name.split('.').pop(); + const filePath = `brand-assets/favicon/favicon-current.${fileExt}`; + + // Step 1: Delete old favicon if exists + const { data: existingFiles } = await supabase.storage + .from('content') + .list('brand-assets/favicon/'); + + if (existingFiles?.length > 0) { + const oldFile = existingFiles.find(f => f.name.startsWith('favicon-current')); + if (oldFile) { + await supabase.storage.from('content').remove([`brand-assets/favicon/${oldFile.name}`]); + } + } + + // Step 2: Upload new favicon + const { data, error } = await supabase.storage + .from('content') + .upload(filePath, file, { + cacheControl: '3600', + upsert: true, + }); + + if (error) throw error; + + // Step 3: Get public URL and update settings + const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath); + setSettings({ ...settings, brand_favicon_url: urlData.publicUrl }); + + toast({ title: 'Berhasil', description: 'Favicon berhasil diupload' }); + } catch (error) { + console.error('Favicon upload error:', error); + toast({ title: 'Error', description: 'Gagal upload favicon', variant: 'destructive' }); + } finally { + setUploadingFavicon(false); + } + }; + + const handleLogoSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleLogoUpload(file); + }; + + const handleFaviconSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFaviconUpload(file); + }; + + const handleRemoveLogo = () => { + setSettings({ ...settings, brand_logo_url: '' }); + }; + + const handleRemoveFavicon = () => { + setSettings({ ...settings, brand_favicon_url: '' }); + }; + if (loading) return
; return ( @@ -195,47 +302,141 @@ export function BrandingTab() {
- setSettings({ ...settings, brand_logo_url: e.target.value })} - placeholder="https://example.com/logo.png" - className="border-2" + + - {settings.brand_logo_url && ( -
- Logo preview (e.currentTarget.style.display = 'none')} - /> -
- )} + +
+ {settings.brand_logo_url ? ( +
+
+ Logo preview { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + toast({ title: 'Error', description: 'Gagal memuat logo', variant: 'destructive' }); + }} + /> +
+
+ + +
+
+ ) : ( + + )} +

+ PNG, SVG, JPG, atau WebP. Maks 2MB. +

+
- setSettings({ ...settings, brand_favicon_url: e.target.value })} - placeholder="https://example.com/favicon.ico" - className="border-2" + + - {settings.brand_favicon_url && ( -
- Favicon preview (e.currentTarget.style.display = 'none')} - /> -
- )} + +
+ {settings.brand_favicon_url ? ( +
+
+ Favicon preview { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + toast({ title: 'Error', description: 'Gagal memuat favicon', variant: 'destructive' }); + }} + /> +
+
+ + +
+
+ ) : ( + + )} +

+ PNG, SVG, JPG, atau ICO. Maks 1MB. +

+
diff --git a/src/components/reviews/ConsultingHistory.tsx b/src/components/reviews/ConsultingHistory.tsx index 9cc30a6..7122467 100644 --- a/src/components/reviews/ConsultingHistory.tsx +++ b/src/components/reviews/ConsultingHistory.tsx @@ -84,11 +84,11 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { const getStatusBadge = (status: string) => { switch (status) { case 'done': - return Selesai; + return Selesai; case 'confirmed': return Terkonfirmasi; case 'pending_payment': - return Menunggu Pembayaran; + return Pending; case 'cancelled': return Dibatalkan; default: diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 0853c44..ce26aee 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -8,7 +8,7 @@ const badgeVariants = cva( { variants: { variant: { - default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80 hover:text-white", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", outline: "text-foreground", diff --git a/src/hooks/useBranding.tsx b/src/hooks/useBranding.tsx index aac9c07..66e9201 100644 --- a/src/hooks/useBranding.tsx +++ b/src/hooks/useBranding.tsx @@ -79,6 +79,11 @@ export function BrandingProvider({ children }: { children: ReactNode }) { homepage_features: features, }); + // Update CSS variable for accent color + if (data.brand_accent_color) { + document.documentElement.style.setProperty('--brand-accent', data.brand_accent_color); + } + // Update favicon if set if (data.brand_favicon_url) { const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement; diff --git a/src/index.css b/src/index.css index 600b8ac..ec9aa63 100644 --- a/src/index.css +++ b/src/index.css @@ -158,4 +158,11 @@ All colors MUST be HSL. body { @apply bg-background text-foreground; } +} + +/* Dynamic brand accent color for badges */ +@layer utilities { + .bg-brand-accent { + background-color: var(--brand-accent, hsl(var(--accent))); + } } \ No newline at end of file diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx index 9fdca5f..b17da9b 100644 --- a/src/pages/Checkout.tsx +++ b/src/pages/Checkout.tsx @@ -191,22 +191,28 @@ export default function Checkout() { Total {formatIDR(total)} - -

Pembayaran diproses melalui Pakasir

+
+ +
+

Pembayaran aman dengan standar QRIS dari Bank Indonesia

+

Diproses oleh mitra pembayaran terpercaya

+
+

Didukung oleh Pakasir | QRIS terdaftar oleh Bank Indonesia

+
diff --git a/src/pages/ConsultingBooking.tsx b/src/pages/ConsultingBooking.tsx index a298151..d2f8e12 100644 --- a/src/pages/ConsultingBooking.tsx +++ b/src/pages/ConsultingBooking.tsx @@ -58,7 +58,14 @@ export default function ConsultingBooking() { const [profile, setProfile] = useState(null); const [selectedDate, setSelectedDate] = useState(addDays(new Date(), 1)); - const [selectedSlots, setSelectedSlots] = useState([]); + + // NEW: Range selection instead of array + interface TimeRange { + start: string | null; + end: string | null; + } + const [selectedRange, setSelectedRange] = useState({ start: null, end: null }); + const [selectedCategory, setSelectedCategory] = useState(''); const [notes, setNotes] = useState(''); const [whatsappInput, setWhatsappInput] = useState(''); @@ -147,15 +154,81 @@ export default function ConsultingBooking() { return slots; }, [selectedDate, workhours, confirmedSlots, settings]); - const toggleSlot = (slotStart: string) => { - setSelectedSlots(prev => - prev.includes(slotStart) - ? prev.filter(s => s !== slotStart) - : [...prev, slotStart] - ); + // Helper: Get all slots between start and end (inclusive) + const getSlotsInRange = useMemo(() => { + if (!selectedRange.start || !selectedRange.end) return []; + + const startIndex = availableSlots.findIndex(s => s.start === selectedRange.start); + const endIndex = availableSlots.findIndex(s => s.start === selectedRange.end); + + if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) return []; + + return availableSlots + .slice(startIndex, endIndex + 1) + .map(s => s.start); + }, [selectedRange, availableSlots]); + + // NEW: Range selection handler + const handleSlotClick = (slotStart: string) => { + const slot = availableSlots.find(s => s.start === slotStart); + if (!slot || !slot.available) return; + + setSelectedRange(prev => { + // CASE 1: No selection yet → Set start time + if (!prev.start) { + return { start: slotStart, end: null }; + } + + // CASE 2: Only start selected → Set end time + if (!prev.end) { + if (slotStart === prev.start) { + // Clicked same slot → Clear selection + return { start: null, end: null }; + } + // Ensure end is after start + const startIndex = availableSlots.findIndex(s => s.start === prev.start); + const clickIndex = availableSlots.findIndex(s => s.start === slotStart); + + if (clickIndex < startIndex) { + // Clicked before start → Make new start, old start becomes end + return { start: slotStart, end: prev.start }; + } + + return { start: prev.start, end: slotStart }; + } + + // CASE 3: Both selected (changing range) + const startIndex = availableSlots.findIndex(s => s.start === prev.start); + const endIndex = availableSlots.findIndex(s => s.start === prev.end); + const clickIndex = availableSlots.findIndex(s => s.start === slotStart); + + // Clicked start time → Clear all + if (slotStart === prev.start) { + return { start: null, end: null }; + } + + // Clicked end time → Update end + if (slotStart === prev.end) { + return { start: prev.start, end: null }; + } + + // Clicked before start → New start, old start becomes end + if (clickIndex < startIndex) { + return { start: slotStart, end: prev.start }; + } + + // Clicked after end → New end + if (clickIndex > endIndex) { + return { start: prev.start, end: slotStart }; + } + + // Clicked within range → Update end to clicked slot + return { start: prev.start, end: slotStart }; + }); }; - const totalBlocks = selectedSlots.length; + // Calculate total blocks from range + const totalBlocks = getSlotsInRange.length; const totalPrice = totalBlocks * (settings?.consulting_block_price || 0); const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30); @@ -166,7 +239,7 @@ export default function ConsultingBooking() { return; } - if (selectedSlots.length === 0) { + if (getSlotsInRange.length === 0) { toast({ title: 'Pilih slot', description: 'Pilih minimal satu slot waktu', variant: 'destructive' }); return; } @@ -206,7 +279,7 @@ export default function ConsultingBooking() { if (orderError) throw orderError; // Create consulting slots - const slotsToInsert = selectedSlots.map(slotStart => { + const slotsToInsert = getSlotsInRange.map(slotStart => { const slotEnd = format( addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes), 'HH:mm' @@ -320,7 +393,7 @@ export default function ConsultingBooking() { Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })} - Klik slot untuk memilih. {settings.consulting_block_duration_minutes} menit per blok. + Klik slot awal dan akhir untuk memilih rentang waktu. {settings.consulting_block_duration_minutes} menit per blok. @@ -329,18 +402,47 @@ export default function ConsultingBooking() { Tidak ada slot tersedia pada hari ini

) : ( -
- {availableSlots.map((slot) => ( - - ))} +
+ {availableSlots.map((slot, index) => { + const isSelected = getSlotsInRange.includes(slot.start); + const isStart = slot.start === selectedRange.start; + const isEnd = slot.start === selectedRange.end; + const isMiddle = isSelected && !isStart && !isEnd; + + // Determine button variant + let variant: "default" | "outline" = "outline"; + if (isSelected) variant = "default"; + + // Determine border radius for seamless connection + let className = "border-2 h-10"; + + if (isStart) { + // First selected slot - right side should connect + className += index < availableSlots.length - 1 && availableSlots[index + 1]?.start === getSlotsInRange[1] + ? " rounded-r-none border-r-0" + : ""; + } else if (isEnd) { + // Last selected slot - left side should connect + className += " rounded-l-none border-l-0"; + } else if (isMiddle) { + // Middle slot - seamless + className += " rounded-none border-x-0"; + } + + return ( + + ); + })}
)} @@ -414,28 +516,37 @@ export default function ConsultingBooking() { {selectedDate ? format(selectedDate, 'd MMM yyyy', { locale: id }) : '-'}
-
- Jumlah Blok - {totalBlocks} blok -
-
- Total Durasi - {totalDuration} menit -
Kategori {selectedCategory || '-'}
- {selectedSlots.length > 0 && ( + {selectedRange.start && selectedRange.end && (
-

Slot dipilih:

-
- {selectedSlots.sort().map((slot) => ( - - {slot} - - ))} +

Waktu dipilih:

+ + {/* Show range */} +
+
+
+

Mulai

+

{selectedRange.start}

+
+ +
+

→

+

{totalBlocks} blok

+
+ +
+

Selesai

+

{selectedRange.end}

+
+
+ +

+ {totalDuration} menit ({formatIDR(totalPrice)}) +

)} @@ -452,7 +563,7 @@ export default function ConsultingBooking() {
)} + {/* Expired QR Handling */} + {order.payment_status === "pending" && order.payment_method === "qris" && isQrExpired && ( +
+ + + + {isConsultingOrder + ? "Waktu pembayaran telah habis. Slot konsultasi telah dilepaskan. Silakan buat booking baru." + : "QR Code telah kadaluarsa. Anda dapat me-regenerate QR code untuk melanjutkan pembayaran."} + + + + {isConsultingOrder ? ( + // Consulting order - show booking button +
+

+ Order ini telah dibatalkan secara otomatis karena waktu pembayaran habis. +

+ +
+ ) : ( + // Product order - show regenerate button +
+
+

{formatIDR(order.total_amount)}

+

+ Order ID: {order.id.slice(0, 8)} +

+
+ + + + +
+ )} +
+ )} + {/* Fallback button for pending payments without QR */} {order.payment_status === "pending" && !order.qr_string && order.payment_url && (
@@ -388,49 +503,109 @@ export default function OrderDetail() { - {/* Order Items */} - - - - - Item Pesanan - - - -
- {order.order_items.map((item) => ( -
-
- - {item.products.title} - -
- - {getTypeLabel(item.products.type)} - - - x{item.quantity} - + {/* Smart Item/Service Display */} + {order.order_items.length > 0 ? ( + // === Product Orders === + + + + + Item Pesanan + + + +
+ {order.order_items.map((item) => ( +
+
+ + {item.products.title} + +
+ + {getTypeLabel(item.products.type)} + + + x{item.quantity} + +
+

{formatIDR(item.products.sale_price || item.products.price)}

-

{formatIDR(item.products.sale_price || item.products.price)}

+ ))} +
+ + + +
+ Total + {formatIDR(order.total_amount)} +
+
+
+ ) : consultingSlots.length > 0 ? ( + // === Consulting Orders === + + + + + + + {/* Summary Card */} +
+
- + {/* Status Alert */} + {order.payment_status === "paid" ? ( + + + ) : ( + + + + Selesaikan pembayaran untuk mengkonfirmasi jadwal sesi konsultasi. + + + )} + + + ) : null} -
- Total - {formatIDR(order.total_amount)} -
- - - - {/* Consulting Slots */} + {/* Consulting Slots Detail */} {consultingSlots.length > 0 && (