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 && (