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:
@@ -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>
|
||||
|
||||
@@ -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 @@
|
||||
<span class="text-sm font-medium text-gray-600">Phone</span>
|
||||
{#if editingPhone}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
<PhoneInput
|
||||
id="phone"
|
||||
type="tel"
|
||||
bind:value={phoneInput}
|
||||
bind:error={phoneError}
|
||||
placeholder="Enter phone number"
|
||||
class="font-medium"
|
||||
/>
|
||||
{#if phoneError}
|
||||
<p class="text-sm text-red-500">{phoneError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={savePhone} disabled={savingPhone}>
|
||||
<Button size="sm" onclick={savePhone} disabled={savingPhone || !isValidUKPhone(phoneInput)}>
|
||||
{savingPhone ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1910,18 +1910,77 @@
|
||||
oninput={handleGiftCardInput}
|
||||
class="font-mono"
|
||||
/>
|
||||
<Button
|
||||
onclick={redeemGiftCard}
|
||||
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>
|
||||
</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 & 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 & Conditions.
|
||||
<em>Link T&Cs here once available.</em>
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={redeemGiftCard}
|
||||
>Confirm & Redeem</AlertDialog.Action
|
||||
>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<!-- Buy Gift Card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="phone" text="Phone Number" />
|
||||
<Input
|
||||
<PhoneInput
|
||||
id="phone"
|
||||
type="tel"
|
||||
bind:value={formData.phone}
|
||||
bind:error={validationErrors.phone}
|
||||
placeholder="07123 456789 or +44 7123 456789"
|
||||
maxlength={20}
|
||||
value={formData.phone}
|
||||
oninput={handlePhoneInput}
|
||||
onblur={() => validatePhone(formData.phone)}
|
||||
required
|
||||
/>
|
||||
{#if validationErrors.phone}
|
||||
<p class="text-sm text-red-500">{validationErrors.phone}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user