style: apply prettier formatting to frontend
This commit is contained in:
@@ -826,8 +826,8 @@
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
const newFirstName = editingFirstName ? firstNameInput.trim() : (userData?.firstName || '');
|
||||
const newLastName = editingLastName ? lastNameInput.trim() : (userData?.lastName || '');
|
||||
const newFirstName = editingFirstName ? firstNameInput.trim() : userData?.firstName || '';
|
||||
const newLastName = editingLastName ? lastNameInput.trim() : userData?.lastName || '';
|
||||
|
||||
// Validate
|
||||
if (!newFirstName || !newLastName) {
|
||||
@@ -1554,78 +1554,114 @@
|
||||
{/each}
|
||||
{:else if userData}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">First Name</span>
|
||||
{#if editingFirstName}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="first-name"
|
||||
bind:value={firstNameInput}
|
||||
placeholder="Enter first name"
|
||||
maxlength={50}
|
||||
error={firstNameError}
|
||||
/>
|
||||
{#if firstNameError}
|
||||
<p class="text-xs text-red-500">{firstNameError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={saveName} disabled={savingName || !firstNameInput.trim()}>
|
||||
{savingName ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={cancelEditName} disabled={savingName}>
|
||||
Cancel
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">First Name</span>
|
||||
{#if editingFirstName}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="first-name"
|
||||
bind:value={firstNameInput}
|
||||
placeholder="Enter first name"
|
||||
maxlength={50}
|
||||
error={firstNameError}
|
||||
/>
|
||||
{#if firstNameError}
|
||||
<p class="text-xs text-red-500">{firstNameError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={saveName}
|
||||
disabled={savingName || !firstNameInput.trim()}
|
||||
>
|
||||
{savingName ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={cancelEditName}
|
||||
disabled={savingName}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
|
||||
>
|
||||
<span>{userData.firstName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
<span>{userData.firstName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">Last Name</span>
|
||||
{#if editingLastName}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="last-name"
|
||||
bind:value={lastNameInput}
|
||||
placeholder="Enter last name"
|
||||
maxlength={50}
|
||||
error={lastNameError}
|
||||
/>
|
||||
{#if lastNameError}
|
||||
<p class="text-xs text-red-500">{lastNameError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={saveName} disabled={savingName || !lastNameInput.trim()}>
|
||||
{savingName ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={cancelEditName} disabled={savingName}>
|
||||
Cancel
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">Last Name</span>
|
||||
{#if editingLastName}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="last-name"
|
||||
bind:value={lastNameInput}
|
||||
placeholder="Enter last name"
|
||||
maxlength={50}
|
||||
error={lastNameError}
|
||||
/>
|
||||
{#if lastNameError}
|
||||
<p class="text-xs text-red-500">{lastNameError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={saveName}
|
||||
disabled={savingName || !lastNameInput.trim()}
|
||||
>
|
||||
{savingName ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={cancelEditName}
|
||||
disabled={savingName}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
|
||||
>
|
||||
<span>{userData.lastName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditLastName}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
<span>{userData.lastName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditLastName}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">Email</span>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
@@ -1643,7 +1679,11 @@
|
||||
placeholder="Enter phone number"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={savePhone} disabled={savingPhone || !isValidUKPhone(phoneInput)}>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={savePhone}
|
||||
disabled={savingPhone || !isValidUKPhone(phoneInput)}
|
||||
>
|
||||
{savingPhone ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1693,16 +1733,17 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="rounded-xl border-2 border-fuchsia-200 bg-fuchsia-50 p-5 sm:p-6"
|
||||
>
|
||||
<div class="rounded-xl border-2 border-fuchsia-200 bg-fuchsia-50 p-5 sm:p-6">
|
||||
{#if userData}
|
||||
<div class="mb-5 text-center sm:text-left">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Loyalty Stamp Card</h3>
|
||||
<p class="mt-0.5 text-xs text-gray-500">
|
||||
{stamps < 10
|
||||
? `Collect ${10 - stamps} more stamp${10 - stamps === 1 ? '' : 's'} to get 10% off your next booking.`
|
||||
: (() => { const fc = Math.floor(stamps / 10); return `You have ${fc === 1 ? 'a' : fc} full stampcard${fc === 1 ? '' : 's'} ready to take advantage of at your next booking!`; })()}
|
||||
: (() => {
|
||||
const fc = Math.floor(stamps / 10);
|
||||
return `You have ${fc === 1 ? 'a' : fc} full stampcard${fc === 1 ? '' : 's'} ready to take advantage of at your next booking!`;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1710,7 +1751,7 @@
|
||||
{#each Array(10) as _, i}
|
||||
{@const slotNum = i + 1}
|
||||
{@const rot = ((slotNum * 37 + 13) % 7) - 3}
|
||||
{#if slotNum <= (stamps > 0 ? (stamps % 10 || 10) : 0)}
|
||||
{#if slotNum <= (stamps > 0 ? stamps % 10 || 10 : 0)}
|
||||
<div
|
||||
class="aspect-square transition-transform duration-200 hover:scale-110"
|
||||
>
|
||||
@@ -1947,17 +1988,15 @@
|
||||
</div>
|
||||
<div class="text-sm">Friends Referred</div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4 text-center">
|
||||
<div class="text-3xl font-bold">
|
||||
£{(userData.referralSavings || 0).toFixed(2)}
|
||||
<div class="rounded-lg border p-4 text-center">
|
||||
<div class="text-3xl font-bold">
|
||||
£{(userData.referralSavings || 0).toFixed(2)}
|
||||
</div>
|
||||
<div class="text-sm">Total Saved</div>
|
||||
</div>
|
||||
<div class="text-sm">Total Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Root
|
||||
class="mt-6 border-amber-200/60 bg-amber-50"
|
||||
>
|
||||
<Card.Root class="mt-6 border-amber-200/60 bg-amber-50">
|
||||
<Card.Content class="pt-4 md:pt-6">
|
||||
<div class="space-y-2 text-sm text-amber-900">
|
||||
<h4 class="font-semibold text-amber-800">How it works:</h4>
|
||||
@@ -2163,13 +2202,13 @@
|
||||
oninput={handleGiftCardInput}
|
||||
class="font-mono"
|
||||
/>
|
||||
<Button
|
||||
onclick={() => (showRedeemConfirm = true)}
|
||||
disabled={redeemingGiftCard ||
|
||||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
|
||||
>
|
||||
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => (showRedeemConfirm = true)}
|
||||
disabled={redeemingGiftCard ||
|
||||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
|
||||
>
|
||||
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
@@ -2180,42 +2219,40 @@
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Redeem Gift Card</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Claiming this gift card will add its remaining balance directly to your
|
||||
account balance, which can be used toward future bookings.
|
||||
Claiming this gift card will add its remaining balance directly to your account
|
||||
balance, which can be used toward future bookings.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<div class="px-6 py-4 space-y-3 text-sm text-muted-foreground">
|
||||
<div class="rounded-lg border bg-amber-50/50 p-3 space-y-2">
|
||||
<div class="space-y-3 px-6 py-4 text-sm text-muted-foreground">
|
||||
<div class="space-y-2 rounded-lg border bg-amber-50/50 p-3">
|
||||
<p>
|
||||
<strong class="text-foreground">What happens when I claim?</strong>
|
||||
</p>
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<ul class="list-disc space-y-1 pl-4">
|
||||
<li>The gift card value is added to your account balance.</li>
|
||||
<li>
|
||||
Account balances do not expire, but gift card codes become invalid once
|
||||
redeemed.
|
||||
</li>
|
||||
<li>
|
||||
This action is final and cannot be reversed.
|
||||
</li>
|
||||
<li>This action is final and cannot be reversed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-blue-50/50 p-3 space-y-2">
|
||||
<div class="space-y-2 rounded-lg border bg-blue-50/50 p-3">
|
||||
<p>
|
||||
<strong class="text-foreground">Legal & GDPR Information</strong>
|
||||
</p>
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<ul class="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
Your personal data (name, email, transaction history) is processed in
|
||||
accordance with UK data protection law.
|
||||
</li>
|
||||
<li>
|
||||
Financial records are retained for 7 years as required by HMRC, after
|
||||
which personally identifiable information is anonymised.
|
||||
Financial records are retained for 7 years as required by HMRC, after which
|
||||
personally identifiable information is anonymised.
|
||||
</li>
|
||||
<li>
|
||||
You can request a full copy of your data or deletion of your account
|
||||
at any time via your account settings.
|
||||
You can request a full copy of your data or deletion of your account at any
|
||||
time via your account settings.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -2348,17 +2385,17 @@
|
||||
</div>
|
||||
|
||||
{#if buyRecipientType === 'friend'}
|
||||
<div class="space-y-2">
|
||||
<label for="recipient-email" class="text-sm font-medium text-gray-700"
|
||||
>Friend's Email (Optional)</label
|
||||
>
|
||||
<EmailInput
|
||||
id="recipient-email"
|
||||
bind:value={buyRecipientEmail}
|
||||
placeholder="friend@example.com (blank to send to yourself)"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="recipient-email" class="text-sm font-medium text-gray-700"
|
||||
>Friend's Email (Optional)</label
|
||||
>
|
||||
<EmailInput
|
||||
id="recipient-email"
|
||||
bind:value={buyRecipientEmail}
|
||||
placeholder="friend@example.com (blank to send to yourself)"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3 border-t pt-2">
|
||||
@@ -2665,7 +2702,22 @@
|
||||
<PolicyPopover>
|
||||
{#snippet trigger()}
|
||||
<Button variant="outline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/><polyline points="14 2 14 8 20 8" /><line
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="8"
|
||||
y2="13"
|
||||
/><line x1="16" y1="17" x2="8" y2="17" /></svg
|
||||
>
|
||||
Cancellation & Deposit Policy
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -71,14 +71,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
try {
|
||||
var token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.replace('/login'); return; }
|
||||
if (!token) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
|
||||
if (payload.role !== 'admin') { window.location.replace('/'); }
|
||||
} catch(e) { window.location.replace('/login'); }
|
||||
if (payload.exp * 1000 <= Date.now()) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (payload.role !== 'admin') {
|
||||
window.location.replace('/');
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</svelte:head>
|
||||
|
||||
@@ -331,7 +331,10 @@
|
||||
function getNotificationSubtitle(n: Notification): string {
|
||||
const parts = [formatRelative(n.created_at)];
|
||||
if (n.user_name) {
|
||||
parts.push(n.user_name); {/* TODO: add formerly name when previous name data is available */}
|
||||
parts.push(n.user_name);
|
||||
{
|
||||
/* TODO: add formerly name when previous name data is available */
|
||||
}
|
||||
}
|
||||
return parts.join(' — ');
|
||||
}
|
||||
@@ -347,14 +350,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
try {
|
||||
var token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.replace('/login'); return; }
|
||||
if (!token) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
|
||||
if (payload.role !== 'admin') { window.location.replace('/'); }
|
||||
} catch(e) { window.location.replace('/login'); }
|
||||
if (payload.exp * 1000 <= Date.now()) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (payload.role !== 'admin') {
|
||||
window.location.replace('/');
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</svelte:head>
|
||||
@@ -509,7 +522,7 @@
|
||||
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking?.id ?? ''} />
|
||||
|
||||
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} openBookingModal={openBookingModal} />
|
||||
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
|
||||
|
||||
{#if showEditRequestModal && selectedEditRequest}
|
||||
<EditRequestModal
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
start_time: string;
|
||||
status: string;
|
||||
duration_minutes: number;
|
||||
user?: { full_name: string; previous_first_name?: string | null; previous_last_name?: string | null };
|
||||
user?: {
|
||||
full_name: string;
|
||||
previous_first_name?: string | null;
|
||||
previous_last_name?: string | null;
|
||||
};
|
||||
services: BookingService[];
|
||||
};
|
||||
type TimeBlocker = {
|
||||
@@ -108,7 +112,12 @@
|
||||
* Intl.DateTimeFormat with Europe/London so the current-time blue line is
|
||||
* positioned correctly regardless of the browser's system timezone. */
|
||||
function getLondonNowMinutes(): number {
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const [h, m] = timeStr.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
@@ -365,7 +374,8 @@
|
||||
return { minStart: 8, maxEnd: 18, hours: Array.from({ length: 11 }, (_, i) => i + 8) };
|
||||
|
||||
// Find earliest start and latest end across ALL data (hours, bookings, blockers)
|
||||
let earliest = 24, latest = 0;
|
||||
let earliest = 24,
|
||||
latest = 0;
|
||||
|
||||
for (const day of getWeekDays(weekStart)) {
|
||||
const dateStr = formatDate(day);
|
||||
@@ -378,14 +388,14 @@
|
||||
const ef = eh + (em || 0) / 60;
|
||||
if (ef > latest) latest = ef;
|
||||
}
|
||||
for (const b of (bookingsByDate.get(dateStr) || [])) {
|
||||
for (const b of bookingsByDate.get(dateStr) || []) {
|
||||
const d = new SvelteDate(b.start_time);
|
||||
const sf = d.getHours() + d.getMinutes() / 60;
|
||||
if (sf < earliest) earliest = sf;
|
||||
const ef = sf + b.duration_minutes / 60;
|
||||
if (ef > latest) latest = ef;
|
||||
}
|
||||
for (const b of (blockersByDate.get(dateStr) || [])) {
|
||||
for (const b of blockersByDate.get(dateStr) || []) {
|
||||
const d = new SvelteDate(b.start_time);
|
||||
const sf = d.getHours() + d.getMinutes() / 60;
|
||||
if (sf < earliest) earliest = sf;
|
||||
@@ -395,15 +405,16 @@
|
||||
}
|
||||
|
||||
// No data: fall back to 8-18
|
||||
if (earliest >= 24 || latest <= 0) return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
|
||||
if (earliest >= 24 || latest <= 0)
|
||||
return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
|
||||
|
||||
// Start: 30min buffer before earliest (to show the pre-booking gap)
|
||||
// End: floor of latest (the row that CONTAINS the latest item, not the next row)
|
||||
let minStart = Math.floor(earliest - 0.5);
|
||||
let maxEnd = Math.floor(latest);
|
||||
let maxEnd = Math.floor(latest);
|
||||
|
||||
minStart = Math.max(0, minStart);
|
||||
maxEnd = Math.min(24, maxEnd);
|
||||
maxEnd = Math.min(24, maxEnd);
|
||||
|
||||
const hours: number[] = [];
|
||||
for (let h = minStart; h <= maxEnd; h++) hours.push(h);
|
||||
@@ -432,14 +443,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
try {
|
||||
var token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.replace('/login'); return; }
|
||||
if (!token) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
|
||||
if (payload.role !== 'admin') { window.location.replace('/'); }
|
||||
} catch(e) { window.location.replace('/login'); }
|
||||
if (payload.exp * 1000 <= Date.now()) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (payload.role !== 'admin') {
|
||||
window.location.replace('/');
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</svelte:head>
|
||||
@@ -612,7 +633,11 @@
|
||||
{#if !hasOverlap}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View booking for {formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}"
|
||||
aria-label="View booking for {formatUserName(
|
||||
b.user?.full_name || 'Guest',
|
||||
b.user?.previous_first_name,
|
||||
b.user?.previous_last_name
|
||||
)}"
|
||||
class="absolute inset-x-0.5 z-10 cursor-pointer overflow-hidden rounded border px-1.5 py-0.5 text-left text-xs transition-shadow hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500"
|
||||
style="top: {topOffset}px; height: {heightPx}px; {bookingStyle(
|
||||
b.status
|
||||
@@ -628,7 +653,11 @@
|
||||
style="background:{dotColor(b.status)}"
|
||||
></div>
|
||||
<span class="truncate font-medium"
|
||||
>{formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}</span
|
||||
>{formatUserName(
|
||||
b.user?.full_name || 'Guest',
|
||||
b.user?.previous_first_name,
|
||||
b.user?.previous_last_name
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
{#if heightPx > 36}
|
||||
|
||||
+189
-196
@@ -28,12 +28,12 @@ Decompose `+page.svelte` into focused, testable components with clear data flow
|
||||
|
||||
**Single source of truth:** `BookingFlow.svelte`
|
||||
|
||||
* currentStep
|
||||
* selectedServices
|
||||
* selectedDate
|
||||
* selectedTime
|
||||
* customerInfo
|
||||
* pricing / totals
|
||||
- currentStep
|
||||
- selectedServices
|
||||
- selectedDate
|
||||
- selectedTime
|
||||
- customerInfo
|
||||
- pricing / totals
|
||||
|
||||
Everything else receives props + dispatches events. No hidden global coupling. 🧠
|
||||
|
||||
@@ -45,14 +45,14 @@ Everything else receives props + dispatches events. No hidden global coupling.
|
||||
|
||||
**Role:** Orchestrator
|
||||
|
||||
* Holds booking state
|
||||
* Validates transitions between steps
|
||||
* Calls API + handles toasts
|
||||
- Holds booking state
|
||||
- Validates transitions between steps
|
||||
- Calls API + handles toasts
|
||||
|
||||
**Props:** none
|
||||
**Emits:** none
|
||||
|
||||
This is the only component allowed to know *everything*.
|
||||
This is the only component allowed to know _everything_.
|
||||
|
||||
---
|
||||
|
||||
@@ -63,8 +63,8 @@ This is the only component allowed to know *everything*.
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
```
|
||||
|
||||
Pure UI. Zero logic.
|
||||
@@ -85,8 +85,8 @@ selected: Service[]
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
select(service)
|
||||
deselect(service)
|
||||
select(service);
|
||||
deselect(service);
|
||||
```
|
||||
|
||||
Internally renders multiple `ServiceCard`s.
|
||||
@@ -100,8 +100,8 @@ Internally renders multiple `ServiceCard`s.
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
service: Service
|
||||
selected: boolean
|
||||
service: Service;
|
||||
selected: boolean;
|
||||
```
|
||||
|
||||
No awareness of booking steps or pricing totals.
|
||||
@@ -117,13 +117,13 @@ Wraps your existing `Calendar` usage.
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
date: CalendarDate | undefined
|
||||
date: CalendarDate | undefined;
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
change(date)
|
||||
change(date);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -143,7 +143,7 @@ availability: TimeSlot[]
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
select(time)
|
||||
select(time);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -155,13 +155,13 @@ select(time)
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
value: CustomerInfo
|
||||
value: CustomerInfo;
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
update(value)
|
||||
update(value);
|
||||
```
|
||||
|
||||
No submit button here. Forms shouldn’t decide flow control.
|
||||
@@ -193,17 +193,17 @@ Zero mutation. Snapshot only.
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
canBack: boolean
|
||||
canNext: boolean
|
||||
isSubmitting: boolean
|
||||
canBack: boolean;
|
||||
canNext: boolean;
|
||||
isSubmitting: boolean;
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
back()
|
||||
next()
|
||||
submit()
|
||||
back();
|
||||
next();
|
||||
submit();
|
||||
```
|
||||
|
||||
---
|
||||
@@ -234,10 +234,10 @@ Use only if the flow must persist across routes or reloads. Otherwise keep it lo
|
||||
|
||||
## Result
|
||||
|
||||
* Smaller files
|
||||
* Predictable data flow
|
||||
* Each component explainable in one sentence
|
||||
* Easier testing and future changes
|
||||
- Smaller files
|
||||
- Predictable data flow
|
||||
- Each component explainable in one sentence
|
||||
- Easier testing and future changes
|
||||
|
||||
Entropy reduced. ✂️🧩
|
||||
|
||||
@@ -249,64 +249,62 @@ We start by **wrapping the existing logic**, not rewriting it.
|
||||
|
||||
### 1. Create `BookingFlow.svelte`
|
||||
|
||||
Move *all* booking‑related state and logic out of `+page.svelte` into this file.
|
||||
Move _all_ booking‑related state and logic out of `+page.svelte` into this file.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import StepIndicator from './StepIndicator.svelte';
|
||||
import ServiceSelector from './ServiceSelector.svelte';
|
||||
import DatePicker from './DatePicker.svelte';
|
||||
import TimeSlotPicker from './TimeSlotPicker.svelte';
|
||||
import CustomerDetailsForm from './CustomerDetailsForm.svelte';
|
||||
import BookingSummary from './BookingSummary.svelte';
|
||||
import BookingActions from './BookingActions.svelte';
|
||||
import StepIndicator from './StepIndicator.svelte';
|
||||
import ServiceSelector from './ServiceSelector.svelte';
|
||||
import DatePicker from './DatePicker.svelte';
|
||||
import TimeSlotPicker from './TimeSlotPicker.svelte';
|
||||
import CustomerDetailsForm from './CustomerDetailsForm.svelte';
|
||||
import BookingSummary from './BookingSummary.svelte';
|
||||
import BookingActions from './BookingActions.svelte';
|
||||
|
||||
import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';
|
||||
import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';
|
||||
|
||||
// --- State (copied verbatim from +page.svelte) ---
|
||||
let currentStep = 1;
|
||||
let selectedServices: Service[] = [];
|
||||
let selectedDate;
|
||||
let selectedTime: string | null = null;
|
||||
let customerInfo: CustomerInfo = { /* unchanged */ };
|
||||
// --- State (copied verbatim from +page.svelte) ---
|
||||
let currentStep = 1;
|
||||
let selectedServices: Service[] = [];
|
||||
let selectedDate;
|
||||
let selectedTime: string | null = null;
|
||||
let customerInfo: CustomerInfo = {
|
||||
/* unchanged */
|
||||
};
|
||||
|
||||
// pricing, derived values, API calls stay here
|
||||
// pricing, derived values, API calls stay here
|
||||
</script>
|
||||
|
||||
<StepIndicator {currentStep} totalSteps={4} />
|
||||
|
||||
{#if currentStep === 1}
|
||||
<ServiceSelector
|
||||
services={services}
|
||||
selected={selectedServices}
|
||||
on:select={(e) => selectedServices.push(e.detail)}
|
||||
on:deselect={(e) => selectedServices = selectedServices.filter(s => s.id !== e.detail.id)}
|
||||
/>
|
||||
<ServiceSelector
|
||||
{services}
|
||||
selected={selectedServices}
|
||||
on:select={(e) => selectedServices.push(e.detail)}
|
||||
on:deselect={(e) => (selectedServices = selectedServices.filter((s) => s.id !== e.detail.id))}
|
||||
/>
|
||||
{:else if currentStep === 2}
|
||||
<DatePicker bind:date={selectedDate} />
|
||||
<TimeSlotPicker
|
||||
date={selectedDate}
|
||||
availability={availability}
|
||||
bind:selectedTime
|
||||
/>
|
||||
<DatePicker bind:date={selectedDate} />
|
||||
<TimeSlotPicker date={selectedDate} {availability} bind:selectedTime />
|
||||
{:else if currentStep === 3}
|
||||
<CustomerDetailsForm bind:value={customerInfo} />
|
||||
<CustomerDetailsForm bind:value={customerInfo} />
|
||||
{:else if currentStep === 4}
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={customerInfo}
|
||||
total={total}
|
||||
/>
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={customerInfo}
|
||||
{total}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<BookingActions
|
||||
canBack={currentStep > 1}
|
||||
canNext={currentStep < 4}
|
||||
on:back={() => currentStep--}
|
||||
on:next={() => currentStep++}
|
||||
on:submit={submitBooking}
|
||||
canBack={currentStep > 1}
|
||||
canNext={currentStep < 4}
|
||||
on:back={() => currentStep--}
|
||||
on:next={() => currentStep++}
|
||||
on:submit={submitBooking}
|
||||
/>
|
||||
```
|
||||
|
||||
@@ -318,7 +316,7 @@ Nothing clever yet. This is a **containerization step**, not a refactor.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
|
||||
import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
|
||||
</script>
|
||||
|
||||
<BookingFlow />
|
||||
@@ -326,9 +324,9 @@ Nothing clever yet. This is a **containerization step**, not a refactor.
|
||||
|
||||
At this point:
|
||||
|
||||
* Behaviour is identical
|
||||
* No store introduced
|
||||
* You have a single, explicit "brain"
|
||||
- Behaviour is identical
|
||||
- No store introduced
|
||||
- You have a single, explicit "brain"
|
||||
|
||||
---
|
||||
|
||||
@@ -342,11 +340,11 @@ This is the safest cut: **pure UI, minimal state, zero flow control**.
|
||||
|
||||
In `ServiceSelector`, find the repeated markup that:
|
||||
|
||||
* Displays service name / price / duration
|
||||
* Highlights selected state
|
||||
* Handles click / toggle
|
||||
- Displays service name / price / duration
|
||||
- Highlights selected state
|
||||
- Handles click / toggle
|
||||
|
||||
If it *renders one service*, it becomes a card.
|
||||
If it _renders one service_, it becomes a card.
|
||||
|
||||
---
|
||||
|
||||
@@ -354,31 +352,32 @@ If it *renders one service*, it becomes a card.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Service } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Service } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let service: Service;
|
||||
export let selected = false;
|
||||
export let service: Service;
|
||||
export let selected = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function toggle() {
|
||||
dispatch(selected ? 'deselect' : 'select', service);
|
||||
}
|
||||
function toggle() {
|
||||
dispatch(selected ? 'deselect' : 'select', service);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class:selected
|
||||
on:click={toggle}
|
||||
>
|
||||
<h3>{service.name}</h3>
|
||||
<p>{service.duration} min</p>
|
||||
<p>£{service.price}</p>
|
||||
<button class:selected on:click={toggle}>
|
||||
<h3>{service.name}</h3>
|
||||
<p>{service.duration} min</p>
|
||||
<p>£{service.price}</p>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button { /* existing styles */ }
|
||||
.selected { /* existing selected styles */ }
|
||||
button {
|
||||
/* existing styles */
|
||||
}
|
||||
.selected {
|
||||
/* existing selected styles */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
@@ -390,26 +389,26 @@ No booking logic. No totals. No step awareness.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import ServiceCard from './ServiceCard.svelte';
|
||||
import type { Service } from '$lib/types/booking';
|
||||
import ServiceCard from './ServiceCard.svelte';
|
||||
import type { Service } from '$lib/types/booking';
|
||||
|
||||
export let services: Service[] = [];
|
||||
export let selected: Service[] = [];
|
||||
export let services: Service[] = [];
|
||||
export let selected: Service[] = [];
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
{#each services as service (service.id)}
|
||||
<ServiceCard
|
||||
{service}
|
||||
selected={selected.some(s => s.id === service.id)}
|
||||
on:select
|
||||
on:deselect
|
||||
/>
|
||||
{/each}
|
||||
{#each services as service (service.id)}
|
||||
<ServiceCard
|
||||
{service}
|
||||
selected={selected.some((s) => s.id === service.id)}
|
||||
on:select
|
||||
on:deselect
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
```
|
||||
|
||||
Selection state still lives *above*. This is critical.
|
||||
Selection state still lives _above_. This is critical.
|
||||
|
||||
---
|
||||
|
||||
@@ -417,9 +416,9 @@ Selection state still lives *above*. This is critical.
|
||||
|
||||
At this point:
|
||||
|
||||
* `ServiceCard` is dumb
|
||||
* `ServiceSelector` coordinates cards
|
||||
* `BookingFlow` owns truth
|
||||
- `ServiceCard` is dumb
|
||||
- `ServiceSelector` coordinates cards
|
||||
- `BookingFlow` owns truth
|
||||
|
||||
If this feels boring, good. Boring code is stable code.
|
||||
|
||||
@@ -439,11 +438,11 @@ The rule here is strict:
|
||||
|
||||
In `BookingFlow`, locate:
|
||||
|
||||
* Name / email / phone inputs
|
||||
* Validation messages
|
||||
* `on:input` handlers
|
||||
- Name / email / phone inputs
|
||||
- Validation messages
|
||||
- `on:input` handlers
|
||||
|
||||
Anything that mutates `customerInfo` belongs in the form *except* submission.
|
||||
Anything that mutates `customerInfo` belongs in the form _except_ submission.
|
||||
|
||||
---
|
||||
|
||||
@@ -451,39 +450,39 @@ Anything that mutates `customerInfo` belongs in the form *except* submission.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { CustomerInfo } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { CustomerInfo } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let value: CustomerInfo;
|
||||
export let value: CustomerInfo;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
|
||||
dispatch('update', { ...value, [key]: val });
|
||||
}
|
||||
function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
|
||||
dispatch('update', { ...value, [key]: val });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={value.name}
|
||||
on:input={(e) => update('name', e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={value.name}
|
||||
on:input={(e) => update('name', e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={value.email}
|
||||
on:input={(e) => update('email', e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={value.email}
|
||||
on:input={(e) => update('email', e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Phone"
|
||||
value={value.phone}
|
||||
on:input={(e) => update('phone', e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Phone"
|
||||
value={value.phone}
|
||||
on:input={(e) => update('phone', e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -496,16 +495,13 @@ No submit button. No step logic. No API calls.
|
||||
Replace inline inputs with:
|
||||
|
||||
```svelte
|
||||
<CustomerDetailsForm
|
||||
value={customerInfo}
|
||||
on:update={(e) => customerInfo = e.detail}
|
||||
/>
|
||||
<CustomerDetailsForm value={customerInfo} on:update={(e) => (customerInfo = e.detail)} />
|
||||
```
|
||||
|
||||
Validation still lives in `BookingFlow`:
|
||||
|
||||
* Can we go to the next step?
|
||||
* Is submit enabled?
|
||||
- Can we go to the next step?
|
||||
- Is submit enabled?
|
||||
|
||||
---
|
||||
|
||||
@@ -513,11 +509,11 @@ Validation still lives in `BookingFlow`:
|
||||
|
||||
You should now observe:
|
||||
|
||||
* The form is reusable
|
||||
* BookingFlow got smaller
|
||||
* Step logic is easier to read
|
||||
- The form is reusable
|
||||
- BookingFlow got smaller
|
||||
- Step logic is easier to read
|
||||
|
||||
If you *can’t* explain where a rule lives in one sentence, it’s in the wrong place.
|
||||
If you _can’t_ explain where a rule lives in one sentence, it’s in the wrong place.
|
||||
|
||||
---
|
||||
|
||||
@@ -533,22 +529,19 @@ Rule of the step:
|
||||
|
||||
### 4.1 Extract `DatePicker.svelte`
|
||||
|
||||
This component selects *only* a date. No availability logic. No time awareness.
|
||||
This component selects _only_ a date. No availability logic. No time awareness.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { CalendarDate } from '@internationalized/date';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { CalendarDate } from '@internationalized/date';
|
||||
|
||||
export let date: CalendarDate | undefined;
|
||||
export let date: CalendarDate | undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<Calendar
|
||||
value={date}
|
||||
on:change={(e) => dispatch('change', e.detail)}
|
||||
/>
|
||||
<Calendar value={date} on:change={(e) => dispatch('change', e.detail)} />
|
||||
```
|
||||
|
||||
It emits intent. That’s it.
|
||||
@@ -557,34 +550,34 @@ It emits intent. That’s it.
|
||||
|
||||
### 4.2 Extract `TimeSlotPicker.svelte`
|
||||
|
||||
Time slots depend on *inputs*, never globals.
|
||||
Time slots depend on _inputs_, never globals.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { TimeSlot } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { TimeSlot } from '$lib/types/booking';
|
||||
|
||||
export let date; // required
|
||||
export let availability: TimeSlot[] = [];
|
||||
export let selectedTime: string | null = null;
|
||||
export let date; // required
|
||||
export let availability: TimeSlot[] = [];
|
||||
export let selectedTime: string | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
{#if !date}
|
||||
<p class="text-muted">Select a date first</p>
|
||||
<p class="text-muted">Select a date first</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each availability as slot (slot.time)}
|
||||
<button
|
||||
class:selected={slot.time === selectedTime}
|
||||
disabled={!slot.available}
|
||||
on:click={() => dispatch('select', slot.time)}
|
||||
>
|
||||
{slot.time}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid">
|
||||
{#each availability as slot (slot.time)}
|
||||
<button
|
||||
class:selected={slot.time === selectedTime}
|
||||
disabled={!slot.available}
|
||||
on:click={() => dispatch('select', slot.time)}
|
||||
>
|
||||
{slot.time}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
@@ -598,27 +591,27 @@ Here is the **complete and correct wiring**, including guards and reset logic:
|
||||
|
||||
```svelte
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
on:change={(e) => {
|
||||
const newDate = e.detail;
|
||||
selectedDate = newDate;
|
||||
date={selectedDate}
|
||||
on:change={(e) => {
|
||||
const newDate = e.detail;
|
||||
selectedDate = newDate;
|
||||
|
||||
// changing date invalidates time
|
||||
selectedTime = null;
|
||||
// changing date invalidates time
|
||||
selectedTime = null;
|
||||
|
||||
// fetch / recompute availability here
|
||||
loadAvailability(newDate);
|
||||
}}
|
||||
// fetch / recompute availability here
|
||||
loadAvailability(newDate);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TimeSlotPicker
|
||||
date={selectedDate}
|
||||
availability={availability}
|
||||
selectedTime={selectedTime}
|
||||
on:select={(e) => {
|
||||
selectedTime = e.detail;
|
||||
}}
|
||||
date={selectedDate}
|
||||
{availability}
|
||||
{selectedTime}
|
||||
on:select={(e) => {
|
||||
selectedTime = e.detail;
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
All temporal logic lives here. Children stay honest.
|
||||
All temporal logic lives here. Children stay honest.
|
||||
|
||||
@@ -369,7 +369,9 @@
|
||||
<span class="text-gray-600">VAT</span>
|
||||
<span class="font-medium">{formatPence(paymentSummary.total_vat_amount)}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between border-t border-gray-200 pt-1 text-sm font-semibold">
|
||||
<div
|
||||
class="mt-1 flex justify-between border-t border-gray-200 pt-1 text-sm font-semibold"
|
||||
>
|
||||
<span class="text-gray-800">Total paid</span>
|
||||
<span class="text-gray-800">{formatPence(paymentSummary.paid_amount)}</span>
|
||||
</div>
|
||||
|
||||
@@ -60,9 +60,9 @@
|
||||
<p class="mb-3">
|
||||
Where a deposit is required to secure your appointment, you must pay at least
|
||||
<strong>20%</strong> of the total service cost before the 24-hour deadline prior to the
|
||||
appointment. The 20% can be paid in a single payment or accumulated across multiple
|
||||
payments — what matters is the total when the deadline passes. When deposit restrictions
|
||||
are active on your account, appointments must be scheduled at least
|
||||
appointment. The 20% can be paid in a single payment or accumulated across multiple payments
|
||||
— what matters is the total when the deadline passes. When deposit restrictions are active
|
||||
on your account, appointments must be scheduled at least
|
||||
<strong>36 hours in advance</strong>.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
@@ -80,9 +80,9 @@
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
If a required deposit is not paid at least 24 hours before the appointment begins, the
|
||||
booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes
|
||||
vulnerable — if another customer books an overlapping time and pays, your original
|
||||
booking is automatically evicted.
|
||||
booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes vulnerable
|
||||
— if another customer books an overlapping time and pays, your original booking is automatically
|
||||
evicted.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
While in this status, your appointment is <strong>not guaranteed</strong>. The system will
|
||||
@@ -144,9 +144,9 @@
|
||||
days).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The
|
||||
gift card's remaining balance is incremented and is immediately available for use.
|
||||
Expired gift cards are non-refundable.
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The gift
|
||||
card's remaining balance is incremented and is immediately available for use. Expired gift
|
||||
cards are non-refundable.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cash payments</strong>: Credited to your account balance, available for immediate
|
||||
@@ -172,8 +172,8 @@
|
||||
registered users).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The
|
||||
gift card's remaining balance is incremented. Expired gift cards are non-refundable.
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The gift
|
||||
card's remaining balance is incremented. Expired gift cards are non-refundable.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cash payments</strong>: Refunded in person at the salon. Please bring your receipt
|
||||
@@ -192,8 +192,8 @@
|
||||
<p class="mb-3">
|
||||
Failing to attend a confirmed appointment without notifying us in advance constitutes a
|
||||
"No-Show". Cancelling a pending or deposit-lapsed booking within 24 hours does
|
||||
<strong>not</strong> count as a no-show — only confirmed bookings (where the slot was
|
||||
secured with a payment) can incur no-show strikes.
|
||||
<strong>not</strong> count as a no-show — only confirmed bookings (where the slot was secured
|
||||
with a payment) can incur no-show strikes.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
If your account accumulates <strong
|
||||
@@ -206,8 +206,8 @@
|
||||
Each completed booking with a payment reduces the required deposit count by one. Once the
|
||||
count reaches zero, all prior no-show records within the 6-month window are forgiven and
|
||||
your account returns to normal — no upfront deposits required — until a new no-show occurs.
|
||||
The salon can also forgive individual no-shows at management's discretion, which
|
||||
immediately removes them from the count.
|
||||
The salon can also forgive individual no-shows at management's discretion, which immediately
|
||||
removes them from the count.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
<div class="mx-auto grid max-w-[744px] gap-6 px-4 lg:grid-cols-2">
|
||||
<div class="h-[376px]">
|
||||
{#if loading}
|
||||
<div class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6">
|
||||
<div
|
||||
class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6"
|
||||
>
|
||||
<div class="mb-4 flex justify-center">
|
||||
<div class="h-24 w-24 rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
@@ -68,26 +70,39 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="map-card mx-auto h-[376px] w-full max-w-sm rounded-lg border-2 border-gray-200 bg-white overflow-hidden lg:mx-0 lg:max-w-none">
|
||||
<div
|
||||
class="map-card mx-auto h-[376px] w-full max-w-sm overflow-hidden rounded-lg border-2 border-gray-200 bg-white lg:mx-0 lg:max-w-none"
|
||||
>
|
||||
<Map theme="light" center={[-3.476464162450991, 56.0781854944036]} zoom={15}>
|
||||
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>
|
||||
<MarkerContent>
|
||||
<div class="flex items-center justify-center rounded-full bg-primary p-2 text-primary-foreground shadow-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
</MarkerContent>
|
||||
<MarkerPopup>
|
||||
<div class="p-2 text-sm">
|
||||
<p class="font-semibold">41 Pollock Walk</p>
|
||||
<p class="text-muted-foreground">Dunfermline KY12 9DA</p>
|
||||
</div>
|
||||
</MarkerPopup>
|
||||
</MapMarker>
|
||||
<MapControls position="bottom-right" showZoom />
|
||||
</Map>
|
||||
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>
|
||||
<MarkerContent>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full bg-primary p-2 text-primary-foreground shadow-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</MarkerContent>
|
||||
<MarkerPopup>
|
||||
<div class="p-2 text-sm">
|
||||
<p class="font-semibold">41 Pollock Walk</p>
|
||||
<p class="text-muted-foreground">Dunfermline KY12 9DA</p>
|
||||
</div>
|
||||
</MarkerPopup>
|
||||
</MapMarker>
|
||||
<MapControls position="bottom-right" showZoom />
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="space-y-3">
|
||||
{#each recentCheckouts as checkout, i (i)}
|
||||
{#each recentCheckouts as checkout, i (i)}
|
||||
<div class="rounded-lg border p-3">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="font-medium">{checkout.customer}</span>
|
||||
@@ -387,7 +387,7 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="space-y-3">
|
||||
{#each loyaltyToday as loyalty, i (i)}
|
||||
{#each loyaltyToday as loyalty, i (i)}
|
||||
<div class="rounded-lg border bg-gray-50 p-3">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="font-medium">{loyalty.customer}</span>
|
||||
|
||||
@@ -260,7 +260,12 @@
|
||||
function fmtDate(dateStr: string): string {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'Europe/London' });
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDateTime(dateStr: string): string {
|
||||
@@ -330,29 +335,29 @@
|
||||
{
|
||||
label: 'Loyalty Stamps',
|
||||
value: `${stats.totalLoyaltyStamps} total`,
|
||||
subtitle: `${stats.stampsRedeemed} redeemed, ${data.user_profile.loyalty_stamps} remaining`,
|
||||
subtitle: `${stats.stampsRedeemed} redeemed, ${data.user_profile.loyalty_stamps} remaining`
|
||||
},
|
||||
{
|
||||
label: 'Discounts Received',
|
||||
value: fmt(stats.totalDiscounts),
|
||||
subtitle: `${data.booking_discounts?.length ?? 0} discount(s) applied`,
|
||||
highlight: true,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Patch Tests',
|
||||
value: `${stats.patchTests}`,
|
||||
subtitle: 'completed',
|
||||
subtitle: 'completed'
|
||||
},
|
||||
{
|
||||
label: 'Edits or Reschedules',
|
||||
value: `${stats.editRequests}`,
|
||||
subtitle: 'submitted',
|
||||
subtitle: 'submitted'
|
||||
},
|
||||
{
|
||||
label: 'Saved Cards',
|
||||
value: `${stats.savedCards}`,
|
||||
subtitle: 'on file',
|
||||
},
|
||||
subtitle: 'on file'
|
||||
}
|
||||
];
|
||||
|
||||
const nonZero = candidates.filter((c) => {
|
||||
@@ -382,9 +387,7 @@
|
||||
const completedBookings = bookings.filter((b) => b.status === 'completed');
|
||||
const cancelledBookings = bookings.filter(
|
||||
(b) =>
|
||||
b.status === 'client_cancelled' ||
|
||||
b.status === 'we_cancelled' ||
|
||||
b.status === 'no_show'
|
||||
b.status === 'client_cancelled' || b.status === 'we_cancelled' || b.status === 'no_show'
|
||||
);
|
||||
const noShowBookings = bookings.filter((b) => b.status === 'no_show');
|
||||
const pendingBookings = bookings.filter(
|
||||
@@ -425,12 +428,8 @@
|
||||
.map(([method, data]) => ({ method, ...data }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const totalDiscounts = discounts.reduce(
|
||||
(sum, d) => sum + Number(d.discount_amount), 0
|
||||
);
|
||||
const stampsRedeemed = redemptions.reduce(
|
||||
(sum, r) => sum + Number(r.stamps_redeemed), 0
|
||||
);
|
||||
const totalDiscounts = discounts.reduce((sum, d) => sum + Number(d.discount_amount), 0);
|
||||
const stampsRedeemed = redemptions.reduce((sum, r) => sum + Number(r.stamps_redeemed), 0);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
@@ -441,9 +440,11 @@
|
||||
totalSpent,
|
||||
totalRefunded,
|
||||
netSpent: totalSpent - totalRefunded,
|
||||
avgBookingValue: completedBookings.length > 0
|
||||
? completedBookings.reduce((s, b) => s + Number(b.total_price), 0) / completedBookings.length
|
||||
: 0,
|
||||
avgBookingValue:
|
||||
completedBookings.length > 0
|
||||
? completedBookings.reduce((s, b) => s + Number(b.total_price), 0) /
|
||||
completedBookings.length
|
||||
: 0,
|
||||
memberSince: data.user_profile?.created_at ?? '',
|
||||
totalLoyaltyStamps: (data.user_profile?.loyalty_stamps ?? 0) + stampsRedeemed,
|
||||
stampsRedeemed,
|
||||
@@ -455,7 +456,7 @@
|
||||
patchTests: patchTests.length,
|
||||
savedCards: (data.saved_cards ?? []).length,
|
||||
referredBy: data.referrals?.referred_by?.referrer_name ?? null,
|
||||
referredCount: data.referrals?.referred_users?.length ?? 0,
|
||||
referredCount: data.referrals?.referred_users?.length ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -566,9 +567,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
{#if computeBookingStats(gdprData)}
|
||||
{@const stats = computeBookingStats(gdprData)!}
|
||||
<h2 class="mb-3 text-xl font-bold">Summary</h2>
|
||||
{#if computeBookingStats(gdprData)}
|
||||
{@const stats = computeBookingStats(gdprData)!}
|
||||
<h2 class="mb-3 text-xl font-bold">Summary</h2>
|
||||
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">Member Since</span>
|
||||
@@ -605,7 +606,9 @@
|
||||
{#each stats.topServices as svc (svc.name)}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-fuchsia-100 text-xs font-semibold text-fuchsia-700">
|
||||
<span
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-fuchsia-100 text-xs font-semibold text-fuchsia-700"
|
||||
>
|
||||
{svc.count}
|
||||
</span>
|
||||
<span class="text-sm">{svc.name}</span>
|
||||
@@ -636,7 +639,9 @@
|
||||
{#each secondary as s (s.label)}
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">{s.label}</span>
|
||||
<p class="mt-1 text-lg font-semibold {s.highlight ? 'text-green-600' : ''}">{s.value}</p>
|
||||
<p class="mt-1 text-lg font-semibold {s.highlight ? 'text-green-600' : ''}">
|
||||
{s.value}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">{s.subtitle}</p>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -750,8 +755,8 @@
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
><tr class="border-b"
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Previous Name</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Changed At</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Previous Name</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Changed At</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Booking</th></tr
|
||||
></thead
|
||||
>
|
||||
@@ -762,7 +767,9 @@
|
||||
>{nh.previous_first_name} {nh.previous_last_name}</td
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(nh.changed_at)}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500">{nh.booking_id || '—'}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500"
|
||||
>{nh.booking_id || '—'}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -965,10 +972,11 @@
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
><tr class="border-b"
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Created</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Discount</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Status</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Used At</th></tr
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Created</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Discount</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Status</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Used At</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
@@ -984,7 +992,9 @@
|
||||
>{rd.used ? 'Used' : 'Available'}</span
|
||||
></td
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{rd.used_at ? fmtDateTime(rd.used_at) : '—'}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap"
|
||||
>{rd.used_at ? fmtDateTime(rd.used_at) : '—'}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -1014,10 +1024,10 @@
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(la.created_at)}</td>
|
||||
<td class="px-4 py-3">{la.attempt_type.replace(/_/g, ' ')}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {la.success
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}"
|
||||
>{la.success ? 'Yes' : 'No'}</span
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {la.success
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}">{la.success ? 'Yes' : 'No'}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1049,9 +1059,10 @@
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rt.created_at)}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rt.expires_at)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {rt.revoked
|
||||
? 'bg-gray-100 text-gray-600'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {rt.revoked
|
||||
? 'bg-gray-100 text-gray-600'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
>{rt.revoked ? 'Revoked' : 'Active'}</span
|
||||
>
|
||||
</td>
|
||||
|
||||
@@ -224,21 +224,25 @@
|
||||
}
|
||||
|
||||
// when password changes, re-compute strength
|
||||
let passwordStrength = $derived(formData.password ? (() => {
|
||||
const result = zxcvbn(formData.password);
|
||||
// Add minimum length check (6 chars) to the score feedback
|
||||
if (formData.password.length < 6) {
|
||||
return {
|
||||
...result,
|
||||
score: Math.min(result.score, 0), // Force weak for too short
|
||||
feedback: {
|
||||
warning: 'Password must be at least 6 characters',
|
||||
suggestions: ['Add more characters to meet the minimum length requirement']
|
||||
}
|
||||
};
|
||||
}
|
||||
return result;
|
||||
})() : null);
|
||||
let passwordStrength = $derived(
|
||||
formData.password
|
||||
? (() => {
|
||||
const result = zxcvbn(formData.password);
|
||||
// Add minimum length check (6 chars) to the score feedback
|
||||
if (formData.password.length < 6) {
|
||||
return {
|
||||
...result,
|
||||
score: Math.min(result.score, 0), // Force weak for too short
|
||||
feedback: {
|
||||
warning: 'Password must be at least 6 characters',
|
||||
suggestions: ['Add more characters to meet the minimum length requirement']
|
||||
}
|
||||
};
|
||||
}
|
||||
return result;
|
||||
})()
|
||||
: null
|
||||
);
|
||||
|
||||
// form completion check
|
||||
let isFormComplete = $derived(
|
||||
@@ -252,7 +256,8 @@
|
||||
formData.confirmPassword &&
|
||||
formData.password === formData.confirmPassword &&
|
||||
passwordStrength &&
|
||||
formData.password.length >= 6 && passwordStrength.score >= 2 &&
|
||||
formData.password.length >= 6 &&
|
||||
passwordStrength.score >= 2 &&
|
||||
agreedToPolicy &&
|
||||
!validationErrors.email &&
|
||||
!validationErrors.phone &&
|
||||
@@ -364,18 +369,18 @@
|
||||
{#if !isLogin}
|
||||
<!-- SECTION 1: LOGIN DETAILS -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold">What we need to log you in</h3>
|
||||
<h3 class="text-lg font-semibold">What we need to log you in</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="email" text="Email" />
|
||||
<EmailInput
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
bind:error={validationErrors.email}
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="email" text="Email" />
|
||||
<EmailInput
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
bind:error={validationErrors.email}
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="password" text="Password" />
|
||||
@@ -486,8 +491,9 @@
|
||||
type="date"
|
||||
bind:value={formData.dateOfBirth}
|
||||
onblur={() => validateAge(formData.dateOfBirth)}
|
||||
max={new SvelteDate(new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16))
|
||||
.toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
|
||||
max={new SvelteDate(
|
||||
new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16)
|
||||
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
|
||||
required
|
||||
/>
|
||||
{#if validationErrors.dateOfBirth}
|
||||
@@ -518,20 +524,20 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Login Fields (Simple inline layout) -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="email" text="Email" />
|
||||
<EmailInput
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
bind:error={validationErrors.email}
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="email" text="Email" />
|
||||
<EmailInput
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
bind:error={validationErrors.email}
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="password" text="Password" />
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="password" text="Password" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
|
||||
@@ -347,14 +347,15 @@
|
||||
const tagsParam = page.url.searchParams.get('tags');
|
||||
const imgParam = page.url.searchParams.get('img');
|
||||
|
||||
selectedTag = (tagParam && tagParam.length <= 100) ? tagParam.slice(0, 100) : '';
|
||||
selectedTags = tagsParam && tagsParam.length <= 500
|
||||
? tagsParam
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
selectedTag = tagParam && tagParam.length <= 100 ? tagParam.slice(0, 100) : '';
|
||||
selectedTags =
|
||||
tagsParam && tagsParam.length <= 500
|
||||
? tagsParam
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
|
||||
if (selectedTag) {
|
||||
searchQuery = selectedTag;
|
||||
@@ -459,7 +460,7 @@
|
||||
// Position button so its top edge is `offset` px below the image top edge.
|
||||
// Same offset as the right edge (`right-2` / `sm:right-4`), so the circle
|
||||
// sits equally inside both edges.
|
||||
closeBtnRef.style.top = (imgTopRel + offset) + 'px';
|
||||
closeBtnRef.style.top = imgTopRel + offset + 'px';
|
||||
|
||||
// FLIP from the old position if we have one
|
||||
if (pendingBtnRect) {
|
||||
@@ -468,10 +469,7 @@
|
||||
pendingBtnRect = null;
|
||||
if (Math.abs(dy) > 0.5) {
|
||||
closeBtnRef.animate(
|
||||
[
|
||||
{ transform: `translateY(${dy}px)` },
|
||||
{ transform: 'translateY(0)' }
|
||||
],
|
||||
[{ transform: `translateY(${dy}px)` }, { transform: 'translateY(0)' }],
|
||||
{ duration: BTN_FLIP_MS, easing: BUTTON_EASE }
|
||||
);
|
||||
}
|
||||
@@ -881,7 +879,13 @@
|
||||
{/if}
|
||||
|
||||
{#if showModal}
|
||||
<Dialog open={showModal} onOpenChange={(v) => { if (!v) closeModal(); else showModal = v; }}>
|
||||
<Dialog
|
||||
open={showModal}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) closeModal();
|
||||
else showModal = v;
|
||||
}}
|
||||
>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
|
||||
<DialogContent
|
||||
hideClose={true}
|
||||
@@ -895,62 +899,72 @@
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
<div class="overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="flex w-[300%]"
|
||||
style="transform: translateX({trackOffset}); transition: {trackTransition}"
|
||||
>
|
||||
<div class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
|
||||
{#if prevFullURLs}
|
||||
<ImageVariant
|
||||
urls={prevFullURLs}
|
||||
type="full"
|
||||
alt=""
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div bind:this={centerSlideRef} class="relative flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
|
||||
{#if imageLoading && selectedThumbURLs}
|
||||
<ImageVariant
|
||||
urls={selectedThumbURLs}
|
||||
type="thumb"
|
||||
alt="Loading preview"
|
||||
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
|
||||
></div>
|
||||
<div class="overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="flex w-[300%]"
|
||||
style="transform: translateX({trackOffset}); transition: {trackTransition}"
|
||||
>
|
||||
<div
|
||||
class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
|
||||
style="width: calc(100% / 3)"
|
||||
>
|
||||
{#if prevFullURLs}
|
||||
<ImageVariant
|
||||
urls={prevFullURLs}
|
||||
type="full"
|
||||
alt=""
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedFullURLs}
|
||||
<ImageVariant
|
||||
urls={selectedFullURLs}
|
||||
type="full"
|
||||
alt="Portfolio full size"
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
|
||||
? 'opacity-0'
|
||||
: ''}"
|
||||
onload={handleFullImageLoad}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
bind:this={centerSlideRef}
|
||||
class="relative flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
|
||||
style="width: calc(100% / 3)"
|
||||
>
|
||||
{#if imageLoading && selectedThumbURLs}
|
||||
<ImageVariant
|
||||
urls={selectedThumbURLs}
|
||||
type="thumb"
|
||||
alt="Loading preview"
|
||||
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
|
||||
{#if nextFullURLs}
|
||||
<ImageVariant
|
||||
urls={nextFullURLs}
|
||||
type="full"
|
||||
alt=""
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
{/if}
|
||||
{#if selectedFullURLs}
|
||||
<ImageVariant
|
||||
urls={selectedFullURLs}
|
||||
type="full"
|
||||
alt="Portfolio full size"
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
|
||||
? 'opacity-0'
|
||||
: ''}"
|
||||
onload={handleFullImageLoad}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
|
||||
style="width: calc(100% / 3)"
|
||||
>
|
||||
{#if nextFullURLs}
|
||||
<ImageVariant
|
||||
urls={nextFullURLs}
|
||||
type="full"
|
||||
alt=""
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
bind:this={closeBtnRef}
|
||||
|
||||
@@ -159,9 +159,13 @@
|
||||
<Card.Root class="border-dashed border-gray-200 bg-gray-50/50 shadow-sm">
|
||||
<Card.Content class="p-6 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Need something different? Select the closest service and add a note, or contact us for a bespoke treatment.
|
||||
Need something different? Select the closest service and add a note, or contact us for a
|
||||
bespoke treatment.
|
||||
</p>
|
||||
<a href={resolve('/contact')} class="mt-1 inline-block text-sm font-medium text-blue-600 hover:underline">
|
||||
<a
|
||||
href={resolve('/contact')}
|
||||
class="mt-1 inline-block text-sm font-medium text-blue-600 hover:underline"
|
||||
>
|
||||
Arrange a custom booking →
|
||||
</a>
|
||||
</Card.Content>
|
||||
@@ -216,9 +220,9 @@
|
||||
<Card.Content class="p-6 text-center md:p-8">
|
||||
<h2 class="mb-4 text-lg font-semibold text-primary md:text-xl">Ready to Book?</h2>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-center md:gap-4">
|
||||
<Button href={resolve('/book')} class="px-6 py-3 md:px-8">Book Appointment</Button>
|
||||
<Button
|
||||
href={resolve('/contact')}
|
||||
<Button href={resolve('/book')} class="px-6 py-3 md:px-8">Book Appointment</Button>
|
||||
<Button
|
||||
href={resolve('/contact')}
|
||||
variant="outline"
|
||||
class="border-primary/30 px-6 py-3 hover:bg-primary/10 md:px-8"
|
||||
>
|
||||
|
||||
@@ -201,12 +201,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if pageState === 'unauthorized'}
|
||||
<div class="mx-auto max-w-4xl px-4 py-16 text-center md:px-8">
|
||||
<p class="text-gray-500">Please log in to view your schedule.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
|
||||
<div class="mb-10 text-center">
|
||||
@@ -234,10 +232,11 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if bookings.length === 0}
|
||||
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50/50 py-16 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8 text-gray-400"
|
||||
fill="none"
|
||||
@@ -253,10 +252,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-1 text-lg font-semibold text-gray-900">No Upcoming Appointments</h3>
|
||||
<p class="mb-6 text-sm text-gray-500">You don't have any upcoming appointments scheduled.</p>
|
||||
<p class="mb-6 text-sm text-gray-500">
|
||||
You don't have any upcoming appointments scheduled.
|
||||
</p>
|
||||
<Button href={resolve('/book')}>Book an Appointment</Button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="space-y-10">
|
||||
{#each bookingGroups as group (group.key)}
|
||||
@@ -271,15 +271,19 @@
|
||||
<div class="space-y-4">
|
||||
{#each group.bookings as booking (booking.id)}
|
||||
{@const cfg = getConfig(booking.status)}
|
||||
{@const serviceNames = booking.services?.map((s: BookingService) => s.service_name).filter(Boolean).join(', ') || ''}
|
||||
{@const serviceNames =
|
||||
booking.services
|
||||
?.map((s: BookingService) => s.service_name)
|
||||
.filter(Boolean)
|
||||
.join(', ') || ''}
|
||||
|
||||
<div
|
||||
in:fly={{ y: 12, duration: 300, delay: 50 }}
|
||||
class="group/card relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<div class="absolute left-0 top-0 h-full w-1 {cfg.bar}"></div>
|
||||
<div class="absolute top-0 left-0 h-full w-1 {cfg.bar}"></div>
|
||||
|
||||
<div class="pl-5 pr-5 pt-4 pb-4 md:pl-6 md:pr-6 md:pt-5 md:pb-5">
|
||||
<div class="pt-4 pr-5 pb-4 pl-5 md:pt-5 md:pr-6 md:pb-5 md:pl-6">
|
||||
<div class="mb-3 flex items-start justify-between gap-3 md:mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
@@ -304,11 +308,12 @@
|
||||
{formatEndTime(booking.start_time, booking.duration_minutes || 0)}
|
||||
</span>
|
||||
{#if booking.duration_minutes}
|
||||
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span>
|
||||
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-none {cfg.badge}"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs leading-none font-medium {cfg.badge}"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full {cfg.dot}"></span>
|
||||
{cfg.label}
|
||||
@@ -319,10 +324,14 @@
|
||||
|
||||
<div>
|
||||
{#if booking.services && booking.services.length > 0}
|
||||
<p class="font-medium text-gray-900">{serviceNames}</p>
|
||||
<p class="font-medium text-gray-900">{serviceNames}</p>
|
||||
{/if}
|
||||
|
||||
<div class="{booking.services && booking.services.length > 0 ? 'mt-3 md:mt-4' : ''} flex items-center justify-between">
|
||||
<div
|
||||
class="{booking.services && booking.services.length > 0
|
||||
? 'mt-3 md:mt-4'
|
||||
: ''} flex items-center justify-between"
|
||||
>
|
||||
{#if booking.total_amount}
|
||||
<span class="text-lg font-bold text-gray-900">
|
||||
£{booking.total_amount.toFixed(2)}
|
||||
@@ -346,11 +355,7 @@
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -80,14 +80,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
try {
|
||||
var token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.replace('/login'); return; }
|
||||
if (!token) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
|
||||
if (payload.role !== 'admin') { window.location.replace('/'); }
|
||||
} catch(e) { window.location.replace('/login'); }
|
||||
if (payload.exp * 1000 <= Date.now()) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (payload.role !== 'admin') {
|
||||
window.location.replace('/');
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</svelte:head>
|
||||
@@ -130,7 +140,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Current/Next Appointment Card (Full Width) -->
|
||||
<CurrentAppointment _openBookingModal={openBookingModal} {openEditBookingModal} {openUserModal} />
|
||||
<CurrentAppointment
|
||||
_openBookingModal={openBookingModal}
|
||||
{openEditBookingModal}
|
||||
{openUserModal}
|
||||
/>
|
||||
|
||||
<!-- Quick Booking + Till Purchases Grid -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
||||
|
||||
Reference in New Issue
Block a user