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:
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user