Implement post-implementation refinements

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 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-24 11:42:20 +07:00
parent 4b8765885b
commit fb24e77e42
15 changed files with 779 additions and 149 deletions

View File

@@ -58,7 +58,14 @@ export default function ConsultingBooking() {
const [profile, setProfile] = useState<Profile | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
const [selectedSlots, setSelectedSlots] = useState<string[]>([]);
// NEW: Range selection instead of array
interface TimeRange {
start: string | null;
end: string | null;
}
const [selectedRange, setSelectedRange] = useState<TimeRange>({ 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 })}
</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent>
@@ -329,18 +402,47 @@ export default function ConsultingBooking() {
Tidak ada slot tersedia pada hari ini
</p>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{availableSlots.map((slot) => (
<Button
key={slot.start}
variant={selectedSlots.includes(slot.start) ? 'default' : 'outline'}
disabled={!slot.available}
onClick={() => slot.available && toggleSlot(slot.start)}
className="border-2"
>
{slot.start}
</Button>
))}
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-0">
{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 (
<Button
key={slot.start}
variant={variant}
disabled={!slot.available}
onClick={() => slot.available && handleSlotClick(slot.start)}
className={className}
>
{isStart && <span className="text-xs opacity-70">Mulai</span>}
{!isStart && !isEnd && slot.start}
{isEnd && <span className="text-xs opacity-70">Selesai</span>}
</Button>
);
})}
</div>
)}
</CardContent>
@@ -414,28 +516,37 @@ export default function ConsultingBooking() {
{selectedDate ? format(selectedDate, 'd MMM yyyy', { locale: id }) : '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Jumlah Blok</span>
<span className="font-medium">{totalBlocks} blok</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total Durasi</span>
<span className="font-medium">{totalDuration} menit</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Kategori</span>
<span className="font-medium">{selectedCategory || '-'}</span>
</div>
{selectedSlots.length > 0 && (
{selectedRange.start && selectedRange.end && (
<div className="pt-4 border-t">
<p className="text-sm text-muted-foreground mb-2">Slot dipilih:</p>
<div className="flex flex-wrap gap-1">
{selectedSlots.sort().map((slot) => (
<span key={slot} className="px-2 py-1 bg-primary/10 text-primary rounded text-sm">
{slot}
</span>
))}
<p className="text-sm text-muted-foreground mb-2">Waktu dipilih:</p>
{/* Show range */}
<div className="bg-primary/10 p-3 rounded-lg border-2 border-primary/20">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">Mulai</p>
<p className="font-bold text-lg">{selectedRange.start}</p>
</div>
<div className="text-center">
<p className="text-2xl"></p>
<p className="text-xs text-muted-foreground">{totalBlocks} blok</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Selesai</p>
<p className="font-bold text-lg">{selectedRange.end}</p>
</div>
</div>
<p className="text-center text-sm mt-2 text-primary font-medium">
{totalDuration} menit ({formatIDR(totalPrice)})
</p>
</div>
</div>
)}
@@ -452,7 +563,7 @@ export default function ConsultingBooking() {
<Button
onClick={handleBookNow}
disabled={submitting || selectedSlots.length === 0 || !selectedCategory}
disabled={submitting || getSlotsInRange.length === 0 || !selectedCategory}
className="w-full shadow-sm"
>
{submitting ? 'Memproses...' : 'Booking Sekarang'}