From e7634ba1874b317166d91a8b81bb78389c389ecf Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 11 Jun 2026 22:08:28 +0100 Subject: [PATCH] feat(frontend): add email check and gift card support to booking flow Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../admin/BookingCreateModal.svelte | 25 +--- .../lib/components/admin/BookingsCard.svelte | 2 +- .../src/lib/components/admin/UserModal.svelte | 24 ++++ .../components/admin/WalkInCreateModal.svelte | 28 ++-- .../lib/components/booking/BookingFlow.svelte | 126 ++++++++++++++++-- frontend/src/routes/account/+page.svelte | 89 ++++++++++--- frontend/src/routes/login/+page.svelte | 29 ++-- 7 files changed, 242 insertions(+), 81 deletions(-) diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index c978d31..c714581 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -15,6 +15,7 @@ import { Separator } from '$lib/components/ui/separator'; import { Skeleton } from '$lib/components/ui/skeleton'; import CharCounter from '$lib/components/ui/CharCounter.svelte'; + import { PhoneInput } from '$lib/components/ui/phone-input/index.js'; // Booking Components import BookingActions from '$lib/components/booking/BookingActions.svelte'; @@ -155,7 +156,9 @@ ); const canProceedStep1 = $derived( - userType === 'member' ? !!selectedUserId : !!(guestName.trim() && guestPhone.trim()) + userType === 'member' + ? !!selectedUserId + : !!(guestName.trim() && guestPhone.trim() && isValidUKPhone(guestPhone)) ); const canProceedStep2 = $derived(selectedServices.length > 0); const canProceedStep3 = $derived(true); // Overrides are optional @@ -939,26 +942,12 @@
- { - guestPhone = e.currentTarget.value; - }} - onblur={() => { - if (guestPhone && !isValidUKPhone(guestPhone)) - guestPhoneError = 'Invalid UK phone number'; - else guestPhoneError = ''; - }} /> - {#if guestPhoneError} -

{guestPhoneError}

- {/if}

Booking as a guest creates a temporary record. Encourage them to sign up for diff --git a/frontend/src/lib/components/admin/BookingsCard.svelte b/frontend/src/lib/components/admin/BookingsCard.svelte index e9de38e..9f4a8e9 100644 --- a/frontend/src/lib/components/admin/BookingsCard.svelte +++ b/frontend/src/lib/components/admin/BookingsCard.svelte @@ -59,7 +59,7 @@ bookings = data.bookings as Booking[]; totalBookings = data.total || 0; - totalPages = data.total_pages || 1; + totalPages = data.totalPages ?? 1; currentPage = data.page || 1; } else { const text = await response.text(); diff --git a/frontend/src/lib/components/admin/UserModal.svelte b/frontend/src/lib/components/admin/UserModal.svelte index 251b790..e4ed913 100644 --- a/frontend/src/lib/components/admin/UserModal.svelte +++ b/frontend/src/lib/components/admin/UserModal.svelte @@ -92,6 +92,7 @@ let hasEligiblePatchTests = $state(false); let customerRelationship = $state(null); let loadingRelationship = $state(false); + let giftCardBalance = $state(null); async function fetchUserDetails() { if (!userId) return; @@ -217,11 +218,25 @@ } } + async function fetchGiftCardBalance() { + if (!userId) return; + try { + const res = await fetch(`/api/admin/users/${userId}/giftcard-balance`, { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (res.ok) { + const data = await res.json(); + giftCardBalance = data.balance; + } + } catch { /* ignore */ } + } + $effect(() => { if (open && userId) { fetchUserDetails(); fetchUserBookings(); fetchCustomerRelationship(); + fetchGiftCardBalance(); } }); @@ -519,6 +534,15 @@ + {#if giftCardBalance !== null && giftCardBalance > 0} +

+
Gift Card Balance
+
+ £{giftCardBalance.toFixed(2)} +
+
+ {/if} + {#if customerRelationship.topServices && customerRelationship.topServices.length > 0}
Most Booked Services
diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index f5661cb..063eb0b 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -13,6 +13,7 @@ import { Separator } from '$lib/components/ui/separator'; import { Skeleton } from '$lib/components/ui/skeleton'; import CharCounter from '$lib/components/ui/CharCounter.svelte'; + import { PhoneInput } from '$lib/components/ui/phone-input/index.js'; // Booking Components import BookingActions from '$lib/components/booking/BookingActions.svelte'; @@ -110,7 +111,11 @@ const isOverDuration = $derived(getTotalDuration() > maxSlotDuration); - const canProceedStep1 = $derived(userType === 'member' ? !!selectedUserId : !!guestName.trim()); + const canProceedStep1 = $derived( + userType === 'member' + ? !!selectedUserId + : !!(guestName.trim() && guestPhone.trim() && isValidUKPhone(guestPhone)) + ); const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration); // =============== Effects =============== @@ -622,26 +627,13 @@
- { - guestPhone = e.currentTarget.value; - }} - onblur={() => { - if (guestPhone && !isValidUKPhone(guestPhone)) - guestPhoneError = 'Invalid UK phone number'; - else guestPhoneError = ''; - }} + required={false} /> - {#if guestPhoneError} -

{guestPhoneError}

- {/if}

Booking as a guest creates a temporary record. Encourage them to sign up for diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index b14ffc7..cedfe1e 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -7,6 +7,8 @@ import CharCounter from '$lib/components/ui/CharCounter.svelte'; import { Separator } from '$lib/components/ui/separator/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js'; + import { PhoneInput } from '$lib/components/ui/phone-input/index.js'; + import { isValidUKPhone, toE164UK } from '$lib/utils/phone'; // INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only // salon app. All customers are physically in the UK and book UK appointment slots. We do NOT // auto-adjust for international timezones — the slot time shown is the actual UK salon time. @@ -87,6 +89,77 @@ newCardCVC.length >= 3) ); + // Email existence check (guest flow only) + let emailChecking = $state(false); + let emailSuggestion = $state(null); + let emailError = $state(''); + let emailFormatValid = $derived( + !customerInfo.email || + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(customerInfo.email) + ); + let allGuestFieldsValid = $derived( + customerInfo.firstName && + customerInfo.lastName && + customerInfo.email && + emailFormatValid && + customerInfo.phone && + isValidUKPhone(customerInfo.phone) + ); + + function validateEmailFormat(email: string) { + if (!email) { + emailError = ''; + return; + } + if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email)) { + emailError = 'Please enter a valid email address'; + } else { + emailError = ''; + } + } + + async function checkEmailExists(email: string) { + if (!email || !/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email)) { + emailSuggestion = null; + return; + } + // Don't check unless ALL guest fields have valid input + if (!allGuestFieldsValid) { + emailSuggestion = null; + return; + } + + emailChecking = true; + try { + // Send all 4 fields to enable the backend to match beyond just email + const params = new URLSearchParams({ + email: email.toLowerCase(), + firstName: customerInfo.firstName, + lastName: customerInfo.lastName, + phone: toE164UK(customerInfo.phone) ?? customerInfo.phone + }); + const resp = await fetch(`/api/check-email?${params}`); + if (resp.ok) { + const data = await resp.json(); + emailSuggestion = data.suggestion ?? null; + } + } catch { + // Network error - silently ignore, don't block booking + emailSuggestion = null; + } finally { + emailChecking = false; + } + } + + let emailCheckTimeout: ReturnType | null = null; + + function debouncedEmailCheck() { + if (emailCheckTimeout) clearTimeout(emailCheckTimeout); + if (allGuestFieldsValid) { + emailCheckTimeout = setTimeout(() => checkEmailExists(customerInfo.email), 500); + } + } + // Confirmation state let confirmedBooking = $state<{ id: string; @@ -172,7 +245,8 @@ const data = await response.json(); paymentMethods = data.payment_methods ?? []; if (paymentMethods.length > 0 && !selectedPaymentMethod) { - const defaultCard = paymentMethods.find((m) => 'is_default' in m && m.is_default) ?? paymentMethods[0]; + const defaultCard = + paymentMethods.find((m) => 'is_default' in m && m.is_default) ?? paymentMethods[0]; selectedPaymentMethod = defaultCard.id; } } else { @@ -1222,7 +1296,7 @@ firstName: customerInfo.firstName, lastName: customerInfo.lastName, email: customerInfo.email, - phone: customerInfo.phone + phone: toE164UK(customerInfo.phone) ?? customerInfo.phone }) }); @@ -1348,7 +1422,10 @@ customerInfo.firstName && customerInfo.lastName && customerInfo.email && - customerInfo.phone + emailFormatValid && + customerInfo.phone && + isValidUKPhone(customerInfo.phone) && + !emailSuggestion )) && !reservationExpired ); const canProceedStep4 = $derived(true); @@ -1622,6 +1699,7 @@ id="firstName" bind:value={customerInfo.firstName} placeholder="Enter your first name" + onblur={debouncedEmailCheck} />

@@ -1630,6 +1708,7 @@ id="lastName" bind:value={customerInfo.lastName} placeholder="Enter your last name" + onblur={debouncedEmailCheck} />
@@ -1639,18 +1718,48 @@ type="email" bind:value={customerInfo.email} placeholder="Enter your email" + onblur={() => { + validateEmailFormat(customerInfo.email); + debouncedEmailCheck(); + }} + oninput={() => { + emailSuggestion = null; + emailError = ''; + debouncedEmailCheck(); + }} /> + {#if emailError} + {emailError} + {/if} + {#if emailChecking} + Checking... + {/if}
- { + if (!err) debouncedEmailCheck(); + }} />
+ + {#if emailSuggestion === 'login'} +

+ This email belongs to a registered user. Please + log in + instead to access your bookings and rewards. +

+ {:else if emailSuggestion === 'check'} +

+ This email might belong to an existing account. Please double-check or + log in. +

+ {/if} {/if}
@@ -2000,8 +2109,9 @@ {#if isRequested}

- Please note: Because you included special requests, the cost and duration - shown are estimates. We may adjust these after reviewing your requirements. You'll receive + Please note: Because you included special requests, the cost and + duration shown are estimates. We may adjust these after reviewing your requirements. + You'll receive {authStore.isAuthenticated ? ' a notification' : ' an email'} once your booking is approved.

diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index da60216..daa43c1 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -28,6 +28,7 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import * as Card from '$lib/components/ui/card'; import { Input } from '$lib/components/ui/input'; + import { PhoneInput } from '$lib/components/ui/phone-input/index.js'; import { Separator } from '$lib/components/ui/separator'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Skeleton } from '$lib/components/ui/skeleton'; @@ -156,6 +157,7 @@ let giftCardCode = $state(''); let redeemingGiftCard = $state(false); + let showRedeemConfirm = $state(false); // Buy Gift Card State let buyAmount = $state<10 | 20 | 50>(10); @@ -728,7 +730,7 @@ } function formatPhoneInput(value: string): string { - return formatPhoneDisplay(phone); + return formatPhoneDisplay(value); } function startEditPhone() { @@ -746,6 +748,8 @@ async function savePhone() { const formattedPhone = toE164UK(phoneInput); if (!formattedPhone) { + phoneError = 'Invalid UK phone number'; + toast.error('Please enter a valid UK phone number'); return; } @@ -1386,18 +1390,14 @@ Phone {#if editingPhone}
- - {#if phoneError} -

{phoneError}

- {/if}
- +
+ + + + Redeem Gift Card + + Claiming this gift card will add its remaining balance directly to your + account balance, which can be used toward future bookings. + + +
+
+

+ What happens when I claim? +

+
    +
  • The gift card value is added to your account balance.
  • +
  • + Account balances do not expire, but gift card codes become invalid once + redeemed. +
  • +
  • + This action is final and cannot be reversed. +
  • +
+
+
+

+ Legal & GDPR Information +

+
    +
  • + Your personal data (name, email, transaction history) is processed in + accordance with UK data protection law. +
  • +
  • + Financial records are retained for 7 years as required by HMRC, after + which personally identifiable information is anonymised. +
  • +
  • + You can request a full copy of your data or deletion of your account + at any time via your account settings. +
  • +
+
+ +

+ By redeeming this gift card, you agree to our Terms & Conditions. + Link T&Cs here once available. +

+
+ + Cancel + Confirm & Redeem + +
+
+ diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index f40bc0c..3b19bb3 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -2,11 +2,12 @@ import { resolve } from '$app/paths'; import { Button } from '$lib/components/ui/button/index.js'; import { Input } from '$lib/components/ui/input/index.js'; + import { PhoneInput } from '$lib/components/ui/phone-input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import { Separator } from '$lib/components/ui/separator/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import RequiredLabel from '$lib/components/layout/RequiredLabel.svelte'; - import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone'; + import { isValidUKPhone, toE164UK } from '$lib/utils/phone'; import { SvelteDate } from 'svelte/reactivity'; // zxcvbn-ts imports @@ -98,17 +99,9 @@ } function validatePhone(phone: string): boolean { - return isValidUKPhone(phone); - } - - function formatPhoneInput(value: string): string { - return formatPhoneDisplay(value); - } - - function handlePhoneInput(e: Event) { - const target = e.target as HTMLInputElement; - const rawValue = target.value; - formData.phone = formatPhoneInput(rawValue); + const valid = isValidUKPhone(phone); + validationErrors.phone = valid ? '' : 'Please enter a valid UK phone number'; + return valid; } // Age validation (must be 16+) @@ -498,19 +491,13 @@
- validatePhone(formData.phone)} required /> - {#if validationErrors.phone} -

{validationErrors.phone}

- {/if}