Fixes for giftcards and phone input
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user