Add Venmo payment step with QR code and configurable username (issue #7)
Deploy to Dev / Deploy & Smoke Test (push) Successful in 27s

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/<username>?txn=pay&amount=<price>&note=<encoded-note>
  Note format: "LisiLou Booking - [SessionType] on [Date]"
- QR code rendered from GET /api/venmo-qr?url=<encoded> (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 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:58:49 -06:00
parent 73403a0ddb
commit f58f5534c6
4 changed files with 526 additions and 79 deletions
+307 -1
View File
@@ -12,7 +12,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.18.3", "express": "^4.18.3",
"pdf-lib": "^1.17.1" "pdf-lib": "^1.17.1",
"qrcode": "^1.5.4"
} }
}, },
"node_modules/@pdf-lib/standard-fonts": { "node_modules/@pdf-lib/standard-fonts": {
@@ -46,6 +47,30 @@
"node": ">= 0.6" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -189,12 +214,50 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/chownr": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -257,6 +320,15 @@
"ms": "2.0.0" "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": { "node_modules/decompress-response": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -309,6 +381,12 @@
"node": ">=8" "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": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -341,6 +419,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT" "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": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -483,6 +567,19 @@
"node": ">= 0.8" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -516,6 +613,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -668,6 +774,27 @@
"node": ">= 0.10" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -839,6 +966,42 @@
"wrappy": "1" "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": { "node_modules/pako": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -854,6 +1017,15 @@
"node": ">= 0.8" "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": { "node_modules/path-to-regexp": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -872,6 +1044,15 @@
"tslib": "^1.11.1" "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": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -922,6 +1103,23 @@
"once": "^1.3.1" "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": { "node_modules/qs": {
"version": "6.15.3", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -991,6 +1189,21 @@
"node": ">= 6" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1074,6 +1287,12 @@
"node": ">= 0.8.0" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1215,6 +1434,32 @@
"safe-buffer": "~5.2.0" "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": { "node_modules/strip-json-comments": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -1325,11 +1570,72 @@
"node": ">= 0.8" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "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"
}
} }
} }
} }
+2 -1
View File
@@ -11,6 +11,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.18.3", "express": "^4.18.3",
"pdf-lib": "^1.17.1" "pdf-lib": "^1.17.1",
"qrcode": "^1.5.4"
} }
} }
+28 -3
View File
@@ -33,6 +33,7 @@ app.post('/api/bookings', (req, res) => {
const { const {
client_name, client_email, client_phone, client_sub, client_name, client_email, client_phone, client_sub,
session_date, session_type, session_length, location, session_date, session_type, session_length, location,
payment_status,
} = req.body; } = req.body;
if (!client_email || !session_date || !session_type || !session_length) { if (!client_email || !session_date || !session_type || !session_length) {
@@ -41,13 +42,37 @@ app.post('/api/bookings', (req, res) => {
const result = db.prepare(` const result = db.prepare(`
INSERT INTO bookings INSERT INTO bookings
(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 (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(client_name, client_email, client_phone, client_sub, session_date, session_type, session_length, location); `).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 }); 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 // Stamp the signature onto the contract PDF and record it
app.post('/api/bookings/:id/sign', async (req, res) => { app.post('/api/bookings/:id/sign', async (req, res) => {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
+193 -78
View File
@@ -1115,15 +1115,67 @@
font-weight: 300; font-weight: 300;
color: var(--color-primary); color: var(--color-primary);
margin: 0.5rem 0 1.25rem; margin: 0.5rem 0 1.25rem;
text-align: center;
} }
.payment-note { .payment-note {
background: var(--color-bg-warm); background: var(--color-bg-warm);
border-left: 2px solid var(--color-primary); border-left: 2px solid var(--color-primary);
padding: 1rem 1.25rem; padding: 0.9rem 1.25rem;
margin-bottom: 1.5rem; margin-bottom: 1.25rem;
font-size: 0.85rem; font-size: 0.8rem;
color: var(--color-text); 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 */ /* Confirm / summary */
@@ -1353,9 +1405,9 @@
<div class="booking-step-item" data-step="2"><div class="step-dot">2</div><span class="step-label-text">Session</span></div> <div class="booking-step-item" data-step="2"><div class="step-dot">2</div><span class="step-label-text">Session</span></div>
<div class="booking-step-item" data-step="3"><div class="step-dot">3</div><span class="step-label-text">Length</span></div> <div class="booking-step-item" data-step="3"><div class="step-dot">3</div><span class="step-label-text">Length</span></div>
<div class="booking-step-item" data-step="4"><div class="step-dot">4</div><span class="step-label-text">Location</span></div> <div class="booking-step-item" data-step="4"><div class="step-dot">4</div><span class="step-label-text">Location</span></div>
<div class="booking-step-item" data-step="5"><div class="step-dot">5</div><span class="step-label-text">Contract</span></div> <div class="booking-step-item" data-step="5"><div class="step-dot">5</div><span class="step-label-text">Info</span></div>
<div class="booking-step-item" data-step="6"><div class="step-dot">6</div><span class="step-label-text">Payment</span></div> <div class="booking-step-item" data-step="6"><div class="step-dot">6</div><span class="step-label-text">Contract</span></div>
<div class="booking-step-item" data-step="7"><div class="step-dot">7</div><span class="step-label-text">Confirm</span></div> <div class="booking-step-item" data-step="7"><div class="step-dot">7</div><span class="step-label-text">Payment</span></div>
</div> </div>
</div> </div>
@@ -1406,17 +1458,35 @@
</div> </div>
<!-- Step 5: Contract --> <!-- Step 5: Contract -->
<!-- Step 5: Your Info -->
<div class="step-panel" id="step-5"> <div class="step-panel" id="step-5">
<h3 class="step-heading">Your Information</h3>
<p class="step-subheading">How should we reach you to confirm your session?</p>
<div class="booking-field">
<label class="booking-label" for="client-name">Full Name</label>
<input type="text" class="booking-input" id="client-name" placeholder="Your full name" autocomplete="name">
</div>
<div class="booking-field">
<label class="booking-label" for="client-email">Email Address</label>
<input type="email" class="booking-input" id="client-email" placeholder="your@email.com" autocomplete="email">
</div>
<div class="booking-field">
<label class="booking-label" for="client-phone">Phone Number</label>
<input type="tel" class="booking-input" id="client-phone" placeholder="(555) 000-0000" autocomplete="tel">
</div>
<div class="booking-error" id="info-error" style="display:none;"></div>
</div>
<!-- Step 6: Contract — filled by initContractStep() -->
<div class="step-panel" id="step-6">
<h3 class="step-heading">Model Release &amp; Contract</h3> <h3 class="step-heading">Model Release &amp; Contract</h3>
<p class="step-subheading">Read the full agreement below, then sign and type your name.</p> <p class="step-subheading">Read the full agreement below, then sign and type your name.</p>
<!-- PDF viewer — filled by initContractStep() -->
<div class="contract-viewer" id="contract-viewer"> <div class="contract-viewer" id="contract-viewer">
<div class="contract-loading" id="contract-loading">Loading contract…</div> <div class="contract-loading" id="contract-loading">Loading contract…</div>
</div> </div>
<div class="contract-scroll-notice" id="contract-scroll-notice">Scroll to the bottom to enable signing ↓</div> <div class="contract-scroll-notice" id="contract-scroll-notice">Scroll to the bottom to enable signing ↓</div>
<!-- Signature pad — locked until scrolled -->
<div class="sig-section locked" id="sig-section"> <div class="sig-section locked" id="sig-section">
<div class="booking-label" style="margin-bottom:0.4rem;">Your Signature</div> <div class="booking-label" style="margin-bottom:0.4rem;">Your Signature</div>
<div class="sig-pad-wrapper"> <div class="sig-pad-wrapper">
@@ -1432,46 +1502,51 @@
<div class="booking-error" id="contract-error" style="display:none;"></div> <div class="booking-error" id="contract-error" style="display:none;"></div>
</div> </div>
<!-- Step 6: Payment --> <!-- Step 7: Payment — filled by initPaymentStep() -->
<div class="step-panel" id="step-6"> <div class="step-panel" id="step-7">
<h3 class="step-heading">Booking Fee</h3> <h3 class="step-heading">Booking Fee</h3>
<p class="step-subheading">A non-refundable booking fee reserves your date.</p> <p class="step-subheading">Send payment via Venmo to reserve your date.</p>
<div class="payment-amount" id="payment-amount"></div>
<div class="payment-note"> <div class="pay-summary" id="pay-summary">
Send payment via Venmo to reserve your session. Your date is not confirmed until payment is received. <div class="pay-summary-row"><span>Date</span><span id="pay-summary-date"></span></div>
</div> <div class="pay-summary-row"><span>Session</span><span id="pay-summary-type"></span></div>
<p style="font-size:0.82rem;color:var(--color-text-light);margin-bottom:1.5rem;">Venmo link and QR code will appear here — Issue #7</p> <div class="pay-summary-row"><span>Length</span><span id="pay-summary-length"></span></div>
<label class="booking-checkbox-label"> <div class="pay-summary-row"><span>Location</span><span id="pay-summary-location"></span></div>
<input type="checkbox" id="payment-sent">
I have sent the booking fee payment via Venmo.
</label>
<div class="booking-error" id="payment-error" style="display:none;">Please confirm you've sent payment to continue.</div>
</div> </div>
<!-- Step 7: Confirm --> <div class="payment-amount" id="payment-amount"></div>
<div class="step-panel" id="step-7">
<h3 class="step-heading">Almost There!</h3> <a class="venmo-btn" id="venmo-link" href="#" target="_blank" rel="noopener">
<p class="step-subheading">Review your details and enter your contact information.</p> <svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" style="vertical-align:middle;margin-right:0.4rem"><path d="M15.25 1C13.5 4.25 11.1 8.15 9.5 11H6.75L5 2.25l-4 .75 2.25 12.5h5.5C11.5 11.5 16.5 4.25 17 1h-1.75z"/></svg>
<div class="confirm-summary"> Pay on Venmo
<div class="confirm-row"><span class="confirm-label">Date</span><span class="confirm-value" id="summary-date"></span></div> </a>
<div class="confirm-row"><span class="confirm-label">Session Type</span><span class="confirm-value" id="summary-type"></span></div>
<div class="confirm-row"><span class="confirm-label">Length</span><span class="confirm-value" id="summary-length"></span></div> <div class="venmo-qr" id="venmo-qr"></div>
<div class="confirm-row"><span class="confirm-label">Location</span><span class="confirm-value" id="summary-location"></span></div>
<p class="payment-note">
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.
</p>
<label class="booking-checkbox-label">
<input type="checkbox" id="payment-sent">
I've sent the booking fee
</label>
<div class="booking-error" id="payment-error" style="display:none;">Please confirm you've sent payment to continue.</div>
<div class="booking-error" id="confirm-error" style="display:none;"></div>
</div> </div>
<div class="booking-field">
<label class="booking-label" for="client-name">Your Name</label> <!-- REMOVED step-7 confirm panel — client info now in step 5 -->
<input type="text" class="booking-input" id="client-name" placeholder="Full name" autocomplete="name"> <!-- keep a dummy anchor so old references still resolve -->
<div style="display:none">
<span id="summary-date"></span><span id="summary-type"></span>
<span id="summary-length"></span><span id="summary-location"></span>
<input id="REMOVED_client-name">
</div> </div>
<div class="booking-field"> <div class="booking-field">
<label class="booking-label" for="client-email">Email Address</label> <label class="booking-label" for="client-email">Email Address</label>
<input type="email" class="booking-input" id="client-email" placeholder="your@email.com" autocomplete="email"> <input type="email" class="booking-input" id="client-email" placeholder="your@email.com" autocomplete="email">
</div> </div>
<div class="booking-field">
<label class="booking-label" for="client-phone">Phone Number</label>
<input type="tel" class="booking-input" id="client-phone" placeholder="(555) 000-0000" autocomplete="tel">
</div>
<div class="booking-error" id="confirm-error" style="display:none;"></div>
</div>
<!-- Success --> <!-- Success -->
<div class="step-panel" id="step-success"> <div class="step-panel" id="step-success">
@@ -1993,6 +2068,44 @@ A complete service agreement and model release will be provided at your session
bookingState.signatureDrawn = false; 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}&note=${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() { function openBooking() {
document.getElementById('booking-overlay').classList.add('active'); document.getElementById('booking-overlay').classList.add('active');
document.body.style.overflow = 'hidden'; 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; btnNext.disabled = false;
counter.textContent = 'Step ' + step + ' of ' + BOOKING_STEPS; counter.textContent = 'Step ' + step + ' of ' + BOOKING_STEPS;
if (step === BOOKING_STEPS) populateSummary(); if (step === 6) initContractStep();
if (step === 5) initContractStep(); if (step === 7) initPaymentStep();
} }
function selectOption(card, field) { function selectOption(card, field) {
@@ -2115,43 +2228,10 @@ A complete service agreement and model release will be provided at your session
case 4: case 4:
return !!bookingState.sessionLocation; return !!bookingState.sessionLocation;
case 5: { case 5: {
const errEl = document.getElementById('contract-error'); // Your Info step
if (!bookingState.contractScrolled) {
errEl.textContent = 'Please scroll through the full agreement.';
errEl.style.display = 'block';
return false;
}
if (!bookingState.signatureDrawn) {
errEl.textContent = 'Please sign in the box above.';
errEl.style.display = 'block';
return false;
}
const name = document.getElementById('contract-name').value.trim();
if (!name) {
errEl.textContent = 'Please type your full name to confirm.';
errEl.style.display = 'block';
document.getElementById('contract-name').focus();
return false;
}
errEl.style.display = 'none';
return true;
}
case 6: {
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 name = document.getElementById('client-name').value.trim();
const email = document.getElementById('client-email').value.trim(); const email = document.getElementById('client-email').value.trim();
const errEl = document.getElementById('confirm-error'); const errEl = document.getElementById('info-error');
if (!name || !email) { if (!name || !email) {
errEl.textContent = 'Please enter your name and email address.'; errEl.textContent = 'Please enter your name and email address.';
errEl.style.display = 'block'; errEl.style.display = 'block';
@@ -2165,8 +2245,42 @@ A complete service agreement and model release will be provided at your session
errEl.style.display = 'none'; errEl.style.display = 'none';
return true; return true;
} }
case 6: {
// Contract step
const errEl = document.getElementById('contract-error');
if (!bookingState.contractScrolled) {
errEl.textContent = 'Please scroll through the full agreement.';
errEl.style.display = 'block';
return false;
}
if (!bookingState.signatureDrawn) {
errEl.textContent = 'Please sign in the box above.';
errEl.style.display = 'block';
return false;
}
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();
return false;
}
errEl.style.display = 'none';
return true;
}
case 7: {
// Payment step — checkbox + submit
const ok = document.getElementById('payment-sent').checked;
document.getElementById('payment-error').style.display = ok ? 'none' : 'block';
return ok;
}
default:
return true;
}
}
async function submitBooking() { async function submitBooking() {
// Client info is now collected in step 5
bookingState.sessionDate = document.getElementById('session-date').value; bookingState.sessionDate = document.getElementById('session-date').value;
bookingState.clientName = document.getElementById('client-name').value.trim(); bookingState.clientName = document.getElementById('client-name').value.trim();
bookingState.clientEmail = document.getElementById('client-email').value.trim(); bookingState.clientEmail = document.getElementById('client-email').value.trim();
@@ -2177,7 +2291,7 @@ A complete service agreement and model release will be provided at your session
btnNext.disabled = true; btnNext.disabled = true;
try { try {
// 1. Create booking record // 1. Create booking — include payment_status since client confirmed sending
const res = await fetch('/api/bookings', { const res = await fetch('/api/bookings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -2192,12 +2306,13 @@ A complete service agreement and model release will be provided at your session
session_length: bookingState.sessionLength, 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, || bookingState.sessionLocation,
payment_status: 'pending_confirmation',
}), }),
}); });
if (!res.ok) throw new Error('server error'); if (!res.ok) throw new Error('server error');
const { id: bookingId } = await res.json(); 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) { if (bookingState.signatureDrawn && bookingId) {
const sigCanvas = document.getElementById('sig-canvas'); const sigCanvas = document.getElementById('sig-canvas');
const contractName = document.getElementById('contract-name').value.trim(); const contractName = document.getElementById('contract-name').value.trim();