From 839e63c0e0cbd82ce61298186802c7873b8658c6 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 25 Mar 2026 11:51:42 -0700 Subject: [PATCH] Add resilient payment agent template --- .../resilient-payment-agent/.env.example | 21 + typescript/resilient-payment-agent/README.md | 57 +++ typescript/resilient-payment-agent/index.ts | 438 ++++++++++++++++++ .../resilient-payment-agent/package.json | 22 + .../resilient-payment-agent/test-site/app.js | 79 ++++ .../test-site/index.html | 171 +++++++ .../test-site/styles.css | 288 ++++++++++++ .../resilient-payment-agent/tsconfig.json | 15 + 8 files changed, 1091 insertions(+) create mode 100644 typescript/resilient-payment-agent/.env.example create mode 100644 typescript/resilient-payment-agent/README.md create mode 100644 typescript/resilient-payment-agent/index.ts create mode 100644 typescript/resilient-payment-agent/package.json create mode 100644 typescript/resilient-payment-agent/test-site/app.js create mode 100644 typescript/resilient-payment-agent/test-site/index.html create mode 100644 typescript/resilient-payment-agent/test-site/styles.css create mode 100644 typescript/resilient-payment-agent/tsconfig.json diff --git a/typescript/resilient-payment-agent/.env.example b/typescript/resilient-payment-agent/.env.example new file mode 100644 index 00000000..3e56736c --- /dev/null +++ b/typescript/resilient-payment-agent/.env.example @@ -0,0 +1,21 @@ +# Execution mode: LOCAL (default) or BROWSERBASE +STAGEHAND_ENV="LOCAL" + +# Stagehand/Agent model (provider key required below) +STAGEHAND_MODEL="google/gemini-2.5-flash" +AGENT_MODEL="google/gemini-2.5-flash" + +# Optional target URL override. +# If empty, a local synthetic sandbox site is started automatically. +TARGET_FORM_URL="" +TEST_SITE_PORT="4173" + +# Browserbase credentials (required only when STAGEHAND_ENV=BROWSERBASE) +BROWSERBASE_PROJECT_ID="" +BROWSERBASE_API_KEY="" + +# Model provider API keys (set the one that matches your model) +OPENAI_API_KEY="" +GOOGLE_API_KEY="" +GOOGLE_GENERATIVE_AI_API_KEY="" +ANTHROPIC_API_KEY="" diff --git a/typescript/resilient-payment-agent/README.md b/typescript/resilient-payment-agent/README.md new file mode 100644 index 00000000..fa550bf2 --- /dev/null +++ b/typescript/resilient-payment-agent/README.md @@ -0,0 +1,57 @@ +# Stagehand + Browserbase: Resilient Payment Agent (Sanitized Template) + +## AT A GLANCE + +- Goal: Distill robust payment-form automation patterns from a customer demo into a public-safe template. +- Includes a synthetic checkout test site with no customer data and no real payment processing. +- Demonstrates guarded agent execution with CAPTCHA pause/resume and a custom field verification tool. +- Produces structured output (success, confirmation ID, charged amount, error reasoning). +- Docs -> https://docs.stagehand.dev/basics/agent + +## LESSONS DISTILLED + +- Explicit payload contract: map normalized business data to deterministic field expectations. +- Verification loop: fill fields, run `verifyFields`, fix issues, verify again before submit. +- Safety boundaries: avoid hidden/disabled inputs and avoid manual CAPTCHA interaction. +- Structured completion: use a schema so downstream systems get predictable output fields. +- Environment split: local synthetic sandbox by default, public URL when running in Browserbase cloud. + +## QUICKSTART + +1. `cd typescript/resilient-payment-agent` +2. `pnpm install` +3. `cp .env.example .env` +4. Add API keys for your chosen model provider in `.env` (for Google models, either `GOOGLE_API_KEY` or `GOOGLE_GENERATIVE_AI_API_KEY` works) +5. `pnpm start` + +## HOW IT RUNS + +- Default mode (`STAGEHAND_ENV=LOCAL`) starts a local synthetic checkout site on `TEST_SITE_PORT`. +- The script visits that sandbox URL, fills synthetic payment data, verifies fields, and submits. +- The sandbox emits `browserbase-solving-started` and `browserbase-solving-finished` console events so the agent can pause while CAPTCHA is "solving." +- Final output is JSON with `success`, `confirmation_id`, `charged_amount`, and `error_reasoning`. + +## USING BROWSERBASE CLOUD + +- Set `STAGEHAND_ENV=BROWSERBASE`. +- Set `TARGET_FORM_URL` to a public URL (not localhost). +- Include `BROWSERBASE_PROJECT_ID` and `BROWSERBASE_API_KEY`. +- Keep synthetic-only data in payloads for demos and testing. + +## COMMON PITFALLS + +- Browserbase cannot access localhost URLs; publish the sandbox site if you need cloud sessions. +- Missing model API keys will fail initialization. +- Overly broad agent prompts can cause mismatched field mapping; keep labels and payload keys explicit. + +## SAFE DEMO NOTES + +- No real merchants or customer portals are used by default. +- No PII is required; all sample values are synthetic. +- No payment network call is made; submission renders a fake sandbox confirmation panel. + +## HELPFUL RESOURCES + +- Stagehand Docs: https://docs.stagehand.dev/v3/first-steps/introduction +- Browserbase Docs: https://docs.browserbase.com +- Templates: https://www.browserbase.com/templates diff --git a/typescript/resilient-payment-agent/index.ts b/typescript/resilient-payment-agent/index.ts new file mode 100644 index 00000000..f140ceeb --- /dev/null +++ b/typescript/resilient-payment-agent/index.ts @@ -0,0 +1,438 @@ +import "dotenv/config"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Stagehand, tool } from "@browserbasehq/stagehand"; +import { z } from "zod"; + +type StagehandEnv = "LOCAL" | "BROWSERBASE"; + +type VerificationField = { + label: string; + expectedValue: string; +}; + +type PaymentPayload = { + websiteName: string; + firstName: string; + lastName: string; + company: string; + email: string; + phone: string; + addressLine1: string; + city: string; + state: string; + zipCode: string; + invoiceNumber: string; + paymentAmountUsd: string; + cardNumber: string; + expiryMonth: string; + expiryYear: string; + cvv: string; + cardHolderName: string; + additionalInstructions: string; +}; + +type RunningTestSite = { + url: string; + close: () => Promise; +}; + +type ModelOption = string | { modelName: string; apiKey: string }; + +const TEST_SITE_DIR = fileURLToPath(new URL("./test-site", import.meta.url)); +const DEFAULT_TEST_SITE_PORT = Number(process.env.TEST_SITE_PORT ?? "4173"); + +const SAMPLE_PAYMENT: PaymentPayload = { + websiteName: "Synthetic Checkout Sandbox", + firstName: "Jordan", + lastName: "Lee", + company: "Northwind Labs", + email: "jordan.lee.demo@example.com", + phone: "4155550198", + addressLine1: "145 Harbor Street", + city: "San Francisco", + state: "CA", + zipCode: "94104", + invoiceNumber: "INV-DEMO-2026-0142", + paymentAmountUsd: "125.62", + cardNumber: "4242 4242 4242 4242", + expiryMonth: "01", + expiryYear: "2030", + cvv: "123", + cardHolderName: "Jordan Lee", + additionalInstructions: + "Use only this synthetic sandbox page and do not navigate to external websites.", +}; + +const MIME_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", +}; + +const outputSchema = z.object({ + success: z.boolean().describe("Whether the payment was submitted successfully."), + confirmation_id: z.string().describe("Confirmation or reference ID shown after submission."), + charged_amount: z.string().describe("Submitted amount shown on the confirmation panel."), + error_reasoning: z.string().describe("If unsuccessful, explain why. Empty string if successful."), +}); + +function resolveModelOption(modelName: string): ModelOption { + const normalized = modelName.toLowerCase(); + + if (normalized.startsWith("google/")) { + const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GOOGLE_API_KEY; + return apiKey ? { modelName, apiKey } : modelName; + } + + if (normalized.startsWith("openai/")) { + return process.env.OPENAI_API_KEY + ? { modelName, apiKey: process.env.OPENAI_API_KEY } + : modelName; + } + + if (normalized.startsWith("anthropic/")) { + return process.env.ANTHROPIC_API_KEY + ? { modelName, apiKey: process.env.ANTHROPIC_API_KEY } + : modelName; + } + + return modelName; +} + +function normalizeEnv(value: string | undefined): StagehandEnv { + return value?.toUpperCase().trim() === "BROWSERBASE" ? "BROWSERBASE" : "LOCAL"; +} + +function normalizeValue(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function valuesMatch(expectedValue: string, currentValue: string): boolean { + const expected = normalizeValue(expectedValue); + const current = normalizeValue(currentValue); + + if (!expected || !current) { + return false; + } + + if (expected === current) { + return true; + } + + return expected.includes(current) || current.includes(expected); +} + +function buildFieldExpectations(payload: PaymentPayload): VerificationField[] { + return [ + { label: "First name", expectedValue: payload.firstName }, + { label: "Last name", expectedValue: payload.lastName }, + { label: "Company", expectedValue: payload.company }, + { label: "Email", expectedValue: payload.email }, + { label: "Phone", expectedValue: payload.phone }, + { label: "Address line 1", expectedValue: payload.addressLine1 }, + { label: "City", expectedValue: payload.city }, + { label: "State", expectedValue: payload.state }, + { label: "ZIP code", expectedValue: payload.zipCode }, + { label: "Invoice number", expectedValue: payload.invoiceNumber }, + { label: "Payment amount (USD)", expectedValue: payload.paymentAmountUsd }, + { label: "Card number", expectedValue: payload.cardNumber }, + { label: "Expiry month", expectedValue: payload.expiryMonth }, + { label: "Expiry year", expectedValue: payload.expiryYear }, + { label: "CVV", expectedValue: payload.cvv }, + { label: "Cardholder name", expectedValue: payload.cardHolderName }, + ]; +} + +function formatFieldExpectations(fields: VerificationField[]): string { + return fields.map((field) => `- ${field.label}: ${field.expectedValue}`).join("\n"); +} + +async function handleStaticRequest( + request: IncomingMessage, + response: ServerResponse, +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Method not allowed"); + return; + } + + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = decodeURIComponent(requestUrl.pathname); + const relativePath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); + const filePath = resolve(TEST_SITE_DIR, relativePath); + + if (!filePath.startsWith(`${TEST_SITE_DIR}/`) && filePath !== TEST_SITE_DIR) { + response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Forbidden"); + return; + } + + try { + const fileContent = await readFile(filePath); + const contentType = MIME_TYPES[extname(filePath)] ?? "application/octet-stream"; + response.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "no-cache, no-store, must-revalidate", + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + response.end(fileContent); + } catch (_error) { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + } +} + +async function startLocalTestSite(port: number): Promise { + const server = createServer((request, response) => { + void handleStaticRequest(request, response); + }); + + await new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(port, "127.0.0.1", () => resolvePromise()); + }); + + return { + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) { + rejectPromise(error); + return; + } + + resolvePromise(); + }); + }), + }; +} + +async function main(): Promise { + const stagehandEnv = normalizeEnv(process.env.STAGEHAND_ENV); + const stagehandModelName = process.env.STAGEHAND_MODEL ?? "google/gemini-2.5-flash"; + const agentModelName = process.env.AGENT_MODEL ?? stagehandModelName; + let localTestSite: RunningTestSite | null = null; + let stagehand: Stagehand | null = null; + + try { + let targetUrl = process.env.TARGET_FORM_URL?.trim(); + + if (!targetUrl) { + localTestSite = await startLocalTestSite(DEFAULT_TEST_SITE_PORT); + targetUrl = `${localTestSite.url}/index.html`; + console.log(`Local sandbox test site running at ${targetUrl}`); + } + + if ( + stagehandEnv === "BROWSERBASE" && + /https?:\/\/(?:localhost|127\.0\.0\.1)/i.test(targetUrl) + ) { + throw new Error( + "BROWSERBASE sessions cannot access localhost. Set TARGET_FORM_URL to a public deployment of ./test-site.", + ); + } + + stagehand = new Stagehand({ + env: stagehandEnv, + model: resolveModelOption(stagehandModelName), + experimental: true, + verbose: 1, + cacheDir: "./cache", + browserbaseSessionCreateParams: + stagehandEnv === "BROWSERBASE" + ? { + projectId: process.env.BROWSERBASE_PROJECT_ID, + browserSettings: { + solveCaptchas: true, + }, + } + : undefined, + }); + + await stagehand.init(); + const activeStagehand = stagehand; + + if (stagehandEnv === "BROWSERBASE" && stagehand.browserbaseSessionId) { + console.log( + `Live session: https://browserbase.com/sessions/${stagehand.browserbaseSessionId}`, + ); + } + + const page = stagehand.context.pages()[0]; + const fieldExpectations = buildFieldExpectations(SAMPLE_PAYMENT); + + let captchaSolving = false; + let captchaResolve: (() => void) | null = null; + + page.on("console", (message) => { + const text = message.text().trim(); + + if (text === "browserbase-solving-started") { + captchaSolving = true; + console.log("Captcha solving started."); + } else if (text === "browserbase-solving-finished") { + captchaSolving = false; + console.log("Captcha solving finished."); + captchaResolve?.(); + captchaResolve = null; + } + }); + + async function waitForCaptcha(): Promise { + if (!captchaSolving) { + return; + } + + await new Promise((resolvePromise) => { + captchaResolve = resolvePromise; + }); + } + + await page.goto(targetUrl, { + waitUntil: "domcontentloaded", + timeoutMs: 60000, + }); + + const agent = stagehand.agent({ + model: resolveModelOption(agentModelName), + mode: "dom", + systemPrompt: `You are a checkout automation agent for synthetic sandbox forms. +- Use only values from the provided payload. +- Fill only visible and enabled fields. +- Never solve CAPTCHAs manually. +- Always call verifyFields after filling all fields and before submitting. +- If verifyFields reports issues, fix only the flagged fields and run verifyFields again. +- After submission, extract the confirmation ID and charged amount from the success panel.`, + tools: { + verifyFields: tool({ + description: + "Checks that filled form fields match expected values. Returns ok=false with a list of issues if fields are empty or mismatched.", + inputSchema: z.object({ + fields: z.array( + z.object({ + label: z.string(), + expectedValue: z.string(), + }), + ), + }), + execute: async ({ fields }: { fields: VerificationField[] }) => { + if (fields.length === 0) { + return { + ok: false, + issues: [{ label: "unknown", issue: "No fields were provided to verifyFields." }], + }; + } + + const labels = fields.map((field) => field.label).join(", "); + const extractedFields = await activeStagehand.extract( + `Read the currently visible values for these fields: ${labels}. Return label/currentValue pairs.`, + z.array( + z.object({ + label: z.string(), + currentValue: z.string(), + }), + ), + ); + + const comparisons = fields.map((expectedField) => { + const matchCandidate = extractedFields.find((item) => { + const extractedLabel = normalizeValue(item.label); + const expectedLabel = normalizeValue(expectedField.label); + return ( + extractedLabel.includes(expectedLabel) || expectedLabel.includes(extractedLabel) + ); + }); + + const currentValue = matchCandidate?.currentValue ?? ""; + + return { + label: expectedField.label, + expectedValue: expectedField.expectedValue, + currentValue, + match: valuesMatch(expectedField.expectedValue, currentValue), + }; + }); + + const issues = comparisons + .filter((result) => !result.match) + .map((result) => ({ + label: result.label, + issue: result.currentValue + ? `Expected "${result.expectedValue}" but found "${result.currentValue}".` + : "Field is empty.", + })); + + if (issues.length === 0) { + return { + ok: true, + checked: comparisons.length, + message: "All fields matched expected values.", + }; + } + + return { + ok: false, + checked: comparisons.length, + issues, + }; + }, + }), + }, + }); + + const result = await agent.execute({ + instruction: `## Context +Website: ${SAMPLE_PAYMENT.websiteName} +Current page: ${targetUrl} + +## Payment payload +${formatFieldExpectations(fieldExpectations)} + +## Task +1. Dismiss cookie banners if present. +2. Fill every visible and enabled form field using the payload above. +3. Call verifyFields with every field/value pair you entered. If verifyFields returns ok=false, fix those fields and call verifyFields again. +4. Submit the form using the "Submit payment" button. +5. Extract the confirmation ID, charged amount, and status from the confirmation panel. + +## Site-specific guidance +${SAMPLE_PAYMENT.additionalInstructions}`, + maxSteps: 80, + output: outputSchema, + callbacks: { + prepareStep: async (stepContext) => { + await waitForCaptcha(); + return stepContext; + }, + }, + }); + + console.log("Agent result:"); + console.log(JSON.stringify(result, null, 2)); + } finally { + if (stagehand) { + await stagehand.close(); + } + + if (localTestSite) { + await localTestSite.close(); + console.log("Local sandbox test site stopped."); + } + } +} + +main().catch((error) => { + console.error("Template run failed:", error); + process.exit(1); +}); diff --git a/typescript/resilient-payment-agent/package.json b/typescript/resilient-payment-agent/package.json new file mode 100644 index 00000000..c2026c70 --- /dev/null +++ b/typescript/resilient-payment-agent/package.json @@ -0,0 +1,22 @@ +{ + "name": "resilient-payment-agent", + "version": "1.0.0", + "description": "Stagehand template for resilient synthetic payment-form automation", + "type": "module", + "main": "index.ts", + "scripts": { + "build": "tsc", + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "latest", + "dotenv": "^16.4.7", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + }, + "packageManager": "pnpm@9.0.0" +} diff --git a/typescript/resilient-payment-agent/test-site/app.js b/typescript/resilient-payment-agent/test-site/app.js new file mode 100644 index 00000000..9c5ccb84 --- /dev/null +++ b/typescript/resilient-payment-agent/test-site/app.js @@ -0,0 +1,79 @@ +/* global document, console, window, FormData */ + +const form = document.querySelector("#payment-form"); +const captchaStatus = document.querySelector("#captcha-status"); +const submitButton = document.querySelector("#submit-payment"); +const confirmation = document.querySelector("#confirmation"); + +let captchaSolved = false; + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatCurrency(value) { + const amount = Number(value); + if (Number.isNaN(amount)) { + return "$0.00"; + } + + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); +} + +function createConfirmationId(invoiceNumber) { + const normalizedInvoice = invoiceNumber.toUpperCase().replace(/[^A-Z0-9]/g, ""); + const invoicePart = normalizedInvoice.slice(-8).padStart(8, "0"); + const timePart = Date.now().toString().slice(-6); + return `SBX-${invoicePart}-${timePart}`; +} + +function simulateCaptcha() { + console.log("browserbase-solving-started"); + captchaStatus.textContent = "Security challenge in progress..."; + submitButton.disabled = true; + + window.setTimeout(() => { + captchaSolved = true; + console.log("browserbase-solving-finished"); + captchaStatus.textContent = "Security check complete. You can submit now."; + captchaStatus.classList.add("ready"); + submitButton.disabled = false; + }, 2200); +} + +form.addEventListener("submit", (event) => { + event.preventDefault(); + + if (!captchaSolved) { + captchaStatus.textContent = "Please wait for the security check to finish."; + return; + } + + const formData = new FormData(form); + const invoiceNumber = String(formData.get("invoice_number") ?? "NO-INVOICE"); + const paymentAmount = String(formData.get("payment_amount") ?? "0"); + const cardNumber = String(formData.get("card_number") ?? ""); + const cardLastFour = cardNumber.replace(/\D/g, "").slice(-4).padStart(4, "0"); + const confirmationId = createConfirmationId(invoiceNumber); + const chargedAmount = formatCurrency(paymentAmount); + + confirmation.innerHTML = ` +

Payment approved

+

StatusApproved (sandbox)

+

Confirmation ID${escapeHtml(confirmationId)}

+

Charged amount${escapeHtml(chargedAmount)}

+

Card used**** **** **** ${escapeHtml(cardLastFour)}

+ `; + confirmation.classList.add("visible"); + confirmation.scrollIntoView({ behavior: "smooth", block: "nearest" }); +}); + +simulateCaptcha(); diff --git a/typescript/resilient-payment-agent/test-site/index.html b/typescript/resilient-payment-agent/test-site/index.html new file mode 100644 index 00000000..6dfe7e3a --- /dev/null +++ b/typescript/resilient-payment-agent/test-site/index.html @@ -0,0 +1,171 @@ + + + + + + Synthetic Checkout Sandbox + + + + + + +
+ + +
+
+

Secure Portal

+

Pay Open Invoice

+

+ Fill all required fields. Submission renders a fake success response for automation + testing. +

+
+ +
+ Security check initializing... +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ + + + +
+ +
+
+
+ + + + diff --git a/typescript/resilient-payment-agent/test-site/styles.css b/typescript/resilient-payment-agent/test-site/styles.css new file mode 100644 index 00000000..00945322 --- /dev/null +++ b/typescript/resilient-payment-agent/test-site/styles.css @@ -0,0 +1,288 @@ +:root { + --bg-1: #f7f3e8; + --bg-2: #e6f4f1; + --ink: #172033; + --ink-soft: #3b4a61; + --panel: rgba(255, 255, 255, 0.82); + --line: rgba(23, 32, 51, 0.12); + --accent: #0f766e; + --accent-2: #f97316; + --success: #166534; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Space Grotesk", "Avenir Next", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(1100px 550px at 10% -20%, #fdd3a7 0%, transparent 55%), + radial-gradient(900px 600px at 92% 0%, #b8e1d9 0%, transparent 58%), + linear-gradient(150deg, var(--bg-1) 0%, var(--bg-2) 100%); + padding: clamp(1rem, 1vw + 0.8rem, 2rem); +} + +.checkout-shell { + width: min(1120px, 100%); + margin: 0 auto; + display: grid; + grid-template-columns: 0.85fr 1.2fr; + gap: 1rem; +} + +.panel { + border: 1px solid var(--line); + border-radius: 20px; + background: var(--panel); + backdrop-filter: blur(10px); + box-shadow: + 0 30px 45px rgba(15, 23, 42, 0.09), + inset 0 1px 0 rgba(255, 255, 255, 0.6); +} + +.summary-panel { + padding: clamp(1.2rem, 2.2vw, 1.8rem); + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.summary-panel h1 { + margin: 0; + font-size: clamp(1.5rem, 2.2vw, 2rem); + line-height: 1.1; +} + +.summary-copy { + margin: 0; + color: var(--ink-soft); + line-height: 1.45; +} + +.eyebrow { + margin: 0; + display: inline-flex; + width: fit-content; + border-radius: 999px; + padding: 0.25rem 0.7rem; + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #064e3b; + background: rgba(20, 184, 166, 0.14); +} + +.stat-row { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--line); + padding-top: 0.7rem; + font-size: 0.96rem; +} + +.tag-list { + margin-top: auto; + display: flex; + gap: 0.45rem; + flex-wrap: wrap; +} + +.tag { + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + letter-spacing: 0.02em; + border: 1px solid rgba(15, 118, 110, 0.2); + color: #0f766e; + background: rgba(15, 118, 110, 0.08); + border-radius: 999px; + padding: 0.32rem 0.62rem; +} + +.payment-panel { + padding: clamp(1.2rem, 2.2vw, 1.8rem); +} + +.payment-header h2 { + margin: 0.4rem 0 0.3rem; + font-size: clamp(1.35rem, 2vw, 1.8rem); +} + +.hint { + margin: 0; + color: var(--ink-soft); +} + +.captcha-pill { + margin-top: 1rem; + border: 1px solid rgba(249, 115, 22, 0.35); + border-radius: 12px; + background: rgba(249, 115, 22, 0.14); + color: #9a3412; + padding: 0.6rem 0.75rem; + font-size: 0.9rem; + font-family: "IBM Plex Mono", monospace; +} + +.captcha-pill.ready { + border-color: rgba(22, 101, 52, 0.4); + background: rgba(34, 197, 94, 0.16); + color: var(--success); +} + +form { + margin-top: 1rem; + display: grid; + gap: 0.8rem; +} + +.grid { + display: grid; + gap: 0.75rem; +} + +.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.three-col { + grid-template-columns: 1.5fr 1fr 1fr; +} + +.field { + display: grid; + gap: 0.35rem; +} + +.field span { + font-size: 0.86rem; + color: var(--ink-soft); +} + +input, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.78); + color: var(--ink); + font-size: 0.95rem; + font: inherit; + padding: 0.62rem 0.7rem; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +input:focus, +textarea:focus { + border-color: rgba(15, 118, 110, 0.65); + box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.18); + outline: none; + transform: translateY(-1px); +} + +button[type="submit"] { + margin-top: 0.3rem; + border: 0; + border-radius: 11px; + padding: 0.76rem 1rem; + font-family: "Space Grotesk", sans-serif; + font-size: 0.98rem; + font-weight: 700; + color: white; + background: linear-gradient(120deg, var(--accent) 0%, #1f9f95 42%, var(--accent-2) 100%); + cursor: pointer; + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease; + box-shadow: 0 12px 22px rgba(15, 118, 110, 0.28); +} + +button[type="submit"]:hover:enabled { + transform: translateY(-1px); + filter: brightness(1.03); +} + +button[type="submit"]:disabled { + cursor: not-allowed; + filter: grayscale(0.3); + opacity: 0.7; +} + +.confirmation-card { + display: none; + margin-top: 1rem; + border: 1px solid rgba(22, 101, 52, 0.24); + background: linear-gradient(160deg, rgba(220, 252, 231, 0.62) 0%, rgba(240, 253, 244, 0.85) 100%); + border-radius: 12px; + padding: 0.85rem; +} + +.confirmation-card.visible { + display: block; + animation: rise 220ms ease-out; +} + +.confirmation-card h3 { + margin: 0 0 0.5rem; +} + +.meta-line { + margin: 0.35rem 0; + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: baseline; +} + +.meta-line span { + color: var(--ink-soft); + font-size: 0.86rem; +} + +.meta-line strong { + font-family: "IBM Plex Mono", monospace; + font-size: 0.86rem; +} + +.hidden-field { + position: absolute; + left: -9999px; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 950px) { + .checkout-shell { + grid-template-columns: 1fr; + } + + .summary-panel { + order: 2; + } +} + +@media (max-width: 640px) { + .two-col, + .three-col { + grid-template-columns: 1fr; + } +} diff --git a/typescript/resilient-payment-agent/tsconfig.json b/typescript/resilient-payment-agent/tsconfig.json new file mode 100644 index 00000000..aa5fa3e6 --- /dev/null +++ b/typescript/resilient-payment-agent/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["index.ts"] +}