Implement P11: Square Web Payments SDK new-card tokenization

Re-enable new-card entry across all 8 flows via Square Web Payments SDK
cnon: nonces (backend was already P11-ready):
- Add square.ts SDK loader (env-gated on VITE_SQUARE_APPLICATION_ID/LOCATION_ID,
  sandbox vs prod URL auto-derived from app-ID prefix) + SquareCardInput.svelte
  (tokenize() via bind:this, onReady state, CardEntryUnavailable fallback)
- CardSelection.svelte: replace newCardDisabled gate with new-card toggle +
  SquareCardInput; expose tokenize() for parent flows
- Wire new-card mode into tip x3, booking payment (UserPaymentModal), deposit
  (BookingFlow incl. guest), Buy a Gift Card + Add a Card (account), and admin
  till online_square (GiftCardsManagement create/topup)
- Retry-safe: each flow caches the one-shot nonce and reuses it on retry so the
  backend idempotency key dedups instead of re-tokenizing
- Docs: README, Gap Backlog P11, Feature Catalog, Technical Manual, P11 plan
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 1cdefb1834
commit 64d4b65083
17 changed files with 936 additions and 222 deletions
+82
View File
@@ -0,0 +1,82 @@
// 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)
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) ?? '';
export interface SquareConfig {
appId: string;
locationId: string;
}
/** True when both Square application + location IDs are configured at build time. */
export function isSquareConfigured(): boolean {
return APP_ID !== '' && LOCATION_ID !== '';
}
export function getSquareConfig(): SquareConfig | null {
if (!isSquareConfigured()) return null;
return { appId: APP_ID, locationId: LOCATION_ID };
}
function sdkUrl(): string {
const env = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? '';
const isSandbox = env === 'sandbox' || (APP_ID !== '' && APP_ID.startsWith('sandbox-'));
return isSandbox
? 'https://sandbox.web.squarecdn.com/v1/square.js'
: 'https://web.squarecdn.com/v1/square.js';
}
let sdkPromise: Promise<unknown> | null = null;
/**
* Loads the Square.js script once and resolves with the global `Square` object.
* The promise is cached so concurrent card forms share a single script load.
*/
export function loadSquareSdk(): Promise<unknown> {
if (typeof window === 'undefined') {
return Promise.reject(new Error('Square SDK requires a browser environment'));
}
const win = window as unknown as { Square?: unknown };
if (win.Square) {
return Promise.resolve(win.Square);
}
if (sdkPromise) return sdkPromise;
sdkPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = sdkUrl();
script.async = true;
script.dataset.squareSdk = 'true';
script.onload = () => {
if (win.Square) {
resolve(win.Square);
} else {
reject(new Error('Square.js loaded but the Square global is missing'));
}
};
script.onerror = () => {
sdkPromise = null;
reject(new Error('Failed to load Square Web Payments SDK'));
};
document.head.appendChild(script);
});
return sdkPromise;
}
/** Returns `Square.payments(appId, locationId)` once the SDK is loaded. */
export async function getSquarePayments(): Promise<unknown> {
const config = getSquareConfig();
if (!config) {
throw new Error(
'Square is not configured — set VITE_SQUARE_APPLICATION_ID and VITE_SQUARE_LOCATION_ID'
);
}
const Square = (await loadSquareSdk()) as {
payments: (appId: string, locationId: string) => unknown;
};
return Square.payments(config.appId, config.locationId);
}