Fixes for giftcards and phone input

This commit is contained in:
2026-06-06 15:10:48 +01:00
parent 5258f90b06
commit 4e174a6123
6 changed files with 986 additions and 768 deletions
@@ -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<string | null>(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}
<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
@@ -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<string | null>(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 @@
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
{#if loadingUsers}
<div class="space-y-2 p-2">
{#each Array(3) as _, i (i)}
<Skeleton class="h-10 w-full" />
{/each}
{#each Array(3) as _, i (i)}
<Skeleton class="h-10 w-full" />
{/each}
</div>
{:else if users.length === 0}
<div class="flex items-center justify-center p-8 text-sm text-gray-500">
@@ -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}
<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
File diff suppressed because it is too large Load Diff
+277 -152
View File
@@ -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 @@
<svelte:head>
<script>
(function() {
(function () {
try {
var token = localStorage.getItem('authToken');
if (!token) { window.location.replace('/login'); return; }
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); }
} catch(e) { window.location.replace('/login'); }
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
<style>
@@ -1807,7 +1809,7 @@
</div>
</div>
{#if addCardError}
<div class="text-xs font-semibold text-red-500 mt-1">{addCardError}</div>
<div class="mt-1 text-xs font-semibold text-red-500">{addCardError}</div>
{/if}
</div>
</div>
@@ -1823,7 +1825,11 @@
>
Cancel
</Button>
<Button onclick={addCard} loading={addingCard} disabled={addingCard || !isAddCardValid}>
<Button
onclick={addCard}
loading={addingCard}
disabled={addingCard || !isAddCardValid}
>
Add Card
</Button>
</div>
@@ -1856,7 +1862,10 @@
size="sm"
variant="ghost"
class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => { cardToDelete = card; showDeleteCardDialog = true; }}
onclick={() => {
cardToDelete = card;
showDeleteCardDialog = true;
}}
>
Remove
</Button>
@@ -1876,17 +1885,32 @@
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
/>
</svg>
Redeem Gift Card
</Card.Title>
<Card.Description>Redeem a gift card directly to your account balance.</Card.Description>
<Card.Description
>Redeem a gift card directly to your account balance.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="rounded-lg bg-accent p-4 flex justify-between items-center">
<div class="flex items-center justify-between rounded-lg bg-accent p-4">
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Your Balance</div>
<div class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
Your Balance
</div>
<div class="mt-1 text-2xl font-bold text-card-foreground">
{loadingBalance ? '...' : formatCurrency(giftCardBalance)}
</div>
@@ -1894,7 +1918,9 @@
</div>
<div class="space-y-2">
<label for="redeem-code" class="text-sm font-medium text-gray-700">Enter Gift Card Code</label>
<label for="redeem-code" class="text-sm font-medium text-gray-700"
>Enter Gift Card Code</label
>
<div class="flex gap-2">
<Input
id="redeem-code"
@@ -1907,7 +1933,8 @@
/>
<Button
onclick={redeemGiftCard}
disabled={redeemingGiftCard || giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
disabled={redeemingGiftCard ||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
>
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button>
@@ -1920,53 +1947,80 @@
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="5" width="20" height="14" rx="2" ry="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Buy a Gift Card
</Card.Title>
<Card.Description>Purchase a gift card online for yourself or a friend.</Card.Description>
<Card.Description
>Purchase a gift card online for yourself or a friend.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
{#if purchaseResultCode}
<div class="rounded-lg border border-green-100 bg-green-50 p-4 space-y-3">
<div class="text-sm font-medium text-green-800 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
<div class="space-y-3 rounded-lg border border-green-100 bg-green-50 p-4">
<div class="flex items-center gap-2 text-sm font-medium text-green-800">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-green-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
Purchase Successful!
</div>
{#if buyRecipientType === 'self'}
<p class="text-xs text-green-700">
Your purchase of <strong>{formatCurrency(buyAmount)}</strong> has been automatically added to your account balance!
Your purchase of <strong>{formatCurrency(buyAmount)}</strong> has been automatically
added to your account balance!
</p>
{:else}
<p class="text-xs text-green-700">
Here is your gift card code:
</p>
<div class="text-center py-2 bg-white rounded border border-green-200 font-mono font-bold text-lg tracking-wider text-green-800">
<p class="text-xs text-green-700">Here is your gift card code:</p>
<div
class="rounded border border-green-200 bg-white py-2 text-center font-mono text-lg font-bold tracking-wider text-green-800"
>
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] text-green-600 italic">
Please save this code! It has been emailed to the recipient.
</p>
{/if}
<Button size="sm" variant="outline" onclick={() => purchaseResultCode = null} class="w-full">
<Button
size="sm"
variant="outline"
onclick={() => (purchaseResultCode = null)}
class="w-full"
>
Buy Another Card
</Button>
</div>
{:else}
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select Value</span>
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Select Value</span
>
<div class="grid grid-cols-3 gap-2">
{#each [10, 20, 50] as amount}
<button
type="button"
class="rounded-lg border py-2.5 text-center text-sm font-semibold transition-colors {buyAmount === amount
class="rounded-lg border py-2.5 text-center text-sm font-semibold transition-colors {buyAmount ===
amount
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyAmount = amount as 10 | 20 | 50}
onclick={() => (buyAmount = amount as 10 | 20 | 50)}
>
{formatCurrency(amount)}
</button>
@@ -1975,23 +2029,27 @@
</div>
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Recipient</span>
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Recipient</span
>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType === 'self'
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType ===
'self'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyRecipientType = 'self'}
onclick={() => (buyRecipientType = 'self')}
>
For Myself (Auto-Redeem)
</button>
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType === 'friend'
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType ===
'friend'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyRecipientType = 'friend'}
onclick={() => (buyRecipientType = 'friend')}
>
For a Friend (Gift Code)
</button>
@@ -2000,7 +2058,9 @@
{#if buyRecipientType === 'friend'}
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700">Friend's Email (Optional)</label>
<label for="recipient-email" class="text-sm font-medium text-gray-700"
>Friend's Email (Optional)</label
>
<Input
id="recipient-email"
type="email"
@@ -2011,25 +2071,34 @@
</div>
{/if}
<div class="space-y-3 pt-2 border-t">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Payment Method</span>
<div class="space-y-3 border-t pt-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Payment Method</span
>
{#if savedCards.length > 0}
<div class="space-y-2">
{#each savedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === card.id
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { buySelectedCard = card.id; }}
onclick={() => {
buySelectedCard = card.id;
}}
>
<div class="flex items-center gap-3">
<div class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium uppercase text-gray-700">
<div
class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium text-gray-700 uppercase"
>
{card.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-gray-400 text-xs">Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span>
<span class="ml-2 text-xs text-gray-400"
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
</div>
</div>
{#if buySelectedCard === card.id}
@@ -2039,14 +2108,23 @@
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === ''
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
''
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { buySelectedCard = ''; }}
onclick={() => {
buySelectedCard = '';
}}
>
<div class="flex items-center gap-3">
<div class="flex h-8 min-w-12 items-center justify-center rounded border-dashed border border-gray-300 text-xs font-medium text-gray-400">NEW</div>
<span class="text-sm font-medium text-gray-700 animate-pulse">Use a new card</span>
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if buySelectedCard === ''}
<span class="text-xs font-semibold text-primary">Selected</span>
@@ -2056,27 +2134,64 @@
{/if}
{#if buySelectedCard === ''}
<div class="space-y-3 bg-gray-50 p-3 rounded-lg border">
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<div>
<label for="buy-card-num" class="text-xs font-medium text-gray-600">Card Number</label>
<Input id="buy-card-num" type="text" inputmode="numeric" placeholder="1234 5678 9012 3456" value={buyNewCardNumber} oninput={handleBuyCardNumberInput} maxlength={19} class="h-8 text-xs mt-1 bg-white" />
<label for="buy-card-num" class="text-xs font-medium text-gray-600"
>Card Number</label
>
<Input
id="buy-card-num"
type="text"
inputmode="numeric"
placeholder="1234 5678 9012 3456"
value={buyNewCardNumber}
oninput={handleBuyCardNumberInput}
maxlength={19}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="buy-card-exp" class="text-xs font-medium text-gray-600">Expiry (MM/YY)</label>
<Input id="buy-card-exp" type="text" inputmode="numeric" placeholder="MM/YY" value={buyNewCardExpiry} oninput={handleBuyExpiryInput} maxlength={5} class="h-8 text-xs mt-1 bg-white" />
<label for="buy-card-exp" class="text-xs font-medium text-gray-600"
>Expiry (MM/YY)</label
>
<Input
id="buy-card-exp"
type="text"
inputmode="numeric"
placeholder="MM/YY"
value={buyNewCardExpiry}
oninput={handleBuyExpiryInput}
maxlength={5}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
<div>
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600">CVC</label>
<Input id="buy-card-cvc" type="text" inputmode="numeric" placeholder="123" value={buyNewCardCVC} oninput={handleBuyCvcInput} maxlength={4} class="h-8 text-xs mt-1 bg-white" />
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600"
>CVC</label
>
<Input
id="buy-card-cvc"
type="text"
inputmode="numeric"
placeholder="123"
value={buyNewCardCVC}
oninput={handleBuyCvcInput}
maxlength={4}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
</div>
{#if buyCardError}
<div class="text-[10px] font-semibold text-red-500 mt-1">{buyCardError}</div>
<div class="mt-1 text-[10px] font-semibold text-red-500">
{buyCardError}
</div>
{/if}
<div class="flex items-center gap-2 pt-1">
<Checkbox id="buy-save-card" bind:checked={buySaveCard} />
<label for="buy-save-card" class="text-[10px] text-gray-500">Save card for future purchases</label>
<label for="buy-save-card" class="text-[10px] text-gray-500"
>Save card for future purchases</label
>
</div>
</div>
{/if}
@@ -2085,7 +2200,7 @@
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard || !isBuyCardValid}
class="w-full mt-2"
class="mt-2 w-full"
>
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
</Button>
@@ -2132,10 +2247,17 @@
View and export all personal data we hold about you
</p>
<Button onclick={() => goto('/gdpr')} variant="outline">
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export My Data
</Button>
@@ -2294,51 +2416,51 @@
</button>
{#if authStore.currentUser?.role !== 'admin'}
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'history')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'history')}
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
History
</button>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
History
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin'}
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'referral')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'referral')}
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Referral
</button>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Referral
</button>
{/if}
{#if canSaveCards}
@@ -2528,7 +2650,9 @@
<!-- Delete Saved Card Confirmation -->
<AlertDialog.Root
bind:open={showDeleteCardDialog}
onOpenChange={(open) => { if (!open) cardToDelete = null; }}
onOpenChange={(open) => {
if (!open) cardToDelete = null;
}}
>
<AlertDialog.Content>
<AlertDialog.Header>
@@ -2541,10 +2665,11 @@
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => cardToDelete = null}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action onclick={() => cardToDelete && deleteCard(cardToDelete)} class="bg-red-600 hover:bg-red-700">
<AlertDialog.Cancel onclick={() => (cardToDelete = null)}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() => cardToDelete && deleteCard(cardToDelete)}
class="bg-red-600 hover:bg-red-700"
>
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
+8 -49
View File
@@ -6,6 +6,7 @@
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 { SvelteDate } from 'svelte/reactivity';
// zxcvbn-ts imports
@@ -96,60 +97,18 @@
}
}
// Phone validation (UK format)
function validatePhone(phone: string): boolean {
if (!phone) {
validationErrors.phone = '';
return true;
}
// Remove spaces, hyphens, parentheses for validation
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);
validationErrors.phone = isValid ? '' : 'Invalid UK phone number';
return isValid;
return isValidUKPhone(phone);
}
// Format phone number as user types
function formatPhoneInput(value: string): string {
// Remove all non-digit and non-plus characters
let cleaned = value.replace(/[^\d+]/g, '');
// If it starts with +44, format as +44 XXXX XXXXXX
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 it starts with 0, format as 0XXXX XXXXXX
if (cleaned.startsWith('0')) {
if (cleaned.length <= 5) return cleaned;
return `${cleaned.slice(0, 5)} ${cleaned.slice(5, 11)}`;
}
return cleaned;
return formatPhoneDisplay(value);
}
// Handle phone input
function handlePhoneInput(e: Event) {
const target = e.target as HTMLInputElement;
const cursorPos = target.selectionStart || 0;
const oldLength = formData.phone.length;
formData.phone = formatPhoneInput(target.value);
// Adjust cursor position after formatting
const newLength = formData.phone.length;
const diff = newLength - oldLength;
requestAnimationFrame(() => {
target.setSelectionRange(cursorPos + diff, cursorPos + diff);
});
const rawValue = target.value;
formData.phone = formatPhoneInput(rawValue);
}
// Age validation (must be 16+)
@@ -212,7 +171,7 @@
lastName: formData.lastName.trim(),
email: formData.email.trim().toLowerCase(),
password: formData.password,
phone: formData.phone.replace(/[\s\-()]/g, ''), // Remove formatting
phone: toE164UK(formData.phone) ?? formData.phone,
dateOfBirth: formData.dateOfBirth.trim(),
// Strip dashes to send raw 12 characters
referralCode: formData.referralCode.replace(/-/g, '').trim() || undefined,
@@ -326,7 +285,7 @@
<svelte:head>
<script>
(function() {
(function () {
try {
var token = localStorage.getItem('authToken');
if (token) {
@@ -336,7 +295,7 @@
window.location.replace(path);
}
}
} catch(e) {}
} catch (e) {}
})();
</script>
</svelte:head>