diff --git a/app/pages/receipt/[token].vue b/app/pages/receipt/[token].vue
new file mode 100644
index 0000000..6c63017
--- /dev/null
+++ b/app/pages/receipt/[token].vue
@@ -0,0 +1,644 @@
+
+
+
+
+
+
+
+
+ {{ DINNER_EVENT_TITLE }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Shared
+
+
+ {{ sharedSeats.length }}
+
+
+
+
+ Available
+
+
+ {{ availableSeats.length }}
+
+
+
+
+ Total
+
+
+ {{ totalFormatted }}
+
+
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Date
+
+
+ {{ DINNER_EVENT_DATE_LABEL }}
+
+
+
+
+ Time
+
+
+ {{ DINNER_EVENT_TIME_LABEL }}
+
+
+
+
+ Venue
+
+
+ {{ DINNER_EVENT_VENUE }}
+
+
+
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Next Seats
+
+
+ {{ availableSeats.length ? seatsToShareLabel : 'No seats available' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ seat.recipientName || 'Unassigned' }}
+
+
+ {{ seat.recipientPhone }}
+
+
+ {{ seat.sharedAt ? `Shared ${formatDateTime(seat.sharedAt)}` : 'Available to share' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/seat/[token].vue b/app/pages/seat/[token].vue
new file mode 100644
index 0000000..8db54d2
--- /dev/null
+++ b/app/pages/seat/[token].vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+ {{ getSeatLabel(receipt.seat.seatNumber) }}
+
+
+ {{ DINNER_EVENT_TITLE }}
+
+
+
+
+
+
+
+ QR Code
+
+
+ Present this QR code at check-in.
+
+
+
+
+
+
+
+
+
+ {{ receipt.seat.recipientName || receipt.booking.customerName }}
+
+
+ {{ receipt.seat.recipientPhone }}
+
+
+ {{ receipt.booking.status === 'confirmed' ? 'Booking confirmed' : 'Booking pending confirmation' }}
+
+
+
+
+
+
+
+ Booking Details
+
+
+ Linked back to the main booking receipt.
+
+
+
+
+
+
+ Guest / Organizer
+
+
+ {{ receipt.booking.customerName }}
+
+
+
+
+ Ticket Category
+
+
+ {{ ticketLabel }}
+
+
+
+
+ Booking Mode
+
+
+ {{ getBookingModeLabel(receipt.booking.bookingMode) }}
+
+
+
+
+ Total Booking Value
+
+
+ {{ totalFormatted }}
+
+
+
+
+
+
+
+ {{ DINNER_EVENT_DATE_LABEL }}
+
+
+
+ {{ DINNER_EVENT_TIME_LABEL }}
+
+
+
+ {{ DINNER_EVENT_VENUE }}
+
+
+
+
+
+ Seat shared {{ receipt.seat.sharedAt ? formatDateTime(receipt.seat.sharedAt) : 'recently' }}
+
+
+ Submitted {{ formatDateTime(receipt.booking.createdAt) }}
+
+
+ Confirmed {{ formatDateTime(receipt.booking.confirmedAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 14fc08d..562413f 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@simplewebauthn/server": "^13.3.0",
"nuxt": "^4.4.2",
"postgres": "^3.4.9",
+ "qrcode": "^1.5.4",
"redis": "^5.11.0",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dace5be..e536962 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,7 +10,7 @@ importers:
dependencies:
'@nuxt/ui':
specifier: 4.6.1
- version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)
+ version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(qrcode@1.5.4)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)
'@simplewebauthn/browser':
specifier: ^13.3.0
version: 13.3.0
@@ -23,6 +23,9 @@ importers:
postgres:
specifier: ^3.4.9
version: 3.4.9
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
redis:
specifier: ^5.11.0
version: 5.11.0
@@ -2076,6 +2079,10 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
@@ -2100,6 +2107,9 @@ packages:
citty@0.2.2:
resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
@@ -2274,6 +2284,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -2315,6 +2329,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -2517,6 +2534,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
fontaine@0.8.0:
resolution: {integrity: sha512-eek1GbzOdWIj9FyQH/emqW1aEdfC3lYRCHepzwlFCm5T77fBSRSyNRKE6/antF1/B1M+SfJXVRQTY9GAr7lnDg==}
engines: {node: '>=18.12.0'}
@@ -2894,6 +2915,10 @@ packages:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@@ -3163,6 +3188,18 @@ packages:
peerDependencies:
oxc-parser: '>=0.98.0'
+ p-limit@2.3.0:
+ resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+ engines: {node: '>=6'}
+
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
+ p-try@2.2.0:
+ resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+ engines: {node: '>=6'}
+
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -3173,6 +3210,10 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -3218,6 +3259,10 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
postcss-calc@10.1.1:
resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==}
engines: {node: ^18.12 || ^20.9 || >=22.0}
@@ -3479,6 +3524,11 @@ packages:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -3537,6 +3587,13 @@ packages:
peerDependencies:
vue: '>= 3.4.0'
+ require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+ engines: {node: '>=0.10.0'}
+
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
@@ -3622,6 +3679,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -4185,6 +4245,9 @@ packages:
resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==}
engines: {node: '>=18'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -4195,6 +4258,10 @@ packages:
engines: {node: ^20.17.0 || >=22.9.0}
hasBin: true
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -4233,6 +4300,9 @@ packages:
peerDependencies:
yjs: ^13.0.0
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -4249,10 +4319,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
@@ -5006,7 +5084,7 @@ snapshots:
rc9: 3.0.1
std-env: 4.0.0
- '@nuxt/ui@4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)':
+ '@nuxt/ui@4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(qrcode@1.5.4)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)':
dependencies:
'@floating-ui/dom': 1.7.6
'@iconify/vue': 5.0.0(vue@3.5.32(typescript@6.0.2))
@@ -5041,7 +5119,7 @@ snapshots:
'@tiptap/vue-3': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(vue@3.5.32(typescript@6.0.2))
'@unhead/vue': 2.1.13(vue@3.5.32(typescript@6.0.2))
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2))
- '@vueuse/integrations': 14.2.1(fuse.js@7.3.0)(vue@3.5.32(typescript@6.0.2))
+ '@vueuse/integrations': 14.2.1(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.32(typescript@6.0.2))
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
colortranslator: 5.0.0
consola: 3.4.2
@@ -6271,13 +6349,14 @@ snapshots:
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
vue: 3.5.32(typescript@6.0.2)
- '@vueuse/integrations@14.2.1(fuse.js@7.3.0)(vue@3.5.32(typescript@6.0.2))':
+ '@vueuse/integrations@14.2.1(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.32(typescript@6.0.2))':
dependencies:
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2))
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
vue: 3.5.32(typescript@6.0.2)
optionalDependencies:
fuse.js: 7.3.0
+ qrcode: 1.5.4
'@vueuse/metadata@10.11.1': {}
@@ -6488,6 +6567,8 @@ snapshots:
cac@6.7.14: {}
+ camelcase@5.3.1: {}
+
caniuse-api@3.0.0:
dependencies:
browserslist: 4.28.2
@@ -6513,6 +6594,12 @@ snapshots:
citty@0.2.2: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@9.0.1:
dependencies:
string-width: 7.2.0
@@ -6668,6 +6755,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
@@ -6693,6 +6782,8 @@ snapshots:
diff@8.0.4: {}
+ dijkstrajs@1.0.3: {}
+
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -6892,6 +6983,11 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
fontaine@0.8.0:
dependencies:
'@capsizecss/unpack': 4.0.0
@@ -7267,6 +7363,10 @@ snapshots:
pkg-types: 2.3.0
quansync: 0.2.11
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
@@ -7803,12 +7903,24 @@ snapshots:
magic-regexp: 0.10.0
oxc-parser: 0.117.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ p-limit@2.3.0:
+ dependencies:
+ p-try: 2.2.0
+
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
+ p-try@2.2.0: {}
+
package-json-from-dist@1.0.1: {}
package-manager-detector@1.6.0: {}
parseurl@1.3.3: {}
+ path-exists@4.0.0: {}
+
path-key@3.1.1: {}
path-key@4.0.0: {}
@@ -7849,6 +7961,8 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ pngjs@5.0.0: {}
+
postcss-calc@10.1.1(postcss@8.5.9):
dependencies:
postcss: 8.5.9
@@ -8132,6 +8246,12 @@ snapshots:
pvutils@1.1.5: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
@@ -8207,6 +8327,10 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
+ require-directory@2.1.1: {}
+
+ require-main-filename@2.0.0: {}
+
resolve-from@5.0.0: {}
resolve@1.22.12:
@@ -8313,6 +8437,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ set-blocking@2.0.0: {}
+
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
@@ -8849,6 +8975,8 @@ snapshots:
wheel-gestures@2.2.48: {}
+ which-module@2.0.1: {}
+
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -8857,6 +8985,12 @@ snapshots:
dependencies:
isexe: 4.0.0
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -8891,6 +9025,8 @@ snapshots:
lib0: 0.2.117
yjs: 13.6.30
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -8899,8 +9035,27 @@ snapshots:
yaml@2.8.3: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@22.0.0: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@18.0.0:
dependencies:
cliui: 9.0.1
diff --git a/server/api/public/receipts/[token].get.ts b/server/api/public/receipts/[token].get.ts
new file mode 100644
index 0000000..6853b31
--- /dev/null
+++ b/server/api/public/receipts/[token].get.ts
@@ -0,0 +1,23 @@
+import type { PublicBookingReceipt } from '~~/shared/booking'
+
+import { getBookingReceiptByReceiptToken } from '../../../utils/booking-repository'
+import { buildAppUrl } from '../../../utils/app-url'
+import { getRequiredRouteParam, httpError } from '../../../utils/http'
+
+export default defineEventHandler(async (event): Promise
=> {
+ const token = getRequiredRouteParam(event, 'token', 'Receipt token')
+ const receipt = await getBookingReceiptByReceiptToken(token)
+
+ if (!receipt) {
+ httpError(404, 'Receipt not found')
+ }
+
+ return {
+ booking: receipt.booking,
+ receiptUrl: buildAppUrl(event, `/receipt/${receipt.booking.receiptToken}`),
+ seats: receipt.seats.map((seat) => ({
+ ...seat,
+ seatUrl: buildAppUrl(event, `/seat/${seat.seatToken}`)
+ }))
+ }
+})
diff --git a/server/api/public/receipts/[token]/seats/[seatId].patch.ts b/server/api/public/receipts/[token]/seats/[seatId].patch.ts
new file mode 100644
index 0000000..22036de
--- /dev/null
+++ b/server/api/public/receipts/[token]/seats/[seatId].patch.ts
@@ -0,0 +1,34 @@
+import type { PublicBookingSeatWithUrl } from '~~/shared/booking'
+
+import { updateBookingSeatShareByReceiptToken } from '../../../../../utils/booking-repository'
+import { parseSeatShareInput } from '../../../../../utils/bookings'
+import { buildAppUrl } from '../../../../../utils/app-url'
+import { getRequiredRouteParam, httpError } from '../../../../../utils/http'
+
+export default defineEventHandler(async (event): Promise<{ seat: PublicBookingSeatWithUrl }> => {
+ const token = getRequiredRouteParam(event, 'token', 'Receipt token')
+ const seatId = getRequiredRouteParam(event, 'seatId', 'Seat')
+ const body = await readBody<{
+ shared?: boolean
+ recipientName?: string | null
+ recipientPhone?: string | null
+ }>(event)
+
+ const input = parseSeatShareInput(body)
+ const seat = await updateBookingSeatShareByReceiptToken({
+ receiptToken: token,
+ seatId,
+ ...input
+ })
+
+ if (!seat) {
+ httpError(404, 'Seat not found')
+ }
+
+ return {
+ seat: {
+ ...seat,
+ seatUrl: buildAppUrl(event, `/seat/${seat.seatToken}`)
+ }
+ }
+})
diff --git a/server/api/public/seats/[token].get.ts b/server/api/public/seats/[token].get.ts
new file mode 100644
index 0000000..b2e933b
--- /dev/null
+++ b/server/api/public/seats/[token].get.ts
@@ -0,0 +1,23 @@
+import type { PublicSeatReceipt } from '~~/shared/booking'
+
+import { getSeatReceiptBySeatToken } from '../../../utils/booking-repository'
+import { buildAppUrl } from '../../../utils/app-url'
+import { getRequiredRouteParam, httpError } from '../../../utils/http'
+
+export default defineEventHandler(async (event): Promise => {
+ const token = getRequiredRouteParam(event, 'token', 'Seat token')
+ const receipt = await getSeatReceiptBySeatToken(token)
+
+ if (!receipt) {
+ httpError(404, 'Seat ticket not found')
+ }
+
+ return {
+ booking: receipt.booking,
+ receiptUrl: buildAppUrl(event, `/receipt/${receipt.booking.receiptToken}`),
+ seat: {
+ ...receipt.seat,
+ seatUrl: buildAppUrl(event, `/seat/${receipt.seat.seatToken}`)
+ }
+ }
+})
diff --git a/server/utils/booking-repository.ts b/server/utils/booking-repository.ts
index 45469eb..7f31ea2 100644
--- a/server/utils/booking-repository.ts
+++ b/server/utils/booking-repository.ts
@@ -6,6 +6,8 @@ import type {
BookingMode,
BookingStatus,
PublicBooking,
+ PublicBookingSeat,
+ ReceiptBooking,
TicketType
} from '~~/shared/booking'
@@ -18,6 +20,7 @@ import { getSqlClient } from './postgres'
type DbBookingRow = {
id: string
confirmation_token: string
+ receipt_token: string
customer_name: string
customer_phone: string
booking_mode: BookingMode
@@ -34,6 +37,34 @@ type DbBookingRow = {
confirmed_at: Date | string | null
}
+type DbBookingSeatRow = {
+ id: string
+ seat_number: number | string
+ seat_token: string
+ recipient_name: string | null
+ recipient_phone: string | null
+ shared_at: Date | string | null
+ created_at: Date | string
+ updated_at: Date | string
+}
+
+type DbBookingSeatWithBookingRow = DbBookingSeatRow & {
+ booking_id: string
+ confirmation_token: string
+ receipt_token: string
+ customer_name: string
+ customer_phone: string
+ booking_mode: BookingMode
+ quantity: number | string
+ seat_count: number | string
+ ticket_type: TicketType
+ unit_price: number | string
+ total_price: number | string
+ status: BookingStatus | string
+ booking_created_at: Date | string
+ confirmed_at: Date | string | null
+}
+
type DbBookingSettingsRow = {
total_tables: number | string | null
updated_at: Date | string
@@ -47,6 +78,7 @@ function mapBooking(row: DbBookingRow): PublicBooking {
return {
id: row.id,
confirmationToken: row.confirmation_token,
+ receiptToken: row.receipt_token,
customerName: row.customer_name,
customerPhone: row.customer_phone,
bookingMode: row.booking_mode,
@@ -64,11 +96,41 @@ function mapBooking(row: DbBookingRow): PublicBooking {
}
}
+function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
+ return {
+ id: row.id,
+ receiptToken: row.receipt_token,
+ customerName: row.customer_name,
+ customerPhone: row.customer_phone,
+ bookingMode: row.booking_mode,
+ quantity: parseInteger(row.quantity),
+ seatCount: parseInteger(row.seat_count),
+ ticketType: row.ticket_type,
+ unitPrice: parseInteger(row.unit_price),
+ totalPrice: parseInteger(row.total_price),
+ status: isBookingStatus(row.status) ? row.status : 'pending',
+ createdAt: toIsoString('booking_created_at' in row ? row.booking_created_at : row.created_at) ?? new Date().toISOString(),
+ confirmedAt: toIsoString(row.confirmed_at)
+ }
+}
+
+function mapBookingSeat(row: DbBookingSeatRow): PublicBookingSeat {
+ return {
+ id: row.id,
+ seatNumber: parseInteger(row.seat_number),
+ seatToken: row.seat_token,
+ recipientName: row.recipient_name,
+ recipientPhone: row.recipient_phone,
+ sharedAt: toIsoString(row.shared_at),
+ createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
+ updatedAt: toIsoString(row.updated_at) ?? new Date().toISOString()
+ }
+}
+
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
if (!row) {
return {
totalTables: null,
- totalSeats: null,
updatedAt: null
}
}
@@ -79,6 +141,29 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
}
}
+async function insertBookingSeats(
+ tx: ReturnType,
+ bookingId: string,
+ seatCount: number
+) {
+ for (let seatNumber = 1; seatNumber <= seatCount; seatNumber += 1) {
+ await tx`
+ insert into booking_seats (
+ id,
+ booking_id,
+ seat_number,
+ seat_token
+ )
+ values (
+ ${randomUUID()},
+ ${bookingId},
+ ${seatNumber},
+ ${randomToken(24)}
+ )
+ `
+ }
+}
+
export async function createBooking(input: {
customerName: string
customerPhone: string
@@ -94,63 +179,75 @@ export async function createBooking(input: {
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
+ const bookingId = randomUUID()
const confirmationToken = randomToken(24)
+ const receiptToken = randomToken(24)
- const [row] = await sql`
- insert into bookings (
- id,
- confirmation_token,
- customer_name,
- customer_phone,
- booking_mode,
- quantity,
- seat_count,
- ticket_type,
- unit_price,
- total_price,
- person_in_charge_id,
- person_in_charge_name,
- person_in_charge_phone_number,
- status
- )
- values (
- ${randomUUID()},
- ${confirmationToken},
- ${input.customerName},
- ${input.customerPhone},
- ${input.bookingMode},
- ${input.quantity},
- ${input.seatCount},
- ${input.ticketType},
- ${input.unitPrice},
- ${input.totalPrice},
- ${input.personInChargeId},
- ${input.personInChargeName},
- ${input.personInChargePhoneNumber},
- 'pending'
- )
- returning
- id,
- confirmation_token,
- customer_name,
- customer_phone,
- booking_mode,
- quantity,
- seat_count,
- ticket_type,
- unit_price,
- total_price,
- person_in_charge_id,
- person_in_charge_name,
- person_in_charge_phone_number,
- status,
- created_at,
- confirmed_at
- `
+ const row = await sql.begin(async (tx) => {
+ const [createdBooking] = await tx`
+ insert into bookings (
+ id,
+ confirmation_token,
+ receipt_token,
+ customer_name,
+ customer_phone,
+ booking_mode,
+ quantity,
+ seat_count,
+ ticket_type,
+ unit_price,
+ total_price,
+ person_in_charge_id,
+ person_in_charge_name,
+ person_in_charge_phone_number,
+ status
+ )
+ values (
+ ${bookingId},
+ ${confirmationToken},
+ ${receiptToken},
+ ${input.customerName},
+ ${input.customerPhone},
+ ${input.bookingMode},
+ ${input.quantity},
+ ${input.seatCount},
+ ${input.ticketType},
+ ${input.unitPrice},
+ ${input.totalPrice},
+ ${input.personInChargeId},
+ ${input.personInChargeName},
+ ${input.personInChargePhoneNumber},
+ 'pending'
+ )
+ returning
+ id,
+ confirmation_token,
+ receipt_token,
+ customer_name,
+ customer_phone,
+ booking_mode,
+ quantity,
+ seat_count,
+ ticket_type,
+ unit_price,
+ total_price,
+ person_in_charge_id,
+ person_in_charge_name,
+ person_in_charge_phone_number,
+ status,
+ created_at,
+ confirmed_at
+ `
+
+ await insertBookingSeats(tx, bookingId, input.seatCount)
+
+ return createdBooking
+ })
return {
booking: mapBooking(row),
- confirmationToken
+ confirmationToken,
+ receiptToken
}
}
@@ -162,6 +259,7 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
select
id,
confirmation_token,
+ receipt_token,
customer_name,
customer_phone,
booking_mode,
@@ -184,6 +282,37 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
return row ? mapBooking(row) : null
}
+export async function getBookingByReceiptToken(receiptToken: string): Promise {
+ await ensureDatabaseReady()
+ const sql = getSqlClient()
+
+ const [row] = await sql`
+ select
+ id,
+ confirmation_token,
+ receipt_token,
+ customer_name,
+ customer_phone,
+ booking_mode,
+ quantity,
+ seat_count,
+ ticket_type,
+ unit_price,
+ total_price,
+ person_in_charge_id,
+ person_in_charge_name,
+ person_in_charge_phone_number,
+ status,
+ created_at,
+ confirmed_at
+ from bookings
+ where receipt_token = ${receiptToken}
+ limit 1
+ `
+
+ return row ? mapBooking(row) : null
+}
+
export async function listBookings(options?: {
personInChargeId?: string
}): Promise {
@@ -195,6 +324,7 @@ export async function listBookings(options?: {
select
id,
confirmation_token,
+ receipt_token,
customer_name,
customer_phone,
booking_mode,
@@ -217,6 +347,7 @@ export async function listBookings(options?: {
select
id,
confirmation_token,
+ receipt_token,
customer_name,
customer_phone,
booking_mode,
@@ -238,6 +369,151 @@ export async function listBookings(options?: {
return rows.map(mapBooking)
}
+export async function listBookingSeats(bookingId: string): Promise {
+ await ensureDatabaseReady()
+ const sql = getSqlClient()
+
+ const rows = await sql`
+ select
+ id,
+ seat_number,
+ seat_token,
+ recipient_name,
+ recipient_phone,
+ shared_at,
+ created_at,
+ updated_at
+ from booking_seats
+ where booking_id = ${bookingId}
+ order by seat_number asc
+ `
+
+ return rows.map(mapBookingSeat)
+}
+
+export async function getBookingReceiptByReceiptToken(receiptToken: string): Promise<{
+ booking: ReceiptBooking
+ seats: PublicBookingSeat[]
+} | null> {
+ const booking = await getBookingByReceiptToken(receiptToken)
+
+ if (!booking) {
+ return null
+ }
+
+ const seats = await listBookingSeats(booking.id)
+
+ return {
+ booking: mapReceiptBooking({
+ id: booking.id,
+ confirmation_token: booking.confirmationToken,
+ receipt_token: booking.receiptToken,
+ customer_name: booking.customerName,
+ customer_phone: booking.customerPhone,
+ booking_mode: booking.bookingMode,
+ quantity: booking.quantity,
+ seat_count: booking.seatCount,
+ ticket_type: booking.ticketType,
+ unit_price: booking.unitPrice,
+ total_price: booking.totalPrice,
+ person_in_charge_id: booking.personInChargeId,
+ person_in_charge_name: booking.personInChargeName,
+ person_in_charge_phone_number: booking.personInChargePhoneNumber,
+ status: booking.status,
+ created_at: booking.createdAt,
+ confirmed_at: booking.confirmedAt
+ }),
+ seats
+ }
+}
+
+export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
+ booking: ReceiptBooking
+ seat: PublicBookingSeat
+} | null> {
+ await ensureDatabaseReady()
+ const sql = getSqlClient()
+
+ const [row] = await sql`
+ select
+ booking_seats.id,
+ booking_seats.seat_number,
+ booking_seats.seat_token,
+ booking_seats.recipient_name,
+ booking_seats.recipient_phone,
+ booking_seats.shared_at,
+ booking_seats.created_at,
+ booking_seats.updated_at,
+ bookings.id as booking_id,
+ bookings.confirmation_token,
+ bookings.receipt_token,
+ bookings.customer_name,
+ bookings.customer_phone,
+ bookings.booking_mode,
+ bookings.quantity,
+ bookings.seat_count,
+ bookings.ticket_type,
+ bookings.unit_price,
+ bookings.total_price,
+ bookings.status,
+ bookings.created_at as booking_created_at,
+ bookings.confirmed_at
+ from booking_seats
+ inner join bookings on bookings.id = booking_seats.booking_id
+ where booking_seats.seat_token = ${seatToken}
+ limit 1
+ `
+
+ if (!row) {
+ return null
+ }
+
+ return {
+ booking: mapReceiptBooking({
+ ...row,
+ id: row.booking_id
+ }),
+ seat: mapBookingSeat(row)
+ }
+}
+
+export async function updateBookingSeatShareByReceiptToken(input: {
+ receiptToken: string
+ seatId: string
+ shared: boolean
+ recipientName: string | null
+ recipientPhone: string | null
+}): Promise {
+ await ensureDatabaseReady()
+ const sql = getSqlClient()
+ const nextSeatToken = input.shared ? null : randomToken(24)
+
+ const [row] = await sql`
+ update booking_seats
+ set
+ recipient_name = ${input.shared ? input.recipientName : null},
+ recipient_phone = ${input.shared ? input.recipientPhone : null},
+ shared_at = ${input.shared ? new Date() : null},
+ seat_token = coalesce(${nextSeatToken}, seat_token),
+ updated_at = now()
+ from bookings
+ where booking_seats.booking_id = bookings.id
+ and bookings.receipt_token = ${input.receiptToken}
+ and booking_seats.id = ${input.seatId}
+ returning
+ booking_seats.id,
+ booking_seats.seat_number,
+ booking_seats.seat_token,
+ booking_seats.recipient_name,
+ booking_seats.recipient_phone,
+ booking_seats.shared_at,
+ booking_seats.created_at,
+ booking_seats.updated_at
+ `
+
+ return row ? mapBookingSeat(row) : null
+}
+
export async function getBookingCapacitySettings(): Promise {
await ensureDatabaseReady()
const sql = getSqlClient()
@@ -298,6 +574,7 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
returning
id,
confirmation_token,
+ receipt_token,
customer_name,
customer_phone,
booking_mode,
diff --git a/server/utils/bookings.ts b/server/utils/bookings.ts
index 4abcc8c..f831663 100644
--- a/server/utils/bookings.ts
+++ b/server/utils/bookings.ts
@@ -63,6 +63,29 @@ export function buildBookingMessage(booking: PublicBooking, confirmationUrl: str
].join('\n')
}
+export function parseSeatShareInput(body: {
+ shared?: boolean
+ recipientName?: string | null
+ recipientPhone?: string | null
+}) {
+ const shared = body.shared
+ const recipientName = normalizeFullName(body.recipientName || '')
+ const recipientPhone = normalizePhoneNumber(body.recipientPhone || '')
+
+ assertBadRequest(typeof shared === 'boolean', 'Shared flag is required')
+
+ if (shared) {
+ assertBadRequest(!recipientName || hasValidFullName(recipientName), 'Recipient name must be at least 2 characters')
+ assertBadRequest(!recipientPhone || isValidPhoneNumber(recipientPhone), 'Recipient phone number must contain 8 to 15 digits')
+ }
+
+ return {
+ shared,
+ recipientName: recipientName || null,
+ recipientPhone: recipientPhone || null
+ }
+}
+
export function parseBookingCapacityInput(body: {
totalTables?: number | string | null
}): Pick {
diff --git a/server/utils/db-init.ts b/server/utils/db-init.ts
index de7917b..dcabbf3 100644
--- a/server/utils/db-init.ts
+++ b/server/utils/db-init.ts
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
+import { randomToken } from './base64url'
import { hashPassword } from './password'
import { getSqlClient } from './postgres'
@@ -65,6 +66,7 @@ async function initializeDatabase() {
create table if not exists bookings (
id text primary key,
confirmation_token text not null unique,
+ receipt_token text not null unique,
customer_name text not null,
customer_phone text not null,
booking_mode text not null check (booking_mode in ('table', 'pax')),
@@ -83,6 +85,36 @@ async function initializeDatabase() {
)
`
+ await sql`
+ alter table bookings
+ add column if not exists receipt_token text
+ `
+
+ await sql`
+ create unique index if not exists bookings_receipt_token_idx
+ on bookings (receipt_token)
+ `
+
+ await sql`
+ create table if not exists booking_seats (
+ id text primary key,
+ booking_id text not null references bookings(id) on delete cascade,
+ seat_number integer not null check (seat_number >= 1),
+ seat_token text not null unique,
+ recipient_name text,
+ recipient_phone text,
+ shared_at timestamptz,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ unique (booking_id, seat_number)
+ )
+ `
+
+ await sql`
+ create index if not exists booking_seats_booking_id_idx
+ on booking_seats (booking_id)
+ `
+
await sql`
create table if not exists booking_settings (
id text primary key,
@@ -98,6 +130,74 @@ async function initializeDatabase() {
on conflict (id) do nothing
`
+ const bookingsMissingReceiptTokens = await sql<{ id: string }[]>`
+ select id
+ from bookings
+ where receipt_token is null or receipt_token = ''
+ `
+
+ for (const booking of bookingsMissingReceiptTokens) {
+ await sql`
+ update bookings
+ set
+ receipt_token = ${randomToken(24)},
+ updated_at = now()
+ where id = ${booking.id}
+ `
+ }
+
+ const existingBookings = await sql<{ id: string, seat_count: number | string }[]>`
+ select
+ id,
+ seat_count
+ from bookings
+ `
+
+ for (const booking of existingBookings) {
+ const seatCount = typeof booking.seat_count === 'number'
+ ? booking.seat_count
+ : Number.parseInt(booking.seat_count, 10)
+
+ const existingSeatRows = await sql<{ seat_number: number | string }[]>`
+ select seat_number
+ from booking_seats
+ where booking_id = ${booking.id}
+ `
+
+ const existingSeatNumbers = new Set(
+ existingSeatRows.map((seat) => typeof seat.seat_number === 'number'
+ ? seat.seat_number
+ : Number.parseInt(seat.seat_number, 10))
+ )
+
+ for (let seatNumber = 1; seatNumber <= seatCount; seatNumber += 1) {
+ if (existingSeatNumbers.has(seatNumber)) {
+ continue
+ }
+
+ await sql`
+ insert into booking_seats (
+ id,
+ booking_id,
+ seat_number,
+ seat_token
+ )
+ values (
+ ${randomUUID()},
+ ${booking.id},
+ ${seatNumber},
+ ${randomToken(24)}
+ )
+ on conflict (booking_id, seat_number) do nothing
+ `
+ }
+ }
+
+ await sql`
+ alter table bookings
+ alter column receipt_token set not null
+ `
+
const [existingSuperAdmin] = await sql<{ id: string }[]>`
select id
from users
diff --git a/shared/booking.ts b/shared/booking.ts
index e0a1383..5e7de1b 100644
--- a/shared/booking.ts
+++ b/shared/booking.ts
@@ -2,6 +2,11 @@ export type BookingMode = 'table' | 'pax'
export type TicketType = 'vip' | 'supporter'
export type BookingStatus = 'pending' | 'confirmed'
+export const DINNER_EVENT_TITLE = 'DAP JOHOR 60th Anniversary Celebration'
+export const DINNER_EVENT_DATE_LABEL = 'Saturday, 30 May 2026'
+export const DINNER_EVENT_TIME_LABEL = '6:30 PM'
+export const DINNER_EVENT_VENUE = "Yong Peng's Chee Ann Kor"
+
export const BOOKING_MODE_OPTIONS = [
{
value: 'table',
@@ -31,6 +36,7 @@ export const BOOKING_TICKET_CATALOG = [
export interface PublicBooking {
id: string
confirmationToken: string
+ receiptToken: string
customerName: string
customerPhone: string
bookingMode: BookingMode
@@ -47,6 +53,49 @@ export interface PublicBooking {
confirmedAt: string | null
}
+export interface ReceiptBooking {
+ id: string
+ receiptToken: string
+ customerName: string
+ customerPhone: string
+ bookingMode: BookingMode
+ quantity: number
+ seatCount: number
+ ticketType: TicketType
+ unitPrice: number
+ totalPrice: number
+ status: BookingStatus
+ createdAt: string
+ confirmedAt: string | null
+}
+
+export interface PublicBookingSeat {
+ id: string
+ seatNumber: number
+ seatToken: string
+ recipientName: string | null
+ recipientPhone: string | null
+ sharedAt: string | null
+ createdAt: string
+ updatedAt: string
+}
+
+export interface PublicBookingSeatWithUrl extends PublicBookingSeat {
+ seatUrl: string
+}
+
+export interface PublicBookingReceipt {
+ booking: ReceiptBooking
+ receiptUrl: string
+ seats: PublicBookingSeatWithUrl[]
+}
+
+export interface PublicSeatReceipt {
+ booking: ReceiptBooking
+ seat: PublicBookingSeatWithUrl
+ receiptUrl: string
+}
+
export interface BookingCapacitySettings {
totalTables: number | null
updatedAt: string | null
@@ -109,6 +158,10 @@ export function formatBookingCurrency(value: number) {
}).format(value)
}
+export function getSeatLabel(seatNumber: number) {
+ return `Seat ${seatNumber}`
+}
+
export function calculateBookingInventorySummary(
bookings: Pick[],
settings: BookingCapacitySettings