Add dev-only frontend Square mock mode for as-if-live walkthroughs

VITE_SQUARE_ENVIRONMENT=mock renders a plain HTML card form (MockCardForm)
instead of the Square Web Payments SDK iframe, minting the same cnon: tokens
the backend dev mock accepts — all 8 payment flows run end-to-end locally with
zero credentials.

- isSquareMock() gated on import.meta.env.DEV: structurally impossible in a
  production build even if the env var is mis-set
- MockCardForm: Luhn/brand/expiry/CVC validation, Amex 15-digit + 4-digit CVC,
  error states, disabled propagation — mirrors the real form's onReady contract
  so CardSelection.isCardValid and submit guards behave identically
- tokenize() maps typed card -> deterministic cnon: token matching backend
  detectCardInfo (4242->test-card, 4111->visa, 5555->mastercard, 3782->amex)
- lazy-loaded via dynamic import: mock code ships in its own chunk, referenced
  only from the mock branch, never statically imported into the main bundle
- docs: .env.example (mock pairing with SQUARE_ENVIRONMENT=mock), P11 plan
  (mock opt-in + canonical-last4 caveat), Feature Catalog (2.1, 2.5)
- prettier formatting fixes in 10 unrelated files (line wrapping only)
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 7439fa86c1
commit 52e2bfff55
15 changed files with 378 additions and 71 deletions
@@ -335,7 +335,9 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to confirm: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to confirm: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch {
toast.error('Network error confirming booking', { id: loadingToast });
@@ -363,7 +365,9 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to decline: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to decline: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch {
toast.error('Network error declining booking', { id: loadingToast });
@@ -279,7 +279,9 @@
toast.error(`Validation error: ${extractErrorMessage(errorText)}`, { id: loadingToast });
} else {
const errorText = await response.text();
toast.error(`Failed to create service: ${extractErrorMessage(errorText)}`, { id: loadingToast });
toast.error(`Failed to create service: ${extractErrorMessage(errorText)}`, {
id: loadingToast
});
}
} catch (err) {
console.error('Error creating service:', err);
@@ -448,7 +448,9 @@
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch (err) {
console.error('Error creating time blocker:', err);
@@ -475,7 +477,9 @@
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch (err) {
console.error('Error deleting time blocker:', err);
@@ -872,7 +876,12 @@
? ''
: 's'} with this slot
</span>
<Button variant="outline" size="sm" class="h-6 text-xs ml-auto" onclick={checkOverlappingBookings}>
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
onclick={checkOverlappingBookings}
>
Refresh
</Button>
</div>
@@ -23,16 +23,14 @@
type AvailabilityState = 'sleeping' | 'with_client' | 'busy' | 'prepping' | 'available';
const STATUS_MAP: Record<
AvailabilityState,
{ label: string; color: 'green' | 'amber' | 'red' }
> = {
sleeping: { label: 'Unable to take calls right now', color: 'red' },
with_client: { label: 'With a client', color: 'red' },
busy: { label: 'Busy', color: 'amber' },
prepping: { label: 'Prepping for a client', color: 'amber' },
available: { label: 'Available to contact', color: 'green' }
};
const STATUS_MAP: Record<AvailabilityState, { label: string; color: 'green' | 'amber' | 'red' }> =
{
sleeping: { label: 'Unable to take calls right now', color: 'red' },
with_client: { label: 'With a client', color: 'red' },
busy: { label: 'Busy', color: 'amber' },
prepping: { label: 'Prepping for a client', color: 'amber' },
available: { label: 'Available to contact', color: 'green' }
};
let availabilityState = $state<AvailabilityState | null>(null);
@@ -59,9 +57,7 @@
}
}
let contactStatus = $derived(
availabilityState ? STATUS_MAP[availabilityState] : null
);
let contactStatus = $derived(availabilityState ? STATUS_MAP[availabilityState] : null);
</script>
<div class="mx-auto h-full max-w-sm">
@@ -91,8 +87,8 @@
class="font-medium"
class:text-green-700={contactStatus.color === 'green'}
class:text-amber-700={contactStatus.color === 'amber'}
class:text-red-700={contactStatus.color === 'red'}
>{contactStatus.label}</span>
class:text-red-700={contactStatus.color === 'red'}>{contactStatus.label}</span
>
</div>
{/if}
@@ -38,7 +38,9 @@
<section class="w-full overflow-hidden py-16">
<div class="mx-auto max-w-6xl px-6">
<h2 class="mb-12 text-center font-['Playfair_Display'] text-2xl font-semibold">Our Portfolio</h2>
<h2 class="mb-12 text-center font-['Playfair_Display'] text-2xl font-semibold">
Our Portfolio
</h2>
</div>
<div class="relative w-full overflow-hidden">
@@ -0,0 +1,253 @@
<!-- DEV-ONLY mock card form — enabled only by VITE_SQUARE_ENVIRONMENT === 'mock'
(never in production). The PAN lives only in local component state; only a
cnon: token is ever returned. -->
<script lang="ts">
import CardBrandIcon from './CardBrandIcon.svelte';
// Deterministic token mapping the backend dev mock (square_dev.go
// detectCardInfo) resolves back to the brand/last4 the user typed.
const MOCK_TOKENS: Record<string, string> = {
'4242': 'cnon:test-card',
'4111': 'cnon:visa',
'5555': 'cnon:mastercard',
'3782': 'cnon:amex'
};
let {
disabled = false,
onReady = () => {}
}: { disabled?: boolean; onReady?: (ready: boolean) => void } = $props();
let cardNumber = $state('');
let expiry = $state('');
let cvc = $state('');
let cardholderName = $state('');
let cardNumberTouched = $state(false);
let expiryTouched = $state(false);
let cvcTouched = $state(false);
// Per-instance ids so two mounted forms never collide on the same id. Pure
// SPA (no SSR/hydration), so a random id cannot mismatch.
const cardNumberId = `mock-card-number-${crypto.randomUUID()}`;
const expiryId = `mock-card-exp-${crypto.randomUUID()}`;
const cvcId = `mock-card-cvc-${crypto.randomUUID()}`;
const nameId = `mock-card-name-${crypto.randomUUID()}`;
const inputClasses =
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20';
const digits = $derived(cardNumber.replace(/\D/g, ''));
const detectedBrand = $derived.by(() => {
if (digits.startsWith('4')) return 'VISA';
if (digits.startsWith('34') || digits.startsWith('37')) return 'AMERICAN_EXPRESS';
if (digits.length >= 2) {
const two = Number(digits.slice(0, 2));
if (two >= 51 && two <= 55) return 'MASTERCARD';
}
if (digits.length >= 4) {
const four = Number(digits.slice(0, 4));
if (four >= 2221 && four <= 2720) return 'MASTERCARD';
}
return '';
});
function formatNumber(raw: string): string {
const isAmex = raw.startsWith('34') || raw.startsWith('37');
const d = raw.replace(/\D/g, '').slice(0, isAmex ? 15 : 16);
if (isAmex) {
if (d.length <= 4) return d;
if (d.length <= 10) return `${d.slice(0, 4)} ${d.slice(4)}`;
return `${d.slice(0, 4)} ${d.slice(4, 10)} ${d.slice(10)}`;
}
return d.replace(/(.{4})/g, '$1 ').trim();
}
function luhnValid(value: string): boolean {
let sum = 0;
let double = false;
for (let i = value.length - 1; i >= 0; i--) {
let digit = value.charCodeAt(i) - 48;
if (double) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
double = !double;
}
return sum % 10 === 0;
}
const numberLuhnValid = $derived(digits.length >= 12 && luhnValid(digits));
const expiryDigits = $derived(expiry.replace(/\D/g, ''));
const expiryMonth = $derived(Number(expiryDigits.slice(0, 2)));
const expiryYear = $derived(Number(expiryDigits.slice(2)));
const expiryValid = $derived.by(() => {
if (expiryDigits.length !== 4) return false;
if (expiryMonth < 1 || expiryMonth > 12) return false;
const now = new Date();
const currentYear = now.getFullYear() % 100;
const currentMonth = now.getMonth() + 1;
if (expiryYear < currentYear) return false;
if (expiryYear === currentYear && expiryMonth < currentMonth) return false;
return true;
});
const cvcValid = $derived(cvc.length === (detectedBrand === 'AMERICAN_EXPRESS' ? 4 : 3));
// Mirrors the real form's onReady(ready && !disabled).
const complete = $derived(numberLuhnValid && expiryValid && cvcValid && !disabled);
$effect(() => {
onReady(complete);
});
const cardNumberError = $derived.by(() => {
if (!cardNumberTouched) return '';
if (digits.length === 0) return 'Card number is required';
if (digits.length < 12) return 'Card number is too short';
if (!luhnValid(digits)) return 'Card number is invalid';
return '';
});
const expiryError = $derived.by(() => {
if (!expiryTouched) return '';
if (expiryDigits.length === 0) return 'Expiry date is required';
if (expiryDigits.length < 4) return 'Enter a valid expiry date';
if (expiryMonth < 1 || expiryMonth > 12) return 'Invalid month';
return expiryValid ? '' : 'Card has expired';
});
const cvcError = $derived.by(() => {
if (!cvcTouched) return '';
if (cvc.length === 0) return 'Security code is required';
const expected = detectedBrand === 'AMERICAN_EXPRESS' ? 4 : 3;
return cvc.length === expected ? '' : `Security code must be ${expected} digits`;
});
let lastExpiry = '';
function onCardNumberInput(e: Event) {
const raw = (e.currentTarget as HTMLInputElement).value;
cardNumber = formatNumber(raw);
cardNumberTouched = true;
}
function onExpiryInput(e: Event) {
const raw = (e.currentTarget as HTMLInputElement).value;
const deleting = raw.length < lastExpiry.length;
lastExpiry = raw;
const d = raw.replace(/\D/g, '').slice(0, 4);
expiry =
d.length <= 1
? d
: d.length === 2
? deleting
? d
: `${d}/`
: `${d.slice(0, 2)}/${d.slice(2)}`;
expiryTouched = true;
}
function onCvcInput(e: Event) {
const raw = (e.currentTarget as HTMLInputElement).value;
cvc = raw.replace(/\D/g, '').slice(0, detectedBrand === 'AMERICAN_EXPRESS' ? 4 : 3);
cvcTouched = true;
}
/** Tokenizes the entered card into a deterministic cnon: token the backend dev mock accepts. */
export async function tokenize(): Promise<string> {
if (!complete) {
throw new Error('Card details are incomplete');
}
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
return Promise.resolve(token);
}
</script>
<div class="space-y-3">
<div>
<label for={cardNumberId} class="mb-1 block text-sm text-gray-600">Card number</label>
<div class="relative">
<input
id={cardNumberId}
type="text"
value={cardNumber}
oninput={(e) => onCardNumberInput(e)}
placeholder="1234 5678 9012 3456"
inputmode="numeric"
autocomplete="cc-number"
{disabled}
class="{inputClasses} pr-14"
aria-invalid={cardNumberError !== ''}
aria-describedby={cardNumberError ? `${cardNumberId}-error` : undefined}
/>
{#if detectedBrand !== ''}
<div class="absolute inset-y-0 right-2 flex items-center">
<CardBrandIcon brand={detectedBrand} />
</div>
{/if}
</div>
{#if cardNumberError}
<p id={`${cardNumberId}-error`} class="mt-1 text-xs text-red-500">{cardNumberError}</p>
{/if}
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label for={expiryId} class="mb-1 block text-sm text-gray-600">Expiry</label>
<input
id={expiryId}
type="text"
value={expiry}
oninput={(e) => onExpiryInput(e)}
placeholder="MM/YY"
inputmode="numeric"
autocomplete="cc-exp"
{disabled}
class={inputClasses}
aria-invalid={expiryError !== ''}
aria-describedby={expiryError ? `${expiryId}-error` : undefined}
/>
{#if expiryError}
<p id={`${expiryId}-error`} class="mt-1 text-xs text-red-500">{expiryError}</p>
{/if}
</div>
<div>
<label for={cvcId} class="mb-1 block text-sm text-gray-600">CVC</label>
<input
id={cvcId}
type="text"
value={cvc}
oninput={(e) => onCvcInput(e)}
placeholder="123"
inputmode="numeric"
autocomplete="cc-csc"
{disabled}
class={inputClasses}
aria-invalid={cvcError !== ''}
aria-describedby={cvcError ? `${cvcId}-error` : undefined}
/>
{#if cvcError}
<p id={`${cvcId}-error`} class="mt-1 text-xs text-red-500">{cvcError}</p>
{/if}
</div>
</div>
<div>
<label for={nameId} class="mb-1 block text-sm text-gray-600">
Name on card <span class="text-gray-400">(optional)</span>
</label>
<input
id={nameId}
type="text"
bind:value={cardholderName}
placeholder="Jane Doe"
autocomplete="cc-name"
{disabled}
class={inputClasses}
/>
</div>
</div>
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import { getSquarePayments, isSquareConfigured } from '$lib/square/square';
import type MockCardForm from './MockCardForm.svelte';
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
interface Props {
/** Disable the form while a payment is processing. */
@@ -14,6 +15,7 @@
let containerEl = $state<HTMLDivElement | null>(null);
let cardInstance: unknown | null = null;
let mockForm = $state<MockCardForm | null>(null);
let ready = $state(false);
let initError = $state<string | null>(null);
@@ -23,6 +25,10 @@
let uniqueId = $state(`square-card-${crypto.randomUUID()}`);
async function init() {
if (isSquareMock()) {
// Local dev mock: no SDK load, no iframe. MockCardForm handles everything.
return;
}
if (!isSquareConfigured()) {
initError = 'not_configured';
return;
@@ -62,11 +68,19 @@
});
$effect(() => {
onReady(ready && !disabled);
if (!isSquareMock()) {
onReady(ready && !disabled);
}
});
/** Tokenizes the entered card. Returns the cnon:xxx nonce, throws with a user-facing message. */
export async function tokenize(): Promise<string> {
if (isSquareMock()) {
if (!mockForm) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
return mockForm.tokenize();
}
const card = cardInstance as {
tokenize: () => Promise<{
status: string;
@@ -90,7 +104,13 @@
}
</script>
{#if initError === 'not_configured'}
{#if isSquareMock()}
{#await import('./MockCardForm.svelte')}
<div class="flex h-9 items-center text-sm text-gray-400">Loading card form...</div>
{:then { default: MockCardFormCtor }}
<MockCardFormCtor bind:this={mockForm} {disabled} {onReady} />
{/await}
{:else if initError === 'not_configured'}
<CardEntryUnavailable />
{:else if initError === 'init_failed'}
<CardEntryUnavailable
@@ -523,7 +523,9 @@
await fetchTodayBlockersData();
} else {
const text = await response.text();
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch (err) {
console.error('Error creating time blocker:', err);
@@ -547,7 +549,9 @@
await fetchTodayBlockersData();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch (err) {
console.error('Error deleting time blocker:', err);
+17 -4
View File
@@ -1,20 +1,33 @@
// Env vars (frontend build-time, public — safe for the browser):
// VITE_SQUARE_APPLICATION_ID Square Web Payments application ID (client-side public)
// VITE_SQUARE_LOCATION_ID Square location ID
// VITE_SQUARE_ENVIRONMENT 'sandbox' | 'production' (optional; auto-derived from
// the application ID prefix when omitted)
// VITE_SQUARE_ENVIRONMENT 'sandbox' | 'production' | 'mock' (optional;
// auto-derived from the application ID prefix when
// omitted). 'mock' is LOCAL-DEV ONLY: it renders a
// token-only mock card form (never a real Square.js
// iframe). It is additionally gated on the dev build
// (import.meta.env.DEV), so it can never activate in
// a production bundle even if the var is mis-set.
const APP_ID = (import.meta.env.VITE_SQUARE_APPLICATION_ID as string | undefined) ?? '';
const LOCATION_ID = (import.meta.env.VITE_SQUARE_LOCATION_ID as string | undefined) ?? '';
const SQUARE_ENV = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? '';
export interface SquareConfig {
appId: string;
locationId: string;
}
/** True when both Square application + location IDs are configured at build time. */
/** True when the frontend runs in local-dev mock mode: VITE_SQUARE_ENVIRONMENT === 'mock'
* AND the dev build (import.meta.env.DEV). The DEV gate makes the mock structurally
* impossible in any production bundle — even if the env var is mis-set at build time. */
export function isSquareMock(): boolean {
return SQUARE_ENV === 'mock' && import.meta.env.DEV;
}
/** True when a card form can be shown: real Square credentials OR local-dev mock mode. */
export function isSquareConfigured(): boolean {
return APP_ID !== '' && LOCATION_ID !== '';
return isSquareMock() || (APP_ID !== '' && LOCATION_ID !== '');
}
export function getSquareConfig(): SquareConfig | null {