style: apply prettier formatting to frontend
Backend CI / Lint & vulns (push) Failing after 1m59s
Backend CI / Tests (push) Successful in 2m1s
Backend CI / Race detector (push) Failing after 4m0s

This commit is contained in:
2026-06-25 13:26:26 +01:00
parent d4664c177c
commit ad0ad253ad
74 changed files with 3927 additions and 2632 deletions
+169 -117
View File
@@ -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 &amp; 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}
+15 -5
View File
@@ -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
+44 -15
View File
@@ -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
View File
@@ -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 shouldnt 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* bookingrelated state and logic out of `+page.svelte` into this file.
Move _all_ bookingrelated 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 *cant* explain where a rule lives in one sentence, its in the wrong place.
If you _cant_ explain where a rule lives in one sentence, its 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. Thats it.
@@ -557,34 +550,34 @@ It emits intent. Thats 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>
+35 -20
View File
@@ -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>
+2 -2
View File
@@ -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>
+51 -40
View File
@@ -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>
+48 -42
View File
@@ -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"
+80 -66
View File
@@ -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}
+9 -5
View File
@@ -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"
>
+23 -18
View File
@@ -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>
+20 -6
View File
@@ -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">