|
|
|
@@ -62,9 +62,7 @@
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let userData = $state<User | null>(null);
|
|
|
|
|
let bookings = $state<Booking[]>([]);
|
|
|
|
|
let loadingUser = $state(true);
|
|
|
|
|
let loadingBookings = $state(true);
|
|
|
|
|
let stamps = $state(0);
|
|
|
|
|
|
|
|
|
|
// =============== Fetch User Data ===============
|
|
|
|
@@ -97,12 +95,22 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============== Fetch Bookings ===============
|
|
|
|
|
async function fetchBookings() {
|
|
|
|
|
let upcomingBookings = $state<Booking[]>([]);
|
|
|
|
|
let pastBookings = $state<Booking[]>([]);
|
|
|
|
|
let pastPage = $state(1);
|
|
|
|
|
let pastTotalPages = $state(1);
|
|
|
|
|
|
|
|
|
|
let loadingUpcoming = $state(false);
|
|
|
|
|
let loadingPast = $state(false);
|
|
|
|
|
|
|
|
|
|
// =============== Fetch Upcoming Bookings (next 3) ===============
|
|
|
|
|
async function fetchUpcomingBookings() {
|
|
|
|
|
if (pageState !== 'authorized') return;
|
|
|
|
|
|
|
|
|
|
loadingBookings = true;
|
|
|
|
|
loadingUpcoming = true;
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/user/bookings', {
|
|
|
|
|
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
|
|
|
|
const response = await fetch(`/api/bookings?start_date=${today}&per_page=3&page=1`, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
@@ -110,24 +118,66 @@
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
bookings = data.bookings || [];
|
|
|
|
|
} else {
|
|
|
|
|
// toast.error('Failed to load booking history');
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const text = await response.text();
|
|
|
|
|
toast.error('Failed to load upcoming bookings: ' + text);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
upcomingBookings = data.bookings || [];
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching bookings:', err);
|
|
|
|
|
toast.error('Network error loading bookings');
|
|
|
|
|
console.error('Error fetching upcoming bookings:', err);
|
|
|
|
|
toast.error('Network error loading upcoming bookings');
|
|
|
|
|
} finally {
|
|
|
|
|
loadingBookings = false;
|
|
|
|
|
loadingUpcoming = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============== Fetch Past Bookings (paginated 10 per page) ===============
|
|
|
|
|
async function fetchPastBookings(page = 1) {
|
|
|
|
|
if (pageState !== 'authorized') return;
|
|
|
|
|
|
|
|
|
|
loadingPast = true;
|
|
|
|
|
try {
|
|
|
|
|
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
|
|
|
|
const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const text = await response.text();
|
|
|
|
|
toast.error('Failed to load past bookings: ' + text);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
pastBookings = data.bookings || [];
|
|
|
|
|
pastPage = data.page || page;
|
|
|
|
|
pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching past bookings:', err);
|
|
|
|
|
toast.error('Network error loading past bookings');
|
|
|
|
|
} finally {
|
|
|
|
|
loadingPast = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============== Pagination Helpers ===============
|
|
|
|
|
function goToPastPage(page: number) {
|
|
|
|
|
if (page < 1 || page > pastTotalPages) return;
|
|
|
|
|
fetchPastBookings(page);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (pageState === 'authorized') {
|
|
|
|
|
fetchUserData();
|
|
|
|
|
fetchBookings();
|
|
|
|
|
fetchUpcomingBookings();
|
|
|
|
|
fetchPastBookings();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -231,13 +281,29 @@
|
|
|
|
|
|
|
|
|
|
function formatDateTime(dateString: string): string {
|
|
|
|
|
const date = new SvelteDate(dateString);
|
|
|
|
|
return date.toLocaleString('en-GB', {
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Get date parts
|
|
|
|
|
const day = date.getDate();
|
|
|
|
|
const month = date.toLocaleString('en-GB', { month: 'short' });
|
|
|
|
|
const year = date.getFullYear();
|
|
|
|
|
|
|
|
|
|
// Get time parts
|
|
|
|
|
const hours = date.getHours();
|
|
|
|
|
const minutes = date.getMinutes();
|
|
|
|
|
|
|
|
|
|
// Special cases for midnight and noon
|
|
|
|
|
let timeStr;
|
|
|
|
|
if (hours === 12 && minutes === 0) {
|
|
|
|
|
timeStr = 'Noon';
|
|
|
|
|
} else if (hours === 0 && minutes === 0) {
|
|
|
|
|
timeStr = 'Midnight';
|
|
|
|
|
} else {
|
|
|
|
|
const period = hours >= 12 ? 'PM' : 'AM';
|
|
|
|
|
const displayHours = hours % 12 || 12;
|
|
|
|
|
timeStr = `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${day} ${month} ${year}, ${timeStr}`;
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
@@ -424,97 +490,152 @@
|
|
|
|
|
</Card.Content>
|
|
|
|
|
</Card.Root>
|
|
|
|
|
{:else if activeTab === 'history'}
|
|
|
|
|
<!-- Booking History -->
|
|
|
|
|
<Card.Root>
|
|
|
|
|
<!-- Upcoming Bookings -->
|
|
|
|
|
<Card.Root class="mb-6">
|
|
|
|
|
<Card.Header>
|
|
|
|
|
<Card.Title>Booking History</Card.Title>
|
|
|
|
|
<Card.Description>Your past and upcoming appointments</Card.Description>
|
|
|
|
|
<Card.Title>Upcoming Appointments</Card.Title>
|
|
|
|
|
<Card.Description>Next 3 upcoming bookings</Card.Description>
|
|
|
|
|
</Card.Header>
|
|
|
|
|
<Card.Content class="space-y-4">
|
|
|
|
|
{#if loadingBookings}
|
|
|
|
|
<Card.Content class="space-y-2">
|
|
|
|
|
{#if loadingUpcoming}
|
|
|
|
|
{#each Array(3) as _, i (i)}
|
|
|
|
|
<Skeleton class="h-32 w-full" />
|
|
|
|
|
<Skeleton class="h-16 w-full" />
|
|
|
|
|
{/each}
|
|
|
|
|
{:else if bookings.length === 0}
|
|
|
|
|
<div class="py-8 text-center text-gray-500">No bookings found</div>
|
|
|
|
|
{:else if upcomingBookings.length === 0}
|
|
|
|
|
<div class="py-4 text-center text-gray-500">No upcoming bookings</div>
|
|
|
|
|
{:else}
|
|
|
|
|
{#each bookings as booking (booking.id)}
|
|
|
|
|
<div class="rounded-lg border p-4 hover:bg-gray-50">
|
|
|
|
|
<div class="mb-3 flex items-start justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="font-semibold">{formatDateTime(booking.start_time)}</div>
|
|
|
|
|
<div class="mt-1 text-xs text-gray-500">
|
|
|
|
|
{booking.duration_minutes} minutes
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span
|
|
|
|
|
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium {booking.status ===
|
|
|
|
|
'confirmed'
|
|
|
|
|
? 'bg-emerald-100 text-emerald-800'
|
|
|
|
|
: booking.status === 'pending'
|
|
|
|
|
? 'bg-amber-100 text-amber-800'
|
|
|
|
|
: booking.status === 'completed'
|
|
|
|
|
? 'bg-green-100 text-green-800'
|
|
|
|
|
: 'bg-gray-100 text-gray-800'}"
|
|
|
|
|
>
|
|
|
|
|
{booking.status}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Services -->
|
|
|
|
|
{#if booking.services && booking.services.length > 0}
|
|
|
|
|
<div class="mb-3 space-y-2">
|
|
|
|
|
<div class="text-xs font-medium text-gray-600">Services:</div>
|
|
|
|
|
{#each booking.services as service (service.service_name)}
|
|
|
|
|
<div class="flex items-center justify-between text-sm">
|
|
|
|
|
<span>{service.service_name}</span>
|
|
|
|
|
<span class="font-medium">£{service.price.toFixed(2)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<Separator class="my-3" />
|
|
|
|
|
|
|
|
|
|
<!-- Payment Summary -->
|
|
|
|
|
<div class="space-y-1 text-sm">
|
|
|
|
|
<div class="flex justify-between">
|
|
|
|
|
<span class="text-gray-600">Total:</span>
|
|
|
|
|
<span class="font-semibold">£{booking.total_amount.toFixed(2)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex justify-between">
|
|
|
|
|
<span class="text-gray-600">Paid:</span>
|
|
|
|
|
<span class="font-semibold text-green-700"
|
|
|
|
|
>£{booking.amount_paid.toFixed(2)}</span
|
|
|
|
|
{#each upcomingBookings as b (b.id)}
|
|
|
|
|
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
|
|
|
|
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
|
|
|
|
<span
|
|
|
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
|
|
|
|
'confirmed'
|
|
|
|
|
? 'bg-emerald-100 text-emerald-800'
|
|
|
|
|
: b.status === 'pending'
|
|
|
|
|
? 'bg-amber-100 text-amber-800'
|
|
|
|
|
: b.status === 'in_progress'
|
|
|
|
|
? 'bg-blue-100 text-blue-800'
|
|
|
|
|
: b.status === 'completed'
|
|
|
|
|
? 'bg-green-100 text-green-800'
|
|
|
|
|
: 'bg-gray-100 text-gray-800'}"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
|
|
|
|
|
? 'bg-emerald-600'
|
|
|
|
|
: b.status === 'pending'
|
|
|
|
|
? 'bg-amber-600'
|
|
|
|
|
: b.status === 'in_progress'
|
|
|
|
|
? 'bg-blue-600'
|
|
|
|
|
: b.status === 'completed'
|
|
|
|
|
? 'bg-green-600'
|
|
|
|
|
: 'bg-gray-600'}"
|
|
|
|
|
></span>
|
|
|
|
|
{b.status}
|
|
|
|
|
</span>
|
|
|
|
|
<span>
|
|
|
|
|
- {(() => {
|
|
|
|
|
const services = (b.services || []).map(
|
|
|
|
|
(s) => s.service_name || 'Unknown Service'
|
|
|
|
|
);
|
|
|
|
|
if (services.length === 0) return 'No services';
|
|
|
|
|
if (services.length === 1) return services[0];
|
|
|
|
|
if (services.length === 2) return services.join(' and ');
|
|
|
|
|
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
|
|
|
|
})()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{#if booking.amount_due > 0}
|
|
|
|
|
<div class="flex justify-between border-t pt-1">
|
|
|
|
|
<span class="font-medium text-gray-900">Due:</span>
|
|
|
|
|
<span class="font-bold text-red-600">£{booking.amount_due.toFixed(2)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Payments -->
|
|
|
|
|
{#if booking.payments && booking.payments.length > 0}
|
|
|
|
|
<div class="mt-3 space-y-1 rounded-lg bg-gray-50 p-2">
|
|
|
|
|
<div class="text-xs font-medium text-gray-600">Payments:</div>
|
|
|
|
|
{#each booking.payments as payment (payment.id)}
|
|
|
|
|
<div class="flex items-center justify-between text-xs">
|
|
|
|
|
<span class="capitalize"
|
|
|
|
|
>{payment.payment_method.replace('_', ' ')} ({payment.payment_type})</span
|
|
|
|
|
>
|
|
|
|
|
<span class="font-medium">£{payment.amount.toFixed(2)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
{/if}
|
|
|
|
|
</Card.Content>
|
|
|
|
|
</Card.Root>
|
|
|
|
|
|
|
|
|
|
<!-- Past Bookings -->
|
|
|
|
|
<Card.Root>
|
|
|
|
|
<Card.Header>
|
|
|
|
|
<Card.Title>Past Appointments</Card.Title>
|
|
|
|
|
<Card.Description>Previous bookings</Card.Description>
|
|
|
|
|
</Card.Header>
|
|
|
|
|
<Card.Content class="space-y-2">
|
|
|
|
|
{#if loadingPast}
|
|
|
|
|
{#each Array(5) as _, i (i)}
|
|
|
|
|
<Skeleton class="h-16 w-full" />
|
|
|
|
|
{/each}
|
|
|
|
|
{:else if pastBookings.length === 0}
|
|
|
|
|
<div class="py-4 text-center text-gray-500">No past bookings</div>
|
|
|
|
|
{:else}
|
|
|
|
|
{#each pastBookings as b (b.id)}
|
|
|
|
|
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
|
|
|
|
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
|
|
|
|
<span
|
|
|
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
|
|
|
|
'confirmed'
|
|
|
|
|
? 'bg-emerald-100 text-emerald-800'
|
|
|
|
|
: b.status === 'pending'
|
|
|
|
|
? 'bg-amber-100 text-amber-800'
|
|
|
|
|
: b.status === 'in_progress'
|
|
|
|
|
? 'bg-blue-100 text-blue-800'
|
|
|
|
|
: b.status === 'completed'
|
|
|
|
|
? 'bg-green-100 text-green-800'
|
|
|
|
|
: 'bg-gray-100 text-gray-800'}"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
|
|
|
|
|
? 'bg-emerald-600'
|
|
|
|
|
: b.status === 'pending'
|
|
|
|
|
? 'bg-amber-600'
|
|
|
|
|
: b.status === 'in_progress'
|
|
|
|
|
? 'bg-blue-600'
|
|
|
|
|
: b.status === 'completed'
|
|
|
|
|
? 'bg-green-600'
|
|
|
|
|
: 'bg-gray-600'}"
|
|
|
|
|
></span>
|
|
|
|
|
{b.status}
|
|
|
|
|
</span>
|
|
|
|
|
<span>
|
|
|
|
|
- {(() => {
|
|
|
|
|
const services = (b.services || []).map(
|
|
|
|
|
(s) => s.service_name || 'Unknown Service'
|
|
|
|
|
);
|
|
|
|
|
if (services.length === 0) return 'No services';
|
|
|
|
|
if (services.length === 1) return services[0];
|
|
|
|
|
if (services.length === 2) return services.join(' and ');
|
|
|
|
|
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
|
|
|
|
})()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Pagination Controls -->
|
|
|
|
|
{#if pastTotalPages > 1}
|
|
|
|
|
<div class="mt-2 flex justify-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={pastPage === 1}
|
|
|
|
|
onclick={() => goToPastPage(pastPage - 1)}>Prev</Button
|
|
|
|
|
>
|
|
|
|
|
<span class="px-2 py-1 text-sm text-gray-700">{pastPage} / {pastTotalPages}</span>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={pastPage === pastTotalPages}
|
|
|
|
|
onclick={() => goToPastPage(pastPage + 1)}>Next</Button
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</Card.Content>
|
|
|
|
|
</Card.Root>
|
|
|
|
|
{:else if activeTab === 'referral'}
|
|
|
|
|
<!-- Referral Program -->
|
|
|
|
|
<Card.Root>
|
|
|
|
|