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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
let hideFooter = $derived(
|
||||
$page.url.pathname === '/admin/schedule' ||
|
||||
$page.url.pathname === '/account' ||
|
||||
$page.url.searchParams.get('format') === 'pdf'
|
||||
$page.url.pathname === '/account' ||
|
||||
$page.url.searchParams.get('format') === 'pdf'
|
||||
);
|
||||
|
||||
// Default to 'top-center' (mobile-first approach)
|
||||
|
||||
@@ -135,33 +135,33 @@
|
||||
|
||||
<section class="bg-gray-50">
|
||||
<div class="mx-auto max-w-4xl px-6 py-16">
|
||||
<h2 class="mb-8 text-center font-['Playfair_Display'] text-2xl font-semibold">Our Services</h2>
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
|
||||
<div class="rounded-lg border p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">Hands</h3>
|
||||
<p class="text-gray-600">
|
||||
Gel polish, nail art, and natural nail care — finished with a soothing hand massage.
|
||||
</p>
|
||||
<h2 class="mb-8 text-center font-['Playfair_Display'] text-2xl font-semibold">Our Services</h2>
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
|
||||
<div class="rounded-lg border p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">Hands</h3>
|
||||
<p class="text-gray-600">
|
||||
Gel polish, nail art, and natural nail care — finished with a soothing hand massage.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">Feet</h3>
|
||||
<p class="text-gray-600">
|
||||
Luxurious pedicures with exfoliation, cuticle care, and a long-lasting polish finish.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">Brows</h3>
|
||||
<p class="text-gray-600">
|
||||
Expert shaping, tinting, and lamination for brows that frame your face perfectly.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">Wax</h3>
|
||||
<p class="text-gray-600">
|
||||
Gentle waxing for face, body, and brows — smooth results with minimal discomfort.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">Feet</h3>
|
||||
<p class="text-gray-600">
|
||||
Luxurious pedicures with exfoliation, cuticle care, and a long-lasting polish finish.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">Brows</h3>
|
||||
<p class="text-gray-600">
|
||||
Expert shaping, tinting, and lamination for brows that frame your face perfectly.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">Wax</h3>
|
||||
<p class="text-gray-600">
|
||||
Gentle waxing for face, body, and brows — smooth results with minimal discomfort.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -169,8 +169,8 @@
|
||||
|
||||
<section class="bg-gray-50">
|
||||
<div class="mx-auto max-w-4xl px-6 py-16">
|
||||
<h2 class="mb-8 text-center font-['Playfair_Display'] text-2xl font-semibold">Opening Hours</h2>
|
||||
<BusinessHours />
|
||||
<h2 class="mb-8 text-center font-['Playfair_Display'] text-2xl font-semibold">Opening Hours</h2>
|
||||
<BusinessHours />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -178,5 +178,3 @@
|
||||
<h2 class="mb-4 font-['Playfair_Display'] text-2xl font-semibold">Ready to Treat Yourself?</h2>
|
||||
<Button href={resolve('/prices')} class="px-6 py-3 text-lg">View Price List</Button>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
@@ -766,7 +766,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} onChanged={() => fetchWeekData()} />
|
||||
<BookingModal
|
||||
bind:open={showBookingModal}
|
||||
bookingId={selectedBookingId ?? ''}
|
||||
onChanged={() => fetchWeekData()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user