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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-11 22:08:28 +01:00
co-authored by Sisyphus
parent 7a459a3986
commit e7634ba187
7 changed files with 242 additions and 81 deletions
@@ -15,6 +15,7 @@
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import CharCounter from '$lib/components/ui/CharCounter.svelte'; import CharCounter from '$lib/components/ui/CharCounter.svelte';
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
// Booking Components // Booking Components
import BookingActions from '$lib/components/booking/BookingActions.svelte'; import BookingActions from '$lib/components/booking/BookingActions.svelte';
@@ -155,7 +156,9 @@
); );
const canProceedStep1 = $derived( 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 canProceedStep2 = $derived(selectedServices.length > 0);
const canProceedStep3 = $derived(true); // Overrides are optional const canProceedStep3 = $derived(true); // Overrides are optional
@@ -939,26 +942,12 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="guest-phone">Phone Number *</Label> <Label for="guest-phone">Phone Number *</Label>
<input <PhoneInput
id="guest-phone" id="guest-phone"
type="tel" bind:value={guestPhone}
class="flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {guestPhoneError bind:error={guestPhoneError}
? 'border-red-500'
: 'border-input'}"
placeholder="07700 900000" placeholder="07700 900000"
value={guestPhone}
oninput={(e) => {
guestPhone = e.currentTarget.value;
}}
onblur={() => {
if (guestPhone && !isValidUKPhone(guestPhone))
guestPhoneError = 'Invalid UK phone number';
else guestPhoneError = '';
}}
/> />
{#if guestPhoneError}
<p class="mt-1 text-xs text-red-600">{guestPhoneError}</p>
{/if}
</div> </div>
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800"> <p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
Booking as a guest creates a temporary record. Encourage them to sign up for Booking as a guest creates a temporary record. Encourage them to sign up for
@@ -59,7 +59,7 @@
bookings = data.bookings as Booking[]; bookings = data.bookings as Booking[];
totalBookings = data.total || 0; totalBookings = data.total || 0;
totalPages = data.total_pages || 1; totalPages = data.totalPages ?? 1;
currentPage = data.page || 1; currentPage = data.page || 1;
} else { } else {
const text = await response.text(); const text = await response.text();
@@ -92,6 +92,7 @@
let hasEligiblePatchTests = $state(false); let hasEligiblePatchTests = $state(false);
let customerRelationship = $state<CustomerRelationship | null>(null); let customerRelationship = $state<CustomerRelationship | null>(null);
let loadingRelationship = $state(false); let loadingRelationship = $state(false);
let giftCardBalance = $state<number | null>(null);
async function fetchUserDetails() { async function fetchUserDetails() {
if (!userId) return; 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(() => { $effect(() => {
if (open && userId) { if (open && userId) {
fetchUserDetails(); fetchUserDetails();
fetchUserBookings(); fetchUserBookings();
fetchCustomerRelationship(); fetchCustomerRelationship();
fetchGiftCardBalance();
} }
}); });
@@ -519,6 +534,15 @@
</div> </div>
</div> </div>
{#if giftCardBalance !== null && giftCardBalance > 0}
<div class="mt-3 border-t pt-3">
<div class="text-xs text-gray-500">Gift Card Balance</div>
<div class="text-2xl font-bold text-green-600">
£{giftCardBalance.toFixed(2)}
</div>
</div>
{/if}
{#if customerRelationship.topServices && customerRelationship.topServices.length > 0} {#if customerRelationship.topServices && customerRelationship.topServices.length > 0}
<div class="mt-4"> <div class="mt-4">
<div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div> <div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div>
@@ -13,6 +13,7 @@
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import CharCounter from '$lib/components/ui/CharCounter.svelte'; import CharCounter from '$lib/components/ui/CharCounter.svelte';
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
// Booking Components // Booking Components
import BookingActions from '$lib/components/booking/BookingActions.svelte'; import BookingActions from '$lib/components/booking/BookingActions.svelte';
@@ -110,7 +111,11 @@
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration); 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); const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
// =============== Effects =============== // =============== Effects ===============
@@ -622,26 +627,13 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="guest-phone">Phone Number</Label> <Label for="guest-phone">Phone Number</Label>
<input <PhoneInput
id="guest-phone" id="guest-phone"
type="tel" bind:value={guestPhone}
class="flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {guestPhoneError bind:error={guestPhoneError}
? 'border-red-500'
: 'border-input'}"
placeholder="07700 900000" placeholder="07700 900000"
value={guestPhone} required={false}
oninput={(e) => {
guestPhone = e.currentTarget.value;
}}
onblur={() => {
if (guestPhone && !isValidUKPhone(guestPhone))
guestPhoneError = 'Invalid UK phone number';
else guestPhoneError = '';
}}
/> />
{#if guestPhoneError}
<p class="mt-1 text-xs text-red-600">{guestPhoneError}</p>
{/if}
</div> </div>
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800"> <p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
Booking as a guest creates a temporary record. Encourage them to sign up for Booking as a guest creates a temporary record. Encourage them to sign up for
@@ -7,6 +7,8 @@
import CharCounter from '$lib/components/ui/CharCounter.svelte'; import CharCounter from '$lib/components/ui/CharCounter.svelte';
import { Separator } from '$lib/components/ui/separator/index.js'; import { Separator } from '$lib/components/ui/separator/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/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 // 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 // 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. // auto-adjust for international timezones — the slot time shown is the actual UK salon time.
@@ -87,6 +89,77 @@
newCardCVC.length >= 3) newCardCVC.length >= 3)
); );
// Email existence check (guest flow only)
let emailChecking = $state(false);
let emailSuggestion = $state<string | null>(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<typeof setTimeout> | null = null;
function debouncedEmailCheck() {
if (emailCheckTimeout) clearTimeout(emailCheckTimeout);
if (allGuestFieldsValid) {
emailCheckTimeout = setTimeout(() => checkEmailExists(customerInfo.email), 500);
}
}
// Confirmation state // Confirmation state
let confirmedBooking = $state<{ let confirmedBooking = $state<{
id: string; id: string;
@@ -172,7 +245,8 @@
const data = await response.json(); const data = await response.json();
paymentMethods = data.payment_methods ?? []; paymentMethods = data.payment_methods ?? [];
if (paymentMethods.length > 0 && !selectedPaymentMethod) { 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; selectedPaymentMethod = defaultCard.id;
} }
} else { } else {
@@ -1222,7 +1296,7 @@
firstName: customerInfo.firstName, firstName: customerInfo.firstName,
lastName: customerInfo.lastName, lastName: customerInfo.lastName,
email: customerInfo.email, email: customerInfo.email,
phone: customerInfo.phone phone: toE164UK(customerInfo.phone) ?? customerInfo.phone
}) })
}); });
@@ -1348,7 +1422,10 @@
customerInfo.firstName && customerInfo.firstName &&
customerInfo.lastName && customerInfo.lastName &&
customerInfo.email && customerInfo.email &&
customerInfo.phone emailFormatValid &&
customerInfo.phone &&
isValidUKPhone(customerInfo.phone) &&
!emailSuggestion
)) && !reservationExpired )) && !reservationExpired
); );
const canProceedStep4 = $derived(true); const canProceedStep4 = $derived(true);
@@ -1622,6 +1699,7 @@
id="firstName" id="firstName"
bind:value={customerInfo.firstName} bind:value={customerInfo.firstName}
placeholder="Enter your first name" placeholder="Enter your first name"
onblur={debouncedEmailCheck}
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -1630,6 +1708,7 @@
id="lastName" id="lastName"
bind:value={customerInfo.lastName} bind:value={customerInfo.lastName}
placeholder="Enter your last name" placeholder="Enter your last name"
onblur={debouncedEmailCheck}
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -1639,18 +1718,48 @@
type="email" type="email"
bind:value={customerInfo.email} bind:value={customerInfo.email}
placeholder="Enter your email" placeholder="Enter your email"
onblur={() => {
validateEmailFormat(customerInfo.email);
debouncedEmailCheck();
}}
oninput={() => {
emailSuggestion = null;
emailError = '';
debouncedEmailCheck();
}}
/> />
{#if emailError}
<span class="text-xs font-medium text-red-500">{emailError}</span>
{/if}
{#if emailChecking}
<span class="text-xs text-gray-500">Checking...</span>
{/if}
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="phone">Phone Number *</Label> <Label for="phone">Phone Number *</Label>
<Input <PhoneInput
id="phone" id="phone"
type="tel"
bind:value={customerInfo.phone} bind:value={customerInfo.phone}
placeholder="Enter your phone number" placeholder="07123 456789"
onerrorchange={(err) => {
if (!err) debouncedEmailCheck();
}}
/> />
</div> </div>
</div> </div>
{#if emailSuggestion === 'login'}
<p class="text-xs font-medium text-red-500">
This email belongs to a registered user. Please
<a href="/login" class="underline hover:text-red-800">log in</a>
instead to access your bookings and rewards.
</p>
{:else if emailSuggestion === 'check'}
<p class="text-xs font-medium text-amber-600">
This email might belong to an existing account. Please double-check or
<a href="/login" class="underline hover:text-amber-800">log in</a>.
</p>
{/if}
{/if} {/if}
<div class="space-y-2"> <div class="space-y-2">
@@ -2000,8 +2109,9 @@
{#if isRequested} {#if isRequested}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4"> <div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="text-sm text-amber-800"> <p class="text-sm text-amber-800">
<strong>Please note:</strong> Because you included special requests, the cost and duration <strong>Please note:</strong> Because you included special requests, the cost and
shown are estimates. We may adjust these after reviewing your requirements. You'll receive 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. {authStore.isAuthenticated ? ' a notification' : ' an email'} once your booking is approved.
</p> </p>
</div> </div>
+74 -15
View File
@@ -28,6 +28,7 @@
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; 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 { Separator } from '$lib/components/ui/separator';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
@@ -156,6 +157,7 @@
let giftCardCode = $state(''); let giftCardCode = $state('');
let redeemingGiftCard = $state(false); let redeemingGiftCard = $state(false);
let showRedeemConfirm = $state(false);
// Buy Gift Card State // Buy Gift Card State
let buyAmount = $state<10 | 20 | 50>(10); let buyAmount = $state<10 | 20 | 50>(10);
@@ -728,7 +730,7 @@
} }
function formatPhoneInput(value: string): string { function formatPhoneInput(value: string): string {
return formatPhoneDisplay(phone); return formatPhoneDisplay(value);
} }
function startEditPhone() { function startEditPhone() {
@@ -746,6 +748,8 @@
async function savePhone() { async function savePhone() {
const formattedPhone = toE164UK(phoneInput); const formattedPhone = toE164UK(phoneInput);
if (!formattedPhone) { if (!formattedPhone) {
phoneError = 'Invalid UK phone number';
toast.error('Please enter a valid UK phone number');
return; return;
} }
@@ -1386,18 +1390,14 @@
<span class="text-sm font-medium text-gray-600">Phone</span> <span class="text-sm font-medium text-gray-600">Phone</span>
{#if editingPhone} {#if editingPhone}
<div class="mt-1 space-y-2"> <div class="mt-1 space-y-2">
<Input <PhoneInput
id="phone" id="phone"
type="tel"
bind:value={phoneInput} bind:value={phoneInput}
bind:error={phoneError}
placeholder="Enter phone number" placeholder="Enter phone number"
class="font-medium"
/> />
{#if phoneError}
<p class="text-sm text-red-500">{phoneError}</p>
{/if}
<div class="flex gap-2"> <div class="flex gap-2">
<Button size="sm" onclick={savePhone} disabled={savingPhone}> <Button size="sm" onclick={savePhone} disabled={savingPhone || !isValidUKPhone(phoneInput)}>
{savingPhone ? 'Saving...' : 'Save'} {savingPhone ? 'Saving...' : 'Save'}
</Button> </Button>
<Button <Button
@@ -1910,18 +1910,77 @@
oninput={handleGiftCardInput} oninput={handleGiftCardInput}
class="font-mono" class="font-mono"
/> />
<Button <Button
onclick={redeemGiftCard} onclick={() => (showRedeemConfirm = true)}
disabled={redeemingGiftCard || disabled={redeemingGiftCard ||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12} giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
> >
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'} {redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button> </Button>
</div> </div>
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<AlertDialog.Root bind:open={showRedeemConfirm}>
<AlertDialog.Content>
<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.
</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">
<p>
<strong class="text-foreground">What happens when I claim?</strong>
</p>
<ul class="list-disc pl-4 space-y-1">
<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>
</ul>
</div>
<div class="rounded-lg border bg-blue-50/50 p-3 space-y-2">
<p>
<strong class="text-foreground">Legal &amp; GDPR Information</strong>
</p>
<ul class="list-disc pl-4 space-y-1">
<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.
</li>
<li>
You can request a full copy of your data or deletion of your account
at any time via your account settings.
</li>
</ul>
</div>
<!-- TODO: Link full Terms & Conditions once the T&Cs page is created -->
<p class="text-xs text-muted-foreground italic">
By redeeming this gift card, you agree to our Terms &amp; Conditions.
<em>Link T&amp;Cs here once available.</em>
</p>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={redeemGiftCard}
>Confirm &amp; Redeem</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Buy Gift Card --> <!-- Buy Gift Card -->
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
+8 -21
View File
@@ -2,11 +2,12 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/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 { Label } from '$lib/components/ui/label/index.js';
import { Separator } from '$lib/components/ui/separator/index.js'; import { Separator } from '$lib/components/ui/separator/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import RequiredLabel from '$lib/components/layout/RequiredLabel.svelte'; 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'; import { SvelteDate } from 'svelte/reactivity';
// zxcvbn-ts imports // zxcvbn-ts imports
@@ -98,17 +99,9 @@
} }
function validatePhone(phone: string): boolean { function validatePhone(phone: string): boolean {
return isValidUKPhone(phone); const valid = isValidUKPhone(phone);
} validationErrors.phone = valid ? '' : 'Please enter a valid UK phone number';
return valid;
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);
} }
// Age validation (must be 16+) // Age validation (must be 16+)
@@ -498,19 +491,13 @@
<div class="space-y-2"> <div class="space-y-2">
<RequiredLabel forId="phone" text="Phone Number" /> <RequiredLabel forId="phone" text="Phone Number" />
<Input <PhoneInput
id="phone" id="phone"
type="tel" bind:value={formData.phone}
bind:error={validationErrors.phone}
placeholder="07123 456789 or +44 7123 456789" placeholder="07123 456789 or +44 7123 456789"
maxlength={20}
value={formData.phone}
oninput={handlePhoneInput}
onblur={() => validatePhone(formData.phone)}
required required
/> />
{#if validationErrors.phone}
<p class="text-sm text-red-500">{validationErrors.phone}</p>
{/if}
</div> </div>
<div class="space-y-2"> <div class="space-y-2">