diff --git a/app/components/QrCodeSvg.vue b/app/components/QrCodeSvg.vue new file mode 100644 index 0000000..cb2abb2 --- /dev/null +++ b/app/components/QrCodeSvg.vue @@ -0,0 +1,69 @@ + + + diff --git a/app/pages/bookings/index.vue b/app/pages/bookings/index.vue index 526dead..a7afb33 100644 --- a/app/pages/bookings/index.vue +++ b/app/pages/bookings/index.vue @@ -276,6 +276,14 @@ icon="i-lucide-external-link" size="sm" /> + @@ -408,6 +416,10 @@ function confirmationPath(booking: PublicBooking) { return `/confirmation/${booking.confirmationToken}` } +function receiptPath(booking: PublicBooking) { + return `/receipt/${booking.receiptToken}` +} + function formatInventoryNumber(value: number | null) { return value === null ? 'Not set' : String(value) } diff --git a/app/pages/confirmation/[token].vue b/app/pages/confirmation/[token].vue index 03251c4..d3914eb 100644 --- a/app/pages/confirmation/[token].vue +++ b/app/pages/confirmation/[token].vue @@ -35,6 +35,7 @@ const booking = ref(initialBooking) const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning') const ticketLabel = computed(() => getTicketCatalogItem(booking.value.ticketType)?.label || booking.value.ticketType.toUpperCase()) const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice)) +const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`) const detailRows = computed(() => { const rows = [ { @@ -194,6 +195,16 @@ async function confirmBooking() { class="justify-center" /> + + ) {

- DAP JOHOR 60th Anniversary Celebration + {{ DINNER_EVENT_TITLE }}

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 @@ + + + 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 @@ + + + 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