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

(e.currentTarget.style.display = 'none')}
- />
-
- )}
+
+
+ {settings.brand_logo_url ? (
+
+
+

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

(e.currentTarget.style.display = 'none')}
- />
-
- )}
+
+
+ {settings.brand_favicon_url ? (
+
+
+

{
+ (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() {