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 { 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 @@
</div>
<div class="space-y-2">
<Label for="guest-phone">Phone Number *</Label>
<input
<PhoneInput
id="guest-phone"
type="tel"
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
? 'border-red-500'
: 'border-input'}"
bind:value={guestPhone}
bind:error={guestPhoneError}
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>
<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
@@ -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();
@@ -92,6 +92,7 @@
let hasEligiblePatchTests = $state(false);
let customerRelationship = $state<CustomerRelationship | null>(null);
let loadingRelationship = $state(false);
let giftCardBalance = $state<number | null>(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 @@
</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}
<div class="mt-4">
<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 { 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 @@
</div>
<div class="space-y-2">
<Label for="guest-phone">Phone Number</Label>
<input
<PhoneInput
id="guest-phone"
type="tel"
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
? 'border-red-500'
: 'border-input'}"
bind:value={guestPhone}
bind:error={guestPhoneError}
placeholder="07700 900000"
value={guestPhone}
oninput={(e) => {
guestPhone = e.currentTarget.value;
}}
onblur={() => {
if (guestPhone && !isValidUKPhone(guestPhone))
guestPhoneError = 'Invalid UK phone number';
else guestPhoneError = '';
}}
required={false}
/>
{#if guestPhoneError}
<p class="mt-1 text-xs text-red-600">{guestPhoneError}</p>
{/if}
</div>
<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
@@ -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<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
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}
/>
</div>
<div class="space-y-2">
@@ -1630,6 +1708,7 @@
id="lastName"
bind:value={customerInfo.lastName}
placeholder="Enter your last name"
onblur={debouncedEmailCheck}
/>
</div>
<div class="space-y-2">
@@ -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}
<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 class="space-y-2">
<Label for="phone">Phone Number *</Label>
<Input
<PhoneInput
id="phone"
type="tel"
bind:value={customerInfo.phone}
placeholder="Enter your phone number"
placeholder="07123 456789"
onerrorchange={(err) => {
if (!err) debouncedEmailCheck();
}}
/>
</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}
<div class="space-y-2">
@@ -2000,8 +2109,9 @@
{#if isRequested}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="text-sm text-amber-800">
<strong>Please note:</strong> Because you included special requests, the cost and duration
shown are estimates. We may adjust these after reviewing your requirements. You'll receive
<strong>Please note:</strong> 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.
</p>
</div>