diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index 31181eb..b6beeaa 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -17,19 +17,19 @@ import ( ) type TillSaleRequest struct { - ItemType string `json:"item_type"` - Action string `json:"action"` - Amount float64 `json:"amount"` - GiftCardID *string `json:"gift_card_id,omitempty"` - PaymentMethod string `json:"payment_method"` - UserSavedCardID *string `json:"user_saved_card_id,omitempty"` - UserID *string `json:"user_id,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` - CardNumber string `json:"card_number,omitempty"` - CardExpMonth int `json:"card_exp_month,omitempty"` - CardExpYear int `json:"card_exp_year,omitempty"` - CardCVC string `json:"card_cvc,omitempty"` - RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` + ItemType string `json:"item_type"` + Action string `json:"action"` + Amount float64 `json:"amount"` + GiftCardID *string `json:"gift_card_id,omitempty"` + PaymentMethod string `json:"payment_method"` + UserSavedCardID *string `json:"user_saved_card_id,omitempty"` + UserID *string `json:"user_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + CardNumber string `json:"card_number,omitempty"` + CardExpMonth int `json:"card_exp_month,omitempty"` + CardExpYear int `json:"card_exp_year,omitempty"` + CardCVC string `json:"card_cvc,omitempty"` + RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` } type TillSaleResponse struct { @@ -379,12 +379,10 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) { _, err = db.DB.Exec(r.Context(), ` UPDATE till_sales SET status = 'completed', - card_last4 = $1, - card_brand = $2, - square_payment_id = $3, + square_payment_id = $1, updated_at = NOW() - WHERE id = $4 - `, paymentResult.CardLast4, paymentResult.CardBrand, paymentResult.SquarePayID, tillSaleID) + WHERE id = $2 + `, paymentResult.SquarePayID, tillSaleID) if err != nil { log.Printf("Failed to update till sale: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index f5443f1..d4d88ce 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -3,6 +3,7 @@ import { toast } from 'svelte-sonner'; import { SvelteDate } from 'svelte/reactivity'; import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; + import { isValidUKPhone, toE164UK } from '$lib/utils/phone'; // UI Components import * as Modal from '$lib/components/ui/dialog'; @@ -57,6 +58,7 @@ let selectedUserId = $state(null); let guestName = $state(''); let guestPhone = $state(''); + let guestPhoneError = $state(''); let loadingUsers = $state(false); // Step 2: Services @@ -659,7 +661,11 @@ let finalUserId = selectedUserId; if (userType === 'guest') { - const phone = guestPhone.trim() || '+447700900000'; + if (!isValidUKPhone(guestPhone)) { + toast.error('Please enter a valid UK phone number for the guest'); + return; + } + const phone = toE164UK(guestPhone)!; const createRes = await fetch('/api/users/guest', { method: 'POST', headers: { @@ -939,8 +945,13 @@ class="flex h-10 w-full rounded-md border border-input 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" placeholder="07700 900000" value={guestPhone} - oninput={(e) => (guestPhone = e.currentTarget.value)} + oninput={(e) => { guestPhone = e.currentTarget.value; }} + onblur={() => { if (guestPhone && !isValidUKPhone(guestPhone)) guestPhoneError = 'Invalid UK phone number'; else guestPhoneError = ''; }} + class={guestPhoneError ? 'border-red-500' : ''} /> + {#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/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index 2f2266a..c50bbc2 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -3,6 +3,7 @@ import { toast } from 'svelte-sonner'; import { SvelteDate } from 'svelte/reactivity'; import { getLocalTimeZone } from '@internationalized/date'; + import { isValidUKPhone, toE164UK } from '$lib/utils/phone'; // UI Components import * as Modal from '$lib/components/ui/dialog'; @@ -51,6 +52,7 @@ let selectedUserId = $state(null); let guestName = $state(''); let guestPhone = $state(''); + let guestPhoneError = $state(''); let loadingUsers = $state(false); // Step 2: Services @@ -279,7 +281,11 @@ let finalUserId = selectedUserId; if (userType === 'guest') { - const phone = guestPhone.trim() || '+447700900000'; + if (!isValidUKPhone(guestPhone)) { + toast.error('Please enter a valid UK phone number for the guest'); + return; + } + const phone = toE164UK(guestPhone)!; const createRes = await fetch('/api/users/guest', { method: 'POST', headers: { @@ -545,9 +551,9 @@

{#if loadingUsers}
- {#each Array(3) as _, i (i)} - - {/each} + {#each Array(3) as _, i (i)} + + {/each}
{:else if users.length === 0}
@@ -622,8 +628,13 @@ class="flex h-10 w-full rounded-md border border-input 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" placeholder="07700 900000 (optional — helps us reach you if running late)" value={guestPhone} - oninput={(e) => (guestPhone = e.currentTarget.value)} + oninput={(e) => { guestPhone = e.currentTarget.value; }} + onblur={() => { guestPhoneError = guestPhone && !isValidUKPhone(guestPhone) ? 'Invalid UK phone number' : ''; }} + class={guestPhoneError ? 'border-red-500' : ''} /> + {#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/payments/TillPaymentModal.svelte b/frontend/src/lib/components/payments/TillPaymentModal.svelte index b847d4e..5a8f0e5 100644 --- a/frontend/src/lib/components/payments/TillPaymentModal.svelte +++ b/frontend/src/lib/components/payments/TillPaymentModal.svelte @@ -6,6 +6,7 @@ import { Input } from '$lib/components/ui/input'; import { authStore } from '$lib/stores/auth.svelte'; import { formatCardCode } from '$lib/utils/format'; + import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone'; interface Props { amount: number; @@ -28,7 +29,18 @@ giftcard_code?: string; }; - type PaymentStep = 'customer-selection' | 'delivery-selection' | 'payment-selection' | 'cash-entering' | 'cash-confirming' | 'card-machine' | 'card-polling' | 'card-details-entering' | 'saved-card-selecting' | 'success' | 'error'; + type PaymentStep = + | 'customer-selection' + | 'delivery-selection' + | 'payment-selection' + | 'cash-entering' + | 'cash-confirming' + | 'card-machine' + | 'card-polling' + | 'card-details-entering' + | 'saved-card-selecting' + | 'success' + | 'error'; let step = $state('customer-selection'); let error = $state(null); @@ -58,7 +70,12 @@ // Customer Selection - Current Customer let loadingCurrentCustomer = $state(false); - let currentCustomerInfo = $state<{ id: string; name: string; email?: string; phone?: string } | null>(null); + let currentCustomerInfo = $state<{ + id: string; + name: string; + email?: string; + phone?: string; + } | null>(null); // Customer Selection - tab let customerTab = $state<'current' | 'member' | 'guest'>('current'); @@ -79,7 +96,15 @@ let guestNameError = $state(''); // Saved Cards (member users) - let savedCardList = $state>([]); + let savedCardList = $state< + Array<{ + id: string; + card_brand: string; + card_last4: string; + card_expiry?: string; + cardholder_name?: string; + }> + >([]); let loadingSavedCards = $state(false); let selectedSavedCardId = $state(null); let sendingSavedCard = $state(false); @@ -128,7 +153,9 @@ const beforeLen = ephemeralCardNumber.length; ephemeralCardNumber = formatCardNumber(input.value); const afterLen = ephemeralCardNumber.length; - requestAnimationFrame(() => input.setSelectionRange(start + (afterLen - beforeLen), start + (afterLen - beforeLen))); + requestAnimationFrame(() => + input.setSelectionRange(start + (afterLen - beforeLen), start + (afterLen - beforeLen)) + ); } function handleEphemeralExpiryInput(e: Event) { @@ -137,7 +164,9 @@ const beforeLen = ephemeralCardExpiry.length; ephemeralCardExpiry = formatExpiryDate(input.value); const afterLen = ephemeralCardExpiry.length; - requestAnimationFrame(() => input.setSelectionRange(start + (afterLen - beforeLen), start + (afterLen - beforeLen))); + requestAnimationFrame(() => + input.setSelectionRange(start + (afterLen - beforeLen), start + (afterLen - beforeLen)) + ); } function handleEphemeralCvcInput(e: Event) { @@ -146,16 +175,19 @@ } let isEphemeralCardValid = $derived( - isValidLuhn(ephemeralCardNumber) && /^\d{2}\/\d{2}$/.test(ephemeralCardExpiry) && ephemeralCardCVC.length >= 3 + isValidLuhn(ephemeralCardNumber) && + /^\d{2}\/\d{2}$/.test(ephemeralCardExpiry) && + ephemeralCardCVC.length >= 3 ); let ephemeralCardError = $derived( ephemeralCardNumber.length > 0 && !isValidLuhn(ephemeralCardNumber) ? 'Invalid card number' - : /^\d{2}\/\d{2}$/.test(ephemeralCardExpiry) && (() => { - const parts = parseEpiry(ephemeralCardExpiry); - return parts && (parts.month < 1 || parts.month > 12); - })() + : /^\d{2}\/\d{2}$/.test(ephemeralCardExpiry) && + (() => { + const parts = parseEpiry(ephemeralCardExpiry); + return parts && (parts.month < 1 || parts.month > 12); + })() ? 'Invalid expiry month' : ephemeralCardCVC.length > 0 && ephemeralCardCVC.length < 3 ? 'CVC must be at least 3 digits' @@ -178,62 +210,18 @@ guestPhoneError = 'Phone number is required'; return false; } - const cleanPhone = phone.replace(/[\s\-()]/g, ''); - const phoneRegex = /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/; - const isValid = phoneRegex.test(cleanPhone); - guestPhoneError = isValid ? '' : 'Invalid UK phone number'; - return isValid; + const valid = isValidUKPhone(phone); + guestPhoneError = valid ? '' : 'Invalid UK phone number'; + return valid; } function formatGuestPhoneInput(value: string): string { - const cleaned = value.replace(/[^\d+]/g, ''); - if (cleaned.startsWith('+44')) { - const digits = cleaned.slice(3); - if (digits.length <= 4) return `+44 ${digits}`; - if (digits.length <= 10) return `+44 ${digits.slice(0, 4)} ${digits.slice(4)}`; - return `+44 ${digits.slice(0, 4)} ${digits.slice(4, 10)}`; - } - if (cleaned.startsWith('0')) { - if (cleaned.length <= 5) return cleaned; - return `${cleaned.slice(0, 5)} ${cleaned.slice(5, 11)}`; - } - return cleaned; + return formatPhoneDisplay(value); } function handleGuestPhoneInput(e: Event) { - const input = e.target as HTMLInputElement; - const cursorPos = input.selectionStart || 0; - - // Count digits/+ before cursor in the RAW input value (which includes - // the just-typed character). This is more accurate than using the old - // guestPhone value because it captures the exact editing position. - const rawValue = input.value; - let digitsBeforeCursor = 0; - for (let i = 0; i < cursorPos && i < rawValue.length; i++) { - if (/\d/.test(rawValue[i]) || rawValue[i] === '+') { - digitsBeforeCursor++; - } - } - - guestPhone = formatGuestPhoneInput(rawValue); - - // Place cursor after the same number of meaningful characters - // in the newly formatted value - let meaningfulCount = 0; - let newCursorPos = guestPhone.length; - for (let i = 0; i < guestPhone.length; i++) { - if (meaningfulCount >= digitsBeforeCursor) { - newCursorPos = i; - break; - } - if (/\d/.test(guestPhone[i]) || guestPhone[i] === '+') { - meaningfulCount++; - } - } - - requestAnimationFrame(() => { - input.setSelectionRange(newCursorPos, newCursorPos); - }); + const target = e.target as HTMLInputElement; + guestPhone = formatGuestPhoneInput(target.value); if (guestPhoneError) validateGuestPhone(guestPhone); } @@ -376,7 +364,12 @@ 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }, - body: JSON.stringify({ firstName, lastName, phone, email }) + body: JSON.stringify({ + firstName, + lastName, + phone: toE164UK(guestPhone) ?? guestPhone, + email + }) }); if (res.ok) { const data = await res.json(); @@ -491,7 +484,7 @@ let attempts = 0; const poll = async () => { while (attempts < maxAttempts) { - await new Promise(r => setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, 2000)); attempts++; try { const res = await fetch(`/api/admin/till/sale/checkout/${ckId}/status`, { @@ -648,535 +641,656 @@ } - { if (!open) onClose(); }}> - - {#if step === 'success' && result} - - Payment Successful - - {action === 'create' ? 'Gift card created' : 'Gift card topped up'} and payment recorded. - - -

-
-
- - - -
-

Payment Complete

-

- {formatCurrency(result.total_amount)} via {result.payment_method} -

- {#if result.payment_method === 'cash' && cashTendered > 0} -
-
- Cash Received - {formatCurrency(cashTendered)} -
-
- Change Due - {formatCurrency(cashTendered - result.total_amount)} -
+ { + if (!open) onClose(); + }} +> + + {#if step === 'success' && result} + + Payment Successful + + {action === 'create' ? 'Gift card created' : 'Gift card topped up'} and payment recorded. + + +
+
+
+ + +
- {/if} - {#if action === 'create' && delivery === 'code'} -
-

Gift Card Code

-

- {formatCardCode(result.giftcard_code || result.item_id || '')} -

-

Show this code to the customer

-
- {/if} - {#if action === 'create' && delivery === 'account'} -
-

- ✓ Gift card added to {selectedCustomer?.name || 'account'} -

-

Balance is available immediately

-
- - {/if} -
- -
- {:else if step === 'error'} - - Payment Failed - -
-
-

{error || 'An error occurred'}

-
-
- - -
-
- {:else if step === 'customer-selection'} - - Select Customer - - Choose a customer for this {formatCurrency(amount)} {action === 'create' ? 'gift card' : 'topup'}. - - - -
-
- Total - {formatCurrency(amount)} -
- - -
- - - -
- - - {#if customerTab === 'current'} -
- {#if currentCustomerInfo} -
-
-
- {currentCustomerInfo.name.charAt(0).toUpperCase()} -
-
-
{currentCustomerInfo.name}
- {#if currentCustomerInfo.email} -
{currentCustomerInfo.email}
- {/if} -
+

Payment Complete

+

+ {formatCurrency(result.total_amount)} via {result.payment_method} +

+ {#if result.payment_method === 'cash' && cashTendered > 0} +
+
+ Cash Received + {formatCurrency(cashTendered)} +
+
+ Change Due + {formatCurrency(cashTendered - result.total_amount)}
- -
- {:else if loadingCurrentCustomer} -
- -
- {:else} -
-

No current appointment found.

{/if} + {#if action === 'create' && delivery === 'code'} +
+

Gift Card Code

+

+ {formatCardCode(result.giftcard_code || result.item_id || '')} +

+

Show this code to the customer

+
+ {/if} + {#if action === 'create' && delivery === 'account'} +
+

+ ✓ Gift card added to {selectedCustomer?.name || 'account'} +

+

Balance is available immediately

+
+ + {/if} +
+ +
+ {:else if step === 'error'} + + Payment Failed + +
+
+

{error || 'An error occurred'}

+
+
+ + +
+
+ {:else if step === 'customer-selection'} + + Select Customer + + Choose a customer for this {formatCurrency(amount)} + {action === 'create' ? 'gift card' : 'topup'}. + + + +
+
+ Total + {formatCurrency(amount)}
- - {:else if customerTab === 'member'} -
-
-
-
- - - -
- { if (e.key === 'Enter') { e.preventDefault(); searchCustomers(1); } }} - /> -
- -
+ +
+ + + +
-
- {#if loadingUsers} -
- {#each Array(3) as _, i (i)} -
- {/each} + + {#if customerTab === 'current'} +
+ {#if currentCustomerInfo} +
+
+
+ {currentCustomerInfo.name.charAt(0).toUpperCase()} +
+
+
{currentCustomerInfo.name}
+ {#if currentCustomerInfo.email} +
{currentCustomerInfo.email}
+ {/if} +
+
+
- {:else if users.length === 0} -
- {userQuery - ? 'No customers found. Try a different search.' - : 'Search for a customer above to get started.'} + {:else if loadingCurrentCustomer} +
+
{:else} -
    - {#each users as userItem (userItem.id)} -
  • - -
  • - {/each} -
+
+

No current appointment found.

+
{/if}
- {#if searchTotal > 5} -
- - Page {currentPage} of {totalPages} - + {loadingUsers ? '...' : 'Search'} +
+ +
+ {#if loadingUsers} +
+ {#each Array(3) as _, i (i)} +
+ {/each} +
+ {:else if users.length === 0} +
+ {userQuery + ? 'No customers found. Try a different search.' + : 'Search for a customer above to get started.'} +
+ {:else} +
    + {#each users as userItem (userItem.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + {#if searchTotal > 5} +
+ + Page {currentPage} of {totalPages} + +
+ {/if} +
+ + + {:else} +
+

+ A walk-in customer purchasing a gift card. No details required. +

+ +
+ {/if} + +
+ +
+
+ {:else if step === 'payment-selection'} + + Take Payment + + Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'} + {#if selectedCustomer} + — {selectedCustomer.name} + {/if}. + + + +
+
+ Total + {formatCurrency(amount)} +
+ +
+ + + {#if !isGuest && savedCardList.length > 0} + + {/if} + {#if isGuest} + {/if}
- - {:else} -
-

- A walk-in customer purchasing a gift card. No details required. -

+
- {creatingGuest ? 'Creating...' : 'Continue as Guest'} -
- {/if} - -
-
-
+ {:else if step === 'delivery-selection'} + + Delivery Method + + How should {selectedCustomer?.name || 'the customer'} receive their gift card? + + - {:else if step === 'payment-selection'} - - Take Payment - - Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'} - {#if selectedCustomer} - — {selectedCustomer.name} - {/if}. - - +
+
+ Total + {formatCurrency(amount)} +
-
-
- Total - {formatCurrency(amount)} -
- -
- - - {#if !isGuest && savedCardList.length > 0} +
- {/if} - {#if isGuest} - {/if} -
- -
- -
-
- - {:else if step === 'delivery-selection'} - - Delivery Method - - How should {selectedCustomer?.name || 'the customer'} receive their gift card? - - - -
-
- Total - {formatCurrency(amount)} -
- -
- - -
- -
- -
-
- - {:else if step === 'saved-card-selecting'} - - Saved Cards - - Select a saved card for {selectedCustomer?.name || 'this customer'} - - - -
- {#if loadingSavedCards} -
-
- {:else if savedCardList.length === 0} -
-

No saved cards found for this customer.

+ +
+
- {:else} -
- {#each savedCardList as card (card.id)} - - {/each} -
- {/if} - -
- -
-
+ {:else if step === 'saved-card-selecting'} + + Saved Cards + + Select a saved card for {selectedCustomer?.name || 'this customer'} + + - {:else} - - Take Payment - - Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'} - {#if selectedCustomer} - — {selectedCustomer.name} - {/if}. - - - -
-
- Total - {formatCurrency(amount)} -
- - {#if step === 'cash-entering'} -
- -
- £ - cashAmount = (e.target as HTMLInputElement).value} - class="pl-7 text-lg font-semibold" - /> +
+ {#if loadingSavedCards} +
+
-
- - {#if cashEntryComplete} -
-
- Change Due - {formatCurrency(changeDue)} -
+ {:else if savedCardList.length === 0} +
+

No saved cards found for this customer.

+
+ {:else} +
+ {#each savedCardList as card (card.id)} + + {/each}
{/if}
- - +
+
+ {:else} + + Take Payment + + Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'} + {#if selectedCustomer} + — {selectedCustomer.name} + {/if}. + + - {:else if step === 'cash-confirming'} -
-
-

Processing cash payment...

+
+
+ Total + {formatCurrency(amount)}
- {:else if step === 'card-machine'} -
-
-

Initiating card machine...

-
- - {:else if step === 'card-polling'} -
-
-

Waiting for customer to tap card...

-

This may take a few moments

-
-
- -
- - {:else if step === 'card-details-entering'} -
-
-

Enter Card Details

-

Card will be charged once — not saved.

-
-
- - -
-
-
- - -
-
- - -
-
- {#if ephemeralCardError} -
{ephemeralCardError}
- {/if} + {#if step === 'cash-entering'} +
+ +
+ £ + (cashAmount = (e.target as HTMLInputElement).value)} + class="pl-7 text-lg font-semibold" + />
+ + {#if cashEntryComplete} +
+
+ Change Due + {formatCurrency(changeDue)} +
+
+ {/if} +
- - +
-
- {/if} -
- {/if} - + {:else if step === 'cash-confirming'} +
+
+

Processing cash payment...

+
+ {:else if step === 'card-machine'} +
+
+

Initiating card machine...

+
+ {:else if step === 'card-polling'} +
+
+

Waiting for customer to tap card...

+

This may take a few moments

+
+
+ +
+ {:else if step === 'card-details-entering'} +
+
+

Enter Card Details

+

Card will be charged once — not saved.

+
+
+ + +
+
+
+ + +
+
+ + +
+
+ {#if ephemeralCardError} +
{ephemeralCardError}
+ {/if} +
+
+
+ + +
+
+ {/if} +
+ {/if} + diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 9db31e3..8a933a3 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -5,6 +5,8 @@ import { browser } from '$app/environment'; import { toast } from 'svelte-sonner'; import UserBookingModal from '$lib/components/account/UserBookingModal.svelte'; + import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone'; + import { savedCardsStore } from '$lib/stores/savedCards.svelte'; // zxcvbn-ts imports import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core'; @@ -327,7 +329,11 @@ if (buySelectedCard) { cardId = buySelectedCard; } else if (buyNewCardNumber) { - if (!isValidLuhn(buyNewCardNumber) || !/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) || buyNewCardCVC.length < 3) { + if ( + !isValidLuhn(buyNewCardNumber) || + !/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) || + buyNewCardCVC.length < 3 + ) { toast.error('Please enter valid credit card details'); buyingGiftCard = false; return; @@ -421,15 +427,19 @@ function handleGiftCardInput(e: Event) { const input = e.target as HTMLInputElement; - const formatted = formatAndPreserveCursor(input, (val) => { - let raw = val.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); - if (raw.length > 12) raw = raw.slice(0, 12); - let clean = ''; - if (raw.length > 0) clean += raw.slice(0, 4); - if (raw.length > 4) clean += '-' + raw.slice(4, 8); - if (raw.length > 8) clean += '-' + raw.slice(8, 12); - return clean; - }, /[a-zA-Z0-9]/); + const formatted = formatAndPreserveCursor( + input, + (val) => { + let raw = val.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); + if (raw.length > 12) raw = raw.slice(0, 12); + let clean = ''; + if (raw.length > 0) clean += raw.slice(0, 4); + if (raw.length > 4) clean += '-' + raw.slice(4, 8); + if (raw.length > 8) clean += '-' + raw.slice(8, 12); + return clean; + }, + /[a-zA-Z0-9]/ + ); giftCardCode = formatted; } @@ -447,7 +457,13 @@ const raw = id.toLowerCase().replace(/[^a-z0-9]/g, ''); if (raw.length <= 4) return raw.toUpperCase(); if (raw.length <= 8) return raw.slice(0, 4).toUpperCase() + '-' + raw.slice(4, 8).toUpperCase(); - return raw.slice(0, 4).toUpperCase() + '-' + raw.slice(4, 8).toUpperCase() + '-' + raw.slice(8, 12).toUpperCase(); + return ( + raw.slice(0, 4).toUpperCase() + + '-' + + raw.slice(4, 8).toUpperCase() + + '-' + + raw.slice(8, 12).toUpperCase() + ); } function isValidLuhn(cardNumber: string): boolean { @@ -494,7 +510,9 @@ function handleCvcInput(e: Event) { const input = e.target as HTMLInputElement; - const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4)); + const formatted = formatAndPreserveCursor(input, (val) => + val.replace(/\D/g, '').substring(0, 4) + ); newCardCVC = formatted; } @@ -513,7 +531,9 @@ function handleBuyCvcInput(e: Event) { const input = e.target as HTMLInputElement; - const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4)); + const formatted = formatAndPreserveCursor(input, (val) => + val.replace(/\D/g, '').substring(0, 4) + ); buyNewCardCVC = formatted; } @@ -536,7 +556,11 @@ } async function addCard() { - if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || newCardCVC.length < 3) { + if ( + !isValidLuhn(newCardNumber) || + !/^\d{2}\/\d{2}$/.test(newCardExpiry) || + newCardCVC.length < 3 + ) { toast.error('Please fill in all card details correctly'); return; } @@ -722,40 +746,11 @@ // Phone validation (UK format) function validatePhone(phone: string): boolean { - if (!phone) { - phoneError = ''; - return true; - } - const cleanPhone = phone.replace(/[\s\-()]/g, ''); - // UK phone regex: +44 followed by 10-11 digits, or 0 followed by 10-11 digits - const phoneRegex = /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/; - const isValid = phoneRegex.test(cleanPhone); - phoneError = isValid ? '' : 'Invalid UK phone number'; - return isValid; + return isValidUKPhone(phone); } - // Format phone number as user types function formatPhoneInput(value: string): string { - // Remove all non-digits except + - const digits = value.replace(/[^\d+]/g, ''); - // Format UK numbers - if (digits.startsWith('+44')) { - return digits; // Keep +44 as is - } - if (digits.startsWith('44')) { - return '+' + digits; - } - if (digits.startsWith('0')) { - // Format 07xx xxx xxxx - if (digits.length <= 5) { - return digits; - } - if (digits.length <= 10) { - return digits.slice(0, 5) + ' ' + digits.slice(5); - } - return digits.slice(0, 5) + ' ' + digits.slice(5, 10) + ' ' + digits.slice(10, 12); - } - return digits; + return formatPhoneDisplay(phone); } function startEditPhone() { @@ -771,8 +766,8 @@ } async function savePhone() { - const formattedPhone = phoneInput.replace(/[\s\-()]/g, ''); - if (!validatePhone(formattedPhone)) { + const formattedPhone = toE164UK(phoneInput); + if (!formattedPhone) { return; } @@ -789,7 +784,7 @@ body: JSON.stringify({ firstName: userData?.firstName, lastName: userData?.lastName, - phone: formattedPhone + phone: formattedPhone // already E.164 from toE164UK }) }); @@ -1121,13 +1116,20 @@