From f58f5534c67c8e357692904ef824abb4c303cc9e Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Wed, 8 Jul 2026 12:58:49 -0600 Subject: [PATCH] Add Venmo payment step with QR code and configurable username (issue #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step reorder (still 7 steps): Info (5) → Contract (6) → Payment (7) - Client info moved to step 5 so it's available for the contract signature name and the personalised Venmo note before the payment step - Contract is now step 6 (initContractStep triggered on step 6 entry) - Step 7 is the full payment step — no separate confirm step needed Payment step (step 7): - Compact summary table: date, session type, length, location - Large price display from siteConfig.booking.pricing - "Pay on Venmo" button linking to: https://venmo.com/?txn=pay&amount=¬e= Note format: "LisiLou Booking - [SessionType] on [Date]" - QR code rendered from GET /api/venmo-qr?url= (server-side SVG via qrcode package, site colours applied) - "I've sent payment" checkbox gates the Submit button - Clicking Submit creates booking with payment_status='pending_confirmation', then signs the contract — success screen shown immediately Configuration: venmoUsername lives in config/site.json under booking.venmoUsername — change it there to point at any Venmo account with no code change and no rebuild. API additions: - GET /api/venmo-qr?url= — validates url starts with venmo.com, returns SVG - POST /api/bookings now accepts payment_status field Closes #7 Co-Authored-By: Claude Sonnet 4.6 --- api/package-lock.json | 308 +++++++++++++++++++++++++++++++++++++++++- api/package.json | 3 +- api/server.js | 31 ++++- src/index.html | 263 ++++++++++++++++++++++++++---------- 4 files changed, 526 insertions(+), 79 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index b50b7b6..5bb612c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -12,7 +12,8 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", - "pdf-lib": "^1.17.1" + "pdf-lib": "^1.17.1", + "qrcode": "^1.5.4" } }, "node_modules/@pdf-lib/standard-fonts": { @@ -46,6 +47,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -189,12 +214,50 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -257,6 +320,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -309,6 +381,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -341,6 +419,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -483,6 +567,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -516,6 +613,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -668,6 +774,27 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -839,6 +966,42 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -854,6 +1017,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -872,6 +1044,15 @@ "tslib": "^1.11.1" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -922,6 +1103,23 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -991,6 +1189,21 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1074,6 +1287,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1215,6 +1434,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -1325,11 +1570,72 @@ "node": ">= 0.8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/api/package.json b/api/package.json index 2da7419..e20da34 100644 --- a/api/package.json +++ b/api/package.json @@ -11,6 +11,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", - "pdf-lib": "^1.17.1" + "pdf-lib": "^1.17.1", + "qrcode": "^1.5.4" } } diff --git a/api/server.js b/api/server.js index f21627c..c22e72a 100644 --- a/api/server.js +++ b/api/server.js @@ -33,6 +33,7 @@ app.post('/api/bookings', (req, res) => { const { client_name, client_email, client_phone, client_sub, session_date, session_type, session_length, location, + payment_status, } = req.body; if (!client_email || !session_date || !session_type || !session_length) { @@ -41,13 +42,37 @@ app.post('/api/bookings', (req, res) => { const result = db.prepare(` INSERT INTO bookings - (client_name, client_email, client_phone, client_sub, session_date, session_type, session_length, location) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run(client_name, client_email, client_phone, client_sub, session_date, session_type, session_length, location); + (client_name, client_email, client_phone, client_sub, session_date, session_type, session_length, location, payment_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + client_name, client_email, client_phone, client_sub, + session_date, session_type, session_length, location, + payment_status || 'pending', + ); res.status(201).json({ id: result.lastInsertRowid }); }); +// Generate a QR code SVG for any URL — used by the Venmo payment step. +// Venmo username and amount come from the frontend (which reads site.json), +// so changing venmoUsername in config updates both the button and QR with +// no server restart needed. +app.get('/api/venmo-qr', async (req, res) => { + const { url } = req.query; + if (!url || !url.startsWith('https://venmo.com/')) { + return res.status(400).json({ error: 'url must be a venmo.com URL' }); + } + try { + const QRCode = require('qrcode'); + const svg = await QRCode.toString(url, { type: 'svg', margin: 2, width: 220, color: { dark: '#2C2C2C', light: '#FDFBF9' } }); + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.send(svg); + } catch (err) { + res.status(500).json({ error: 'QR generation failed' }); + } +}); + // Stamp the signature onto the contract PDF and record it app.post('/api/bookings/:id/sign', async (req, res) => { const id = parseInt(req.params.id, 10); diff --git a/src/index.html b/src/index.html index 7cbf580..bba7a1a 100644 --- a/src/index.html +++ b/src/index.html @@ -1115,15 +1115,67 @@ font-weight: 300; color: var(--color-primary); margin: 0.5rem 0 1.25rem; + text-align: center; } .payment-note { background: var(--color-bg-warm); border-left: 2px solid var(--color-primary); - padding: 1rem 1.25rem; - margin-bottom: 1.5rem; - font-size: 0.85rem; + padding: 0.9rem 1.25rem; + margin-bottom: 1.25rem; + font-size: 0.8rem; color: var(--color-text); + line-height: 1.6; + } + + .pay-summary { + border: 1px solid var(--color-accent); + margin-bottom: 1.25rem; + } + + .pay-summary-row { + display: flex; + justify-content: space-between; + padding: 0.5rem 0.9rem; + font-size: 0.8rem; + border-bottom: 1px solid var(--color-accent); + } + + .pay-summary-row:last-child { border-bottom: none; } + .pay-summary-row span:first-child { color: var(--color-text-light); } + .pay-summary-row span:last-child { font-weight: 500; color: var(--color-text); text-align: right; max-width: 60%; } + + .venmo-btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.85rem 1.5rem; + background: #3D95CE; + color: #fff; + text-decoration: none; + font-size: 0.9rem; + font-family: var(--font-body); + letter-spacing: 0.04em; + font-weight: 600; + margin-bottom: 1.25rem; + transition: background 0.2s; + } + + .venmo-btn:hover { background: #2d7db0; } + + .venmo-qr { + display: flex; + justify-content: center; + margin-bottom: 1.25rem; + } + + .venmo-qr svg { + width: 160px; + height: 160px; + border: 1px solid var(--color-accent); + padding: 6px; + background: var(--color-bg-warm); } /* Confirm / summary */ @@ -1353,9 +1405,9 @@
2
Session
3
Length
4
Location
-
5
Contract
-
6
Payment
-
7
Confirm
+
5
Info
+
6
Contract
+
7
Payment
@@ -1406,17 +1458,35 @@ +
+

Your Information

+

How should we reach you to confirm your session?

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +

Model Release & Contract

Read the full agreement below, then sign and type your name.

-
Loading contract…
Scroll to the bottom to enable signing ↓
-
Your Signature
@@ -1432,45 +1502,50 @@
- -
+ +

Booking Fee

-

A non-refundable booking fee reserves your date.

-
-
- Send payment via Venmo to reserve your session. Your date is not confirmed until payment is received. +

Send payment via Venmo to reserve your date.

+ +
+
Date
+
Session
+
Length
+
Location
-

Venmo link and QR code will appear here — Issue #7

+ +
+ + + + Pay on Venmo + + +
+ +

+ Your session is reserved once we confirm receipt — usually within 24 hours. + No Venmo? Send the exact amount and session note via any method and check the box below. +

+ +
- -
-

Almost There!

-

Review your details and enter your contact information.

-
-
Date
-
Session Type
-
Length
-
Location
-
-
- - + + +
+ + +
-
-
- - -
-
@@ -1993,6 +2068,44 @@ A complete service agreement and model release will be provided at your session bookingState.signatureDrawn = false; } + function initPaymentStep() { + const pricing = window.siteConfig?.booking?.pricing || { mini: 75, full: 150 }; + const username = window.siteConfig?.booking?.venmoUsername || 'LisiLouPhoto'; + const types = window.siteConfig?.booking?.sessionTypes || []; + const locations = window.siteConfig?.locations || []; + + const amount = bookingState.sessionLength === 'mini' ? pricing.mini : pricing.full; + const typeLabel = (bookingState.sessionType === 'other' && bookingState.sessionTypeOther) + ? `Other — ${bookingState.sessionTypeOther}` + : types.find(t => t.id === bookingState.sessionType)?.label || bookingState.sessionType; + const locLabel = locations.find(l => l.id === bookingState.sessionLocation)?.name || bookingState.sessionLocation; + const lenLabel = bookingState.sessionLength === 'mini' ? 'Mini — 30 min' : 'Full — 60 min'; + + document.getElementById('pay-summary-date').textContent = bookingState.sessionDate || '—'; + document.getElementById('pay-summary-type').textContent = typeLabel || '—'; + document.getElementById('pay-summary-length').textContent = lenLabel; + document.getElementById('pay-summary-location').textContent = locLabel || '—'; + document.getElementById('payment-amount').textContent = `$${amount}`; + + const note = `LisiLou Booking - ${typeLabel} on ${bookingState.sessionDate}`; + const venmoUrl = `https://venmo.com/${username}?txn=pay&amount=${amount}¬e=${encodeURIComponent(note)}`; + + document.getElementById('venmo-link').href = venmoUrl; + renderVenmoQr(venmoUrl); + } + + async function renderVenmoQr(venmoUrl) { + const container = document.getElementById('venmo-qr'); + container.innerHTML = ''; + try { + const res = await fetch(`/api/venmo-qr?url=${encodeURIComponent(venmoUrl)}`); + if (!res.ok) throw new Error('qr failed'); + container.innerHTML = await res.text(); + } catch { + // Silent — the button still works without the QR + } + } + function openBooking() { document.getElementById('booking-overlay').classList.add('active'); document.body.style.overflow = 'hidden'; @@ -2050,8 +2163,8 @@ A complete service agreement and model release will be provided at your session btnNext.disabled = false; counter.textContent = 'Step ' + step + ' of ' + BOOKING_STEPS; - if (step === BOOKING_STEPS) populateSummary(); - if (step === 5) initContractStep(); + if (step === 6) initContractStep(); + if (step === 7) initPaymentStep(); } function selectOption(card, field) { @@ -2115,6 +2228,25 @@ A complete service agreement and model release will be provided at your session case 4: return !!bookingState.sessionLocation; case 5: { + // Your Info step + const name = document.getElementById('client-name').value.trim(); + const email = document.getElementById('client-email').value.trim(); + const errEl = document.getElementById('info-error'); + if (!name || !email) { + errEl.textContent = 'Please enter your name and email address.'; + errEl.style.display = 'block'; + return false; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errEl.textContent = 'Please enter a valid email address.'; + errEl.style.display = 'block'; + return false; + } + errEl.style.display = 'none'; + return true; + } + case 6: { + // Contract step const errEl = document.getElementById('contract-error'); if (!bookingState.contractScrolled) { errEl.textContent = 'Please scroll through the full agreement.'; @@ -2126,8 +2258,8 @@ A complete service agreement and model release will be provided at your session errEl.style.display = 'block'; return false; } - const name = document.getElementById('contract-name').value.trim(); - if (!name) { + const sigName = document.getElementById('contract-name').value.trim(); + if (!sigName) { errEl.textContent = 'Please type your full name to confirm.'; errEl.style.display = 'block'; document.getElementById('contract-name').focus(); @@ -2136,77 +2268,60 @@ A complete service agreement and model release will be provided at your session errEl.style.display = 'none'; return true; } - case 6: { + case 7: { + // Payment step — checkbox + submit const ok = document.getElementById('payment-sent').checked; document.getElementById('payment-error').style.display = ok ? 'none' : 'block'; return ok; } - case 7: - return validateClientInfo(); default: return true; } } - function validateClientInfo() { - const name = document.getElementById('client-name').value.trim(); - const email = document.getElementById('client-email').value.trim(); - const errEl = document.getElementById('confirm-error'); - if (!name || !email) { - errEl.textContent = 'Please enter your name and email address.'; - errEl.style.display = 'block'; - return false; - } - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - errEl.textContent = 'Please enter a valid email address.'; - errEl.style.display = 'block'; - return false; - } - errEl.style.display = 'none'; - return true; - } - async function submitBooking() { - bookingState.sessionDate = document.getElementById('session-date').value; - bookingState.clientName = document.getElementById('client-name').value.trim(); - bookingState.clientEmail = document.getElementById('client-email').value.trim(); - bookingState.clientPhone = document.getElementById('client-phone').value.trim(); + // Client info is now collected in step 5 + bookingState.sessionDate = document.getElementById('session-date').value; + bookingState.clientName = document.getElementById('client-name').value.trim(); + bookingState.clientEmail = document.getElementById('client-email').value.trim(); + bookingState.clientPhone = document.getElementById('client-phone').value.trim(); const btnNext = document.getElementById('btn-next'); btnNext.textContent = 'Submitting…'; btnNext.disabled = true; try { - // 1. Create booking record + // 1. Create booking — include payment_status since client confirmed sending const res = await fetch('/api/bookings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - client_name: bookingState.clientName, - client_email: bookingState.clientEmail, - client_phone: bookingState.clientPhone, - session_date: bookingState.sessionDate, - session_type: bookingState.sessionType === 'other' + client_name: bookingState.clientName, + client_email: bookingState.clientEmail, + client_phone: bookingState.clientPhone, + session_date: bookingState.sessionDate, + session_type: bookingState.sessionType === 'other' ? 'other: ' + bookingState.sessionTypeOther : bookingState.sessionType, session_length: bookingState.sessionLength, - location: (window.siteConfig?.locations || []).find(l => l.id === bookingState.sessionLocation)?.name + location: (window.siteConfig?.locations || []).find(l => l.id === bookingState.sessionLocation)?.name || bookingState.sessionLocation, + payment_status: 'pending_confirmation', }), }); if (!res.ok) throw new Error('server error'); const { id: bookingId } = await res.json(); - // 2. Submit signature if collected in step 5 + // 2. Submit signature captured in step 6 if (bookingState.signatureDrawn && bookingId) { - const sigCanvas = document.getElementById('sig-canvas'); + const sigCanvas = document.getElementById('sig-canvas'); const contractName = document.getElementById('contract-name').value.trim(); await fetch(`/api/bookings/${bookingId}/sign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ signature_png: sigCanvas.toDataURL('image/png'), - full_name: contractName || bookingState.clientName, + full_name: contractName || bookingState.clientName, }), }); }