Give it a nice UI, add invitation syncing, replace the chat-stubs with actual implementation. Should probably inspect the syncing logic and the api client stuff a bit closer since it doesnt use the same logic that I had fed it as an example
This commit is contained in:
1
.env.development
Normal file
1
.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
300
package-lock.json
generated
300
package-lock.json
generated
@@ -1,25 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "my-vue-app",
|
"name": "vending-machine-www",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-vue-app",
|
"name": "vending-machine-www",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/vue": "^1.16.0",
|
"@lucide/vue": "^1.16.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reka-ui": "^2.9.8",
|
"reka-ui": "^2.9.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
"vue-router": "^5.0.7"
|
"vue-router": "^5.0.7",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.12.4",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"shadcn-vue": "^2.7.3",
|
"shadcn-vue": "^2.7.3",
|
||||||
@@ -1661,6 +1664,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/web-bluetooth": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.21",
|
"version": "0.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||||
@@ -2083,7 +2096,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -2093,7 +2105,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2409,6 +2420,15 @@
|
|||||||
"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/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001793",
|
"version": "1.0.30001793",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||||
@@ -2534,6 +2554,31 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/cliui/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/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -2554,7 +2599,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -2567,7 +2611,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
@@ -2802,6 +2845,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/decode-uri-component": {
|
"node_modules/decode-uri-component": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
||||||
@@ -2938,6 +2990,12 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
@@ -3074,7 +3132,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -3823,6 +3880,15 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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-east-asian-width": {
|
"node_modules/get-east-asian-width": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||||
@@ -4203,7 +4269,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5411,6 +5476,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -5439,9 +5513,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -5555,6 +5627,15 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
@@ -5737,6 +5818,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
@@ -5881,6 +5979,15 @@
|
|||||||
"vue": ">= 3.4.0"
|
"vue": ">= 3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -5891,6 +5998,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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/reserved-identifiers": {
|
"node_modules/reserved-identifiers": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz",
|
||||||
@@ -6138,6 +6251,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
@@ -6192,6 +6311,16 @@
|
|||||||
"shadcn-vue": "dist/index.js"
|
"shadcn-vue": "dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shadcn-vue/node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -6446,7 +6575,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -7271,6 +7399,12 @@
|
|||||||
"node": "^16.13.0 || >=18.0.0"
|
"node": "^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -7282,6 +7416,34 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/wrap-ansi/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/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",
|
||||||
@@ -7305,6 +7467,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@@ -7327,6 +7495,107 @@
|
|||||||
"url": "https://github.com/sponsors/eemeli"
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/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/yargs/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/yargs/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/yargs/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/yargs/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/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
@@ -7371,10 +7640,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "my-vue-app",
|
"name": "vending-machine-www",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,14 +13,17 @@
|
|||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reka-ui": "^2.9.8",
|
"reka-ui": "^2.9.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
"vue-router": "^5.0.7"
|
"vue-router": "^5.0.7",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.12.4",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"shadcn-vue": "^2.7.3",
|
"shadcn-vue": "^2.7.3",
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HelloWorld />
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
import { App } from '../services/app'
|
import { App } from '../services/app';
|
||||||
|
|
||||||
import type { App as VueApp } from 'vue'
|
import type { App as VueApp } from 'vue';
|
||||||
import type { Router } from 'vue-router'
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
export default async ({
|
export default async ({
|
||||||
app: vueApp,
|
app: vueApp,
|
||||||
router,
|
router,
|
||||||
}: {
|
}: {
|
||||||
app: VueApp
|
app: VueApp;
|
||||||
router: Router
|
router: Router;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
console.log('booting app')
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Instantiate new app instance.
|
const app = await App.create(router, {
|
||||||
const app = await App.create(router, { apiUrl: 'http://localhost:3000' })
|
apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:3000',
|
||||||
|
});
|
||||||
|
|
||||||
// Inject the app instance so that all children can access it.
|
vueApp.provide('app', app);
|
||||||
vueApp.provide('app', app)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log the error to the console.
|
console.error(error);
|
||||||
console.error(error)
|
|
||||||
// Create a dialog that never resolves so execution does not continue.
|
|
||||||
await new Promise(() => {
|
await new Promise(() => {
|
||||||
alert(`Failed to initialize app: ${error}`)
|
alert(`Failed to initialize app: ${error}`);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
44
src/components/InvitationQrCode.vue
Normal file
44
src/components/InvitationQrCode.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: string;
|
||||||
|
/** Canvas size in CSS pixels. */
|
||||||
|
size?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
async function renderQrCode(): Promise<void> {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!canvas || !props.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await QRCode.toCanvas(canvas, props.value, {
|
||||||
|
margin: 2,
|
||||||
|
width: props.size ?? 240,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void renderQrCode();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
() => {
|
||||||
|
void renderQrCode();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="rounded-lg border bg-white p-2"
|
||||||
|
:aria-label="`QR code for ${value}`"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
73
src/pages/CheckoutPage.vue
Normal file
73
src/pages/CheckoutPage.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useApp } from '@/services/app';
|
||||||
|
|
||||||
|
const app = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const lines = computed(() =>
|
||||||
|
app.catalog.cart.value.flatMap((line) => {
|
||||||
|
const item = app.catalog.items.value.get(line.itemId);
|
||||||
|
if (!item) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ ...item, quantity: line.quantity, lineTotal: item.price * line.quantity }];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = computed(() => app.catalog.cartTotal);
|
||||||
|
|
||||||
|
const confirmCheckout = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.checkout();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Checkout failed';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 p-6">
|
||||||
|
<header>
|
||||||
|
<Button variant="ghost" @click="router.push('/')">← Back to catalog</Button>
|
||||||
|
<h1 class="mt-4 text-3xl font-semibold">Checkout</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="rounded-xl border bg-card p-6 shadow-sm">
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<li
|
||||||
|
v-for="line in lines"
|
||||||
|
:key="line.id"
|
||||||
|
class="flex items-center justify-between border-b pb-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ line.quantity }}× {{ line.name }}</p>
|
||||||
|
<p class="text-muted-foreground text-sm">{{ line.price }} sats each</p>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold">{{ line.lineTotal }} sats</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-between text-lg font-semibold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{{ total }} sats</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-destructive text-sm">{{ error }}</p>
|
||||||
|
|
||||||
|
<Button :disabled="loading || lines.length === 0" @click="confirmCheckout">
|
||||||
|
{{ loading ? 'Creating order…' : 'Confirm & pay' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,14 +1,60 @@
|
|||||||
|
|
||||||
<template>
|
|
||||||
<div class="home-page">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useApp } from '@/services/app';
|
||||||
|
|
||||||
|
const app = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const items = computed(() => Array.from(app.catalog.items.value.values()));
|
||||||
|
const cartLines = computed(() => app.catalog.cart.value);
|
||||||
|
const cartTotal = computed(() => app.catalog.cartTotal);
|
||||||
|
|
||||||
|
function addToCart(itemId: string) {
|
||||||
|
app.catalog.addToCart(itemId, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCheckout() {
|
||||||
|
if (cartLines.value.length > 0) {
|
||||||
|
router.push('/checkout');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
|
<div class="mx-auto flex min-h-screen max-w-5xl flex-col gap-8 p-6">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-semibold tracking-tight">XO Vending Machine</h1>
|
||||||
|
<p class="text-muted-foreground mt-1">Select items and pay with XO Cash</p>
|
||||||
|
</div>
|
||||||
|
<Button :disabled="cartLines.length === 0" @click="goToCheckout">
|
||||||
|
Cart ({{ app.catalog.cartQuantity }}) — {{ cartTotal }} sats
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
</style>
|
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<article
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="rounded-xl border bg-card p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-medium">{{ item.name }}</h2>
|
||||||
|
<p class="text-muted-foreground mt-1 text-sm">{{ item.description }}</p>
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<span class="font-semibold">{{ item.price }} sats</span>
|
||||||
|
<span class="text-muted-foreground text-sm">Stock: {{ item.quantity }}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
class="mt-4 w-full"
|
||||||
|
:disabled="item.quantity < 1"
|
||||||
|
@click="addToCart(item.id)"
|
||||||
|
>
|
||||||
|
Add to cart
|
||||||
|
</Button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
268
src/pages/OrderPage.vue
Normal file
268
src/pages/OrderPage.vue
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import InvitationQrCode from '@/components/InvitationQrCode.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useApp } from '@/services/app';
|
||||||
|
import { InvitationSyncClient } from '@/services/invitation-sync-client';
|
||||||
|
import { OrderInvitation } from '@/services/modals/invitation';
|
||||||
|
import {
|
||||||
|
buildInvitationSyncFetchUrl,
|
||||||
|
buildXoInvitationImportUrl,
|
||||||
|
} from '@/utils/invitation-import-url';
|
||||||
|
|
||||||
|
const app = useApp();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const orderInvitation = ref<OrderInvitation | null>(null);
|
||||||
|
const copiedField = ref<string | null>(null);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const orderId = computed(() => String(route.params.id));
|
||||||
|
|
||||||
|
const status = computed(() => orderInvitation.value?.state.order.status ?? 'loading');
|
||||||
|
const receipt = computed(() => orderInvitation.value?.state.receipt ?? null);
|
||||||
|
const invitation = computed(() => orderInvitation.value?.state.invitation ?? '');
|
||||||
|
const syncServerUrl = computed(() => orderInvitation.value?.state.syncServerUrl ?? '');
|
||||||
|
const invitationIdentifier = computed(
|
||||||
|
() => orderInvitation.value?.state.order.invitation_identifier ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Wallet import link bundling sync server + invitation ID for QR scan. */
|
||||||
|
const walletImportUrl = computed(() => {
|
||||||
|
if (!syncServerUrl.value || !invitationIdentifier.value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return buildXoInvitationImportUrl(syncServerUrl.value, invitationIdentifier.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Direct sync-server fetch URL (alternative import path). */
|
||||||
|
const syncFetchUrl = computed(() => {
|
||||||
|
if (!syncServerUrl.value || !invitationIdentifier.value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return buildInvitationSyncFetchUrl(syncServerUrl.value, invitationIdentifier.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canPay = computed(
|
||||||
|
() =>
|
||||||
|
(status.value === 'pending' || status.value === 'paid') &&
|
||||||
|
Boolean(invitation.value || invitationIdentifier.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const active = app.activeOrder.value;
|
||||||
|
if (active?.state.order.id === orderId.value) {
|
||||||
|
orderInvitation.value = active;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const order = await app.apiClient.getOrder(orderId.value);
|
||||||
|
let invitationText = '';
|
||||||
|
|
||||||
|
if (order.invitation_identifier) {
|
||||||
|
const syncClient = new InvitationSyncClient(order.syncServerUrl);
|
||||||
|
invitationText =
|
||||||
|
(await syncClient.fetchSerialized(order.invitation_identifier)) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
orderInvitation.value = await OrderInvitation.create(
|
||||||
|
{ apiClient: app.apiClient },
|
||||||
|
{
|
||||||
|
order,
|
||||||
|
invitation: invitationText,
|
||||||
|
syncServerUrl: order.syncServerUrl,
|
||||||
|
receipt: {
|
||||||
|
summary: `Order ${order.id}`,
|
||||||
|
lineItems: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load order';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(async () => {
|
||||||
|
const current = orderInvitation.value;
|
||||||
|
const active = app.activeOrder.value;
|
||||||
|
if (current !== null && current !== active) {
|
||||||
|
await current.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyText(field: string, text: string): Promise<void> {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
copiedField.value = field;
|
||||||
|
setTimeout(() => {
|
||||||
|
copiedField.value = null;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLabel(field: string, defaultLabel: string): string {
|
||||||
|
return copiedField.value === field ? 'Copied!' : defaultLabel;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 p-6">
|
||||||
|
<header>
|
||||||
|
<Button variant="ghost" @click="router.push('/')">← Back to catalog</Button>
|
||||||
|
<h1 class="mt-4 text-3xl font-semibold">Order {{ orderId }}</h1>
|
||||||
|
<p class="text-muted-foreground mt-1 capitalize">Status: {{ status }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-destructive">{{ error }}</p>
|
||||||
|
|
||||||
|
<section v-if="receipt" class="rounded-xl border bg-card p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-medium">Receipt</h2>
|
||||||
|
<p class="text-muted-foreground mt-2">{{ receipt.summary }}</p>
|
||||||
|
<ul class="mt-4 space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="line in receipt.lineItems"
|
||||||
|
:key="line.id"
|
||||||
|
class="flex justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span>{{ line.quantity }}× {{ line.name }}</span>
|
||||||
|
<span>{{ line.price * line.quantity }} sats</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-4 flex justify-between border-t pt-4 font-semibold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{{ receipt.totalSatoshis }} sats</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="canPay" class="rounded-xl border bg-card p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-medium">Pay with XO</h2>
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
|
Scan the QR code in XO Wallet, or copy the import link below. The serialized
|
||||||
|
invitation is also available for manual import — it does not include sync-server
|
||||||
|
info on its own, so use the QR/link when importing into a wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="walletImportUrl" class="mt-6 flex flex-col items-center gap-4">
|
||||||
|
<InvitationQrCode :value="walletImportUrl" :size="240" />
|
||||||
|
<p class="text-muted-foreground text-center text-xs">Scan to import with sync server</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium">State sync server</label>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
class="flex-1 rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||||
|
readonly
|
||||||
|
:value="syncServerUrl"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!syncServerUrl"
|
||||||
|
@click="copyText('sync', syncServerUrl)"
|
||||||
|
>
|
||||||
|
{{ copyLabel('sync', 'Copy') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium">Invitation ID</label>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
class="flex-1 rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||||
|
readonly
|
||||||
|
:value="invitationIdentifier"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!invitationIdentifier"
|
||||||
|
@click="copyText('id', invitationIdentifier)"
|
||||||
|
>
|
||||||
|
{{ copyLabel('id', 'Copy') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium">Wallet import link</label>
|
||||||
|
<p class="text-muted-foreground mt-1 text-xs">
|
||||||
|
Includes sync server + invitation ID (<code>xo-invitation:</code> URL)
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
class="flex-1 rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||||
|
readonly
|
||||||
|
:value="walletImportUrl"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!walletImportUrl"
|
||||||
|
@click="copyText('import', walletImportUrl)"
|
||||||
|
>
|
||||||
|
{{ copyLabel('import', 'Copy') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium">Sync server fetch URL</label>
|
||||||
|
<p class="text-muted-foreground mt-1 text-xs">
|
||||||
|
Direct link to fetch this invitation from the state-sync server
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
class="flex-1 rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||||
|
readonly
|
||||||
|
:value="syncFetchUrl"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!syncFetchUrl"
|
||||||
|
@click="copyText('fetch', syncFetchUrl)"
|
||||||
|
>
|
||||||
|
{{ copyLabel('fetch', 'Copy') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t pt-6">
|
||||||
|
<label class="text-sm font-medium">Serialized invitation</label>
|
||||||
|
<p class="text-muted-foreground mt-1 text-xs">
|
||||||
|
Full invitation payload (no sync-server metadata)
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
class="mt-2 h-40 w-full rounded-md border bg-background p-3 font-mono text-xs"
|
||||||
|
readonly
|
||||||
|
:value="invitation"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
class="mt-4"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!invitation"
|
||||||
|
@click="copyText('invitation', invitation)"
|
||||||
|
>
|
||||||
|
{{ copyLabel('invitation', 'Copy invitation') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="status === 'completed'"
|
||||||
|
class="rounded-xl border border-green-500/40 bg-green-500/10 p-6 text-center"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-semibold text-green-700">Enjoy your snack!</h2>
|
||||||
|
<p class="text-muted-foreground mt-2">Mock dispense complete.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
|
||||||
|
import CheckoutPage from '@/pages/CheckoutPage.vue';
|
||||||
import HomePage from '@/pages/HomePage.vue';
|
import HomePage from '@/pages/HomePage.vue';
|
||||||
|
import OrderPage from '@/pages/OrderPage.vue';
|
||||||
|
|
||||||
/**
|
|
||||||
* Application routes.
|
|
||||||
*/
|
|
||||||
const routes = [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
name: 'home',
|
|
||||||
component: HomePage,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vue Router instance.
|
|
||||||
*/
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes: [
|
||||||
|
{ path: '/', component: HomePage },
|
||||||
|
{ path: '/checkout', component: CheckoutPage },
|
||||||
|
{ path: '/orders/:id', component: OrderPage },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@@ -1,213 +1,110 @@
|
|||||||
import { EventEmitter } from "../utils/event-emitter";
|
import { z } from 'zod';
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export interface ApiClientOptions {
|
export class ApiError extends Error {
|
||||||
baseUrl: string;
|
public readonly status: number;
|
||||||
tokens: AuthTokens;
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthTokens = {
|
export const ItemSchema = z.object({
|
||||||
accessToken: string;
|
id: z.string(),
|
||||||
accessTokenExpiresAt: number;
|
name: z.string(),
|
||||||
refreshToken: string;
|
description: z.string(),
|
||||||
refreshTokenExpiresAt: number;
|
price: z.number(),
|
||||||
}
|
quantity: z.number(),
|
||||||
|
image: z.string(),
|
||||||
|
created_at: z.number(),
|
||||||
|
updated_at: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
export type ApiClientEventMap = {
|
export type Item = z.infer<typeof ItemSchema>;
|
||||||
'tokens-refreshed': AuthTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiClient extends EventEmitter<ApiClientEventMap> {
|
export const OrderSchema = z.object({
|
||||||
static AUTH_TOKENS_SCHEMA = z.object({
|
id: z.string(),
|
||||||
accessToken: z.string(),
|
status: z.string(),
|
||||||
accessTokenExpiresAt: z.number(),
|
total_price: z.number(),
|
||||||
refreshToken: z.string(),
|
total_quantity: z.number(),
|
||||||
refreshTokenExpiresAt: z.number(),
|
items: z.array(z.object({ id: z.string(), quantity: z.number() })),
|
||||||
})
|
invitation_identifier: z.string().nullable(),
|
||||||
|
created_at: z.number(),
|
||||||
|
updated_at: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
export type Order = z.infer<typeof OrderSchema>;
|
||||||
* API responses for token refresh can omit `accessTokenExpiresAt` today, so
|
|
||||||
* this schema intentionally keeps the expiration optional and lets us derive it
|
|
||||||
* from the token payload when needed.
|
|
||||||
*/
|
|
||||||
private static AUTH_TOKEN_REFRESH_RESPONSE_SCHEMA = z.object({
|
|
||||||
accessToken: z.string(),
|
|
||||||
accessTokenExpiresAt: z.number().optional(),
|
|
||||||
refreshToken: z.string().optional(),
|
|
||||||
refreshTokenExpiresAt: z.number().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
private baseUrl: string;
|
export const OrderDetailSchema = OrderSchema.extend({
|
||||||
private tokens: AuthTokens;
|
syncServerUrl: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
constructor(options: ApiClientOptions) {
|
export type OrderDetail = z.infer<typeof OrderDetailSchema>;
|
||||||
super();
|
|
||||||
|
|
||||||
// validate the tokens
|
export const CreateOrderResponseSchema = z.object({
|
||||||
const { refreshToken, refreshTokenExpiresAt } = options.tokens;
|
order: OrderSchema,
|
||||||
const accessTokenExpiresAt = ApiClient.resolveAccessTokenExpiresAt(
|
invitation: z.string(),
|
||||||
options.tokens.accessToken,
|
syncServerUrl: z.string(),
|
||||||
options.tokens.accessTokenExpiresAt,
|
receipt: z.object({
|
||||||
);
|
summary: z.string(),
|
||||||
|
lineItems: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
quantity: z.number(),
|
||||||
|
price: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof accessTokenExpiresAt !== "number" || Number.isNaN(accessTokenExpiresAt)) {
|
export type CreateOrderResponse = z.infer<typeof CreateOrderResponseSchema>;
|
||||||
throw new Error("Failed to resolve access token expiration from token payload");
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have no refresh token or its expired, don't create the client
|
|
||||||
if (!refreshToken || !refreshTokenExpiresAt || refreshTokenExpiresAt < Date.now()) {
|
|
||||||
throw new Error('No refresh token found for user during creation');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the client
|
export class ApiClient {
|
||||||
this.baseUrl = options.baseUrl;
|
private readonly baseUrl: string;
|
||||||
this.tokens = {
|
|
||||||
...options.tokens,
|
constructor(baseUrl: string) {
|
||||||
accessTokenExpiresAt,
|
this.baseUrl = baseUrl;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getItems(): Promise<Item[]> {
|
||||||
* Send a JSON request to the API and return the parsed response.
|
const data = await this.request<unknown>('/items');
|
||||||
* @param path - The path to the API endpoint.
|
return z.array(ItemSchema).parse(data);
|
||||||
* @param options - The options for the request.
|
}
|
||||||
* @returns The parsed response.
|
|
||||||
*/
|
async getOrder(id: string): Promise<OrderDetail> {
|
||||||
public async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
const data = await this.request<unknown>(`/orders/${id}`);
|
||||||
const headers: Record<string, any> = {
|
return OrderDetailSchema.parse(data);
|
||||||
...(options.headers || {}),
|
}
|
||||||
}
|
|
||||||
|
async createOrder(items: Array<{ id: string; quantity: number }>): Promise<CreateOrderResponse> {
|
||||||
|
const data = await this.request<unknown>('/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
});
|
||||||
|
return CreateOrderResponseSchema.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(options.headers as Record<string, string> | undefined),
|
||||||
|
};
|
||||||
|
|
||||||
if (options.body !== undefined && options.body !== null && options.body !== '') {
|
if (options.body !== undefined && options.body !== null && options.body !== '') {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.requestRaw(path, {
|
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new ApiError(text || response.statusText, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Send a request to the API and return the raw response.
|
|
||||||
* @param path - The path to the API endpoint.
|
|
||||||
* @param options - The options for the request.
|
|
||||||
* @returns The raw response.
|
|
||||||
*/
|
|
||||||
public async requestRaw(path: string, options: RequestInit = {}): Promise<Response> {
|
|
||||||
// First, we are going to check if we need to refresh the tokens
|
|
||||||
if (this.tokens.accessTokenExpiresAt < Date.now()) {
|
|
||||||
console.log('Access Token Expired At:', new Date(this.tokens.accessTokenExpiresAt).toLocaleString('en-AU'));
|
|
||||||
await this.refreshTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.headers && options.headers instanceof Headers) {
|
|
||||||
options.headers = Object.fromEntries(options.headers.entries());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the request
|
|
||||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
...options.headers,
|
|
||||||
'Authorization': `Bearer ${this.tokens.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh the tokens for the API client.
|
|
||||||
* @returns The new tokens.
|
|
||||||
*/
|
|
||||||
public async refreshTokens(): Promise<AuthTokens> {
|
|
||||||
// We need to refresh the tokens
|
|
||||||
const response = await fetch(`${this.baseUrl}/users/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ refreshToken: this.tokens.refreshToken }),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// If the response is not ok, throw an error
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API request failed (${response.status} ${response.statusText}): ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the response
|
|
||||||
const data = ApiClient.AUTH_TOKEN_REFRESH_RESPONSE_SCHEMA.parse(await response.json());
|
|
||||||
|
|
||||||
const accessTokenExpiresAt = ApiClient.resolveAccessTokenExpiresAt(
|
|
||||||
data.accessToken,
|
|
||||||
data.accessTokenExpiresAt,
|
|
||||||
);
|
|
||||||
if (typeof accessTokenExpiresAt !== "number" || Number.isNaN(accessTokenExpiresAt)) {
|
|
||||||
throw new Error("Failed to resolve access token expiration from token refresh response");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the tokens
|
|
||||||
this.tokens = {
|
|
||||||
accessToken: data.accessToken,
|
|
||||||
accessTokenExpiresAt,
|
|
||||||
refreshToken: data.refreshToken ?? this.tokens.refreshToken,
|
|
||||||
refreshTokenExpiresAt: data.refreshTokenExpiresAt ?? this.tokens.refreshTokenExpiresAt,
|
|
||||||
};
|
|
||||||
this.emit('tokens-refreshed', this.tokens);
|
|
||||||
return this.tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the access token expiration timestamp.
|
|
||||||
*
|
|
||||||
* If `accessTokenExpiresAt` is missing, we parse the token payload from the
|
|
||||||
* first segment and read `expiresAt`.
|
|
||||||
*
|
|
||||||
* @param accessToken - Signed token in `payload.signature` format.
|
|
||||||
* @param accessTokenExpiresAt - Optional explicit expiresAt value from API payload.
|
|
||||||
* @returns The parsed expiration timestamp in milliseconds.
|
|
||||||
*/
|
|
||||||
private static resolveAccessTokenExpiresAt(
|
|
||||||
accessToken: string,
|
|
||||||
accessTokenExpiresAt?: number,
|
|
||||||
): number | undefined {
|
|
||||||
if (typeof accessTokenExpiresAt === "number") {
|
|
||||||
return accessTokenExpiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ApiClient.parseAccessTokenExpiresAt(accessToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the `expiresAt` field from the base64url payload of the token.
|
|
||||||
*
|
|
||||||
* The token format is `base64urlPayload.base64PayloadSignature`.
|
|
||||||
*
|
|
||||||
* @param accessToken - Signed access token.
|
|
||||||
* @returns The expiresAt value from payload, when parseable.
|
|
||||||
*/
|
|
||||||
private static parseAccessTokenExpiresAt(accessToken: string): number | undefined {
|
|
||||||
const [encodedPayload] = accessToken.split(".");
|
|
||||||
if (!encodedPayload) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPayload = encodedPayload
|
|
||||||
.replace(/-/g, '+')
|
|
||||||
.replace(/_/g, '/');
|
|
||||||
const paddedPayload = normalizedPayload + "=".repeat((4 - (normalizedPayload.length % 4)) % 4);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payloadJson = atob(paddedPayload);
|
|
||||||
const payload = JSON.parse(payloadJson) as { expiresAt?: number };
|
|
||||||
if (typeof payload.expiresAt !== "number") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return payload.expiresAt;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,34 +1,52 @@
|
|||||||
import { inject } from 'vue';
|
import { inject, ref, type Ref } from 'vue';
|
||||||
import { type Router } from 'vue-router';
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
export type AppDependencies = {
|
import { ApiClient } from './api-client.js';
|
||||||
router: Router;
|
import { Catalog } from './modals/catalog.js';
|
||||||
|
import { OrderInvitation } from './modals/invitation.js';
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AppDependencies = {
|
||||||
|
router: Router;
|
||||||
|
apiClient: ApiClient;
|
||||||
|
catalog: Catalog;
|
||||||
|
};
|
||||||
|
|
||||||
export class App {
|
export class App {
|
||||||
//---------------------------------------------------------------------------
|
|
||||||
// Initialization
|
|
||||||
//---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
public router: Router;
|
public router: Router;
|
||||||
public apiUrl: string;
|
public apiClient: ApiClient;
|
||||||
|
public catalog: Catalog;
|
||||||
|
public activeOrder: Ref<OrderInvitation | null> = ref(null);
|
||||||
|
|
||||||
constructor(
|
constructor(dependencies: AppDependencies) {
|
||||||
dependencies: AppDependencies
|
|
||||||
) {
|
|
||||||
this.router = dependencies.router;
|
this.router = dependencies.router;
|
||||||
this.apiUrl = dependencies.apiUrl;
|
this.apiClient = dependencies.apiClient;
|
||||||
|
this.catalog = dependencies.catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(router: Router, { apiUrl }: { apiUrl: string }) {
|
static async create(router: Router, config: AppConfig): Promise<App> {
|
||||||
// Create new instance of app.
|
const apiClient = new ApiClient(config.apiUrl);
|
||||||
return new this(
|
const catalog = await Catalog.create({ apiClient });
|
||||||
{
|
|
||||||
router,
|
return new App({ router, apiClient, catalog });
|
||||||
apiUrl,
|
}
|
||||||
}
|
|
||||||
);
|
async checkout(): Promise<OrderInvitation> {
|
||||||
|
const items = this.catalog.cart.value.map((line) => ({
|
||||||
|
id: line.itemId,
|
||||||
|
quantity: line.quantity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await this.apiClient.createOrder(items);
|
||||||
|
const orderInvitation = await OrderInvitation.create({ apiClient: this.apiClient }, response);
|
||||||
|
|
||||||
|
this.activeOrder.value = orderInvitation;
|
||||||
|
this.catalog.clearCart();
|
||||||
|
await this.router.push(`/orders/${response.order.id}`);
|
||||||
|
return orderInvitation;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +54,10 @@ export class App {
|
|||||||
// Composition API
|
// Composition API
|
||||||
// Boilerplate for Vue3, so we can call useApp() to get the app instance inside a component.
|
// Boilerplate for Vue3, so we can call useApp() to get the app instance inside a component.
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
export function useApp() {
|
export function useApp(): App {
|
||||||
const app = inject<App>('app');
|
const app = inject<App>('app');
|
||||||
if (!app) throw new Error('App not properly initialized');
|
if (!app) {
|
||||||
|
throw new Error('App not properly initialized');
|
||||||
|
}
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/services/invitation-sync-client.ts
Normal file
122
src/services/invitation-sync-client.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { SSESession, type SSEvent } from '../utils/sse-session.js';
|
||||||
|
|
||||||
|
export type InvitationSyncListeners = {
|
||||||
|
onInvitationUpdated?: (invitation: unknown) => void;
|
||||||
|
onConnected?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
onDisconnected?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class InvitationSyncSubscription {
|
||||||
|
public readonly invitationIdentifier: string;
|
||||||
|
private sse: SSESession;
|
||||||
|
private listeners: InvitationSyncListeners;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
invitationIdentifier: string,
|
||||||
|
baseUrl: string,
|
||||||
|
listeners: InvitationSyncListeners,
|
||||||
|
) {
|
||||||
|
this.invitationIdentifier = invitationIdentifier;
|
||||||
|
this.listeners = listeners;
|
||||||
|
|
||||||
|
const url = `${baseUrl.replace(/\/$/, '')}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`;
|
||||||
|
|
||||||
|
this.sse = new SSESession(url, {
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
onConnected: () => this.listeners.onConnected?.(),
|
||||||
|
onDisconnected: () => this.listeners.onDisconnected?.(),
|
||||||
|
onError: (error) =>
|
||||||
|
this.listeners.onError?.(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
),
|
||||||
|
onMessage: (event) => this.handleMessage(event),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
await this.sse.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.sse.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(event: SSEvent): void {
|
||||||
|
const payload = parseSsePayload(event);
|
||||||
|
if (payload !== undefined) {
|
||||||
|
this.listeners.onInvitationUpdated?.(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvitationSyncClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get url(): string {
|
||||||
|
return this.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(
|
||||||
|
invitationIdentifier: string,
|
||||||
|
listeners: InvitationSyncListeners,
|
||||||
|
): InvitationSyncSubscription {
|
||||||
|
return new InvitationSyncSubscription(invitationIdentifier, this.baseUrl, listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the serialized invitation snapshot for display (e.g. page reload).
|
||||||
|
*/
|
||||||
|
async fetchSerialized(invitationIdentifier: string): Promise<string | undefined> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl.replace(/\/$/, '')}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`,
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch invitation: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the payload from an SSE event.
|
||||||
|
* @param event - The SSE event to parse.
|
||||||
|
* @returns The parsed payload, or undefined if the event is invalid.
|
||||||
|
*/
|
||||||
|
function parseSsePayload(event: SSEvent): unknown | undefined {
|
||||||
|
if (!event.data) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (event.event === 'invitation-updated') {
|
||||||
|
// TODO: Use extended JSON decode to parse the invitation data
|
||||||
|
const parsed = JSON.parse(event.data) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object' && 'topic' in parsed && 'data' in parsed) {
|
||||||
|
return (parsed as { data: unknown }).data;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Use extended JSON decode to parse the invitation data
|
||||||
|
const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||||
|
if (parsed.topic === 'invitation-updated') {
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -1,181 +1,116 @@
|
|||||||
import { EventEmitter } from "../utils/event-emitter.js";
|
import { ref, shallowRef, triggerRef, type Ref, type ShallowRef } from 'vue';
|
||||||
import z from "zod";
|
|
||||||
import { ref, shallowRef, triggerRef, type Ref, type ShallowRef } from "vue";
|
|
||||||
|
|
||||||
export type MessageEventMap = {
|
import { EventEmitter } from '../../utils/event-emitter.js';
|
||||||
'deleted': void;
|
import { ApiClient, ItemSchema, type Item } from '../api-client.js';
|
||||||
}
|
|
||||||
|
|
||||||
export type MessageDependencies = {
|
export type CartLine = {
|
||||||
apiClient: ApiClient
|
itemId: string;
|
||||||
}
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type MessageCreateOptions = {
|
export type CatalogEventMap = {
|
||||||
hydratePartsFromStorage?: boolean
|
'items-loaded': Item[];
|
||||||
}
|
'cart-changed': CartLine[];
|
||||||
|
};
|
||||||
|
|
||||||
export type MessageSchema = z.infer<typeof Message.MESSAGE_SCHEMA>
|
export type CatalogDeps = {
|
||||||
|
apiClient: ApiClient;
|
||||||
|
};
|
||||||
|
|
||||||
export class Message extends EventEmitter<MessageEventMap> {
|
export class Catalog extends EventEmitter<CatalogEventMap> {
|
||||||
static MESSAGE_SCHEMA = z.object({
|
static ItemSchema = ItemSchema;
|
||||||
id: z.string(),
|
|
||||||
chatId: z.string(),
|
|
||||||
branchId: z.string().nullable().optional(),
|
|
||||||
branchOrdinal: z.number().nullable().optional(),
|
|
||||||
parentMessageId: z.string().nullable().optional(),
|
|
||||||
role: z.enum(["user", "assistant", "system", "tool"]),
|
|
||||||
status: z.string().nullable().optional(),
|
|
||||||
createdAt: z.number(),
|
|
||||||
updatedAt: z.number().optional(),
|
|
||||||
|
|
||||||
contentParts: z.array(MessagePart.MESSAGE_PART_SCHEMA).optional(),
|
static async create(deps: CatalogDeps): Promise<Catalog> {
|
||||||
})
|
const catalog = new Catalog(deps);
|
||||||
|
await catalog.load();
|
||||||
/**
|
return catalog;
|
||||||
* Async generator that yields messages one-by-one in reverse chronological
|
|
||||||
* order (newest first). Raw message data is bulk-loaded and sorted upfront
|
|
||||||
* (fast), then each Message instance is created and yielded individually so
|
|
||||||
* the consumer can render recent messages immediately.
|
|
||||||
*/
|
|
||||||
static async *fromStorage(deps: MessageDependencies): AsyncGenerator<Message> {
|
|
||||||
const { storage } = deps
|
|
||||||
const messageIds = await storage.keys()
|
|
||||||
if (messageIds.length === 0) return
|
|
||||||
|
|
||||||
// Bulk-load raw message data so we can sort before creating instances.
|
|
||||||
// IndexedDB reads are fast; the expensive work is creating Message
|
|
||||||
// instances and loading their parts.
|
|
||||||
const rawMessages = (await Promise.all(
|
|
||||||
messageIds.map(async (messageId) => storage.get<MessageSchema>(messageId))
|
|
||||||
)).filter((m): m is MessageSchema => m !== null)
|
|
||||||
|
|
||||||
// Sort newest-first so the most recent messages are yielded first
|
|
||||||
rawMessages.sort((a, b) => b.createdAt - a.createdAt)
|
|
||||||
|
|
||||||
for (const message of rawMessages) {
|
|
||||||
yield await Message.create(deps, message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(
|
public items: ShallowRef<Map<string, Item>> = shallowRef(new Map());
|
||||||
deps: MessageDependencies,
|
public cart: Ref<CartLine[]> = ref([]);
|
||||||
message: MessageSchema,
|
|
||||||
options: MessageCreateOptions = {},
|
|
||||||
): Promise<Message> {
|
|
||||||
const { storage } = deps
|
|
||||||
|
|
||||||
const messageStore = storage.child(message.id)
|
private deps: CatalogDeps;
|
||||||
|
|
||||||
const messageInstance = new Message({ ...deps, storage: messageStore }, message)
|
private constructor(deps: CatalogDeps) {
|
||||||
if (message.contentParts?.length) {
|
|
||||||
await messageInstance.mergeInitialParts(message.contentParts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.hydratePartsFromStorage === false) {
|
|
||||||
await messageInstance.saveToStorage()
|
|
||||||
return messageInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
await messageInstance.start();
|
|
||||||
return messageInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private deps: MessageDependencies;
|
|
||||||
|
|
||||||
public data: Ref<MessageSchema> = ref<MessageSchema>({
|
|
||||||
id: '',
|
|
||||||
chatId: '',
|
|
||||||
branchId: null,
|
|
||||||
branchOrdinal: null,
|
|
||||||
parentMessageId: null,
|
|
||||||
role: 'user',
|
|
||||||
status: 'pending',
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
public parts: ShallowRef<ExtMap<MessagePart>> = shallowRef<ExtMap<MessagePart>>(new ExtMap<MessagePart>())
|
|
||||||
|
|
||||||
constructor(deps: MessageDependencies, message: MessageSchema) {
|
|
||||||
super();
|
super();
|
||||||
this.deps = deps;
|
this.deps = deps;
|
||||||
|
|
||||||
const messageData: MessageSchema = { ...message }
|
|
||||||
delete messageData.contentParts
|
|
||||||
// Set the message's data
|
|
||||||
this.data.value = messageData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async load(): Promise<void> {
|
||||||
// Load all parts in parallel but merge each one as soon as its data
|
const items = await this.deps.apiClient.getItems();
|
||||||
// resolves. This is faster than sequential iteration (parts per message
|
const map = new Map<string, Item>();
|
||||||
// are few) while still triggering reactivity incrementally.
|
for (const item of items) {
|
||||||
const partsStorage = this.deps.storage.child('message-parts')
|
map.set(item.id, item);
|
||||||
const partIds = await partsStorage.keys()
|
|
||||||
|
|
||||||
await Promise.all(partIds.map(async (partId) => {
|
|
||||||
const partData = await partsStorage.get<MessagePartSchema>(partId)
|
|
||||||
if (!partData) return
|
|
||||||
await this.mergePart(partData)
|
|
||||||
}))
|
|
||||||
|
|
||||||
this.saveToStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async mergeInitialParts(parts: MessagePartSchema[]): Promise<void> {
|
|
||||||
for (const part of parts) {
|
|
||||||
await this.mergePart(part)
|
|
||||||
}
|
}
|
||||||
|
this.items.value = map;
|
||||||
|
triggerRef(this.items);
|
||||||
|
this.emit('items-loaded', items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveToStorage(): Promise<void> {
|
addToCart(itemId: string, quantity = 1): void {
|
||||||
await this.deps.storage.set(this.data.value);
|
const item = this.items.value.get(itemId);
|
||||||
}
|
if (!item || item.quantity < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
private async mergePart(part: MessagePartSchema): Promise<void> {
|
const existing = this.cart.value.find((line) => line.itemId === itemId);
|
||||||
const existing = this.parts.value.get(part.id);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.data.value = part;
|
existing.quantity = Math.min(existing.quantity + quantity, item.quantity);
|
||||||
await existing.saveToStorage();
|
|
||||||
} else {
|
} else {
|
||||||
const partInstance = await MessagePart.create({ ...this.deps, storage: this.deps.storage.child('message-parts') }, part);
|
this.cart.value.push({ itemId, quantity: Math.min(quantity, item.quantity) });
|
||||||
this.parts.value.set(partInstance.data.value.id, partInstance);
|
|
||||||
|
|
||||||
partInstance.on('deleted', () => this.removePart(partInstance.data.value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerRef(this.parts);
|
this.emit('cart-changed', [...this.cart.value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addPart(part: MessagePartSchema): Promise<void> {
|
removeFromCart(itemId: string): void {
|
||||||
await this.mergePart(part);
|
this.cart.value = this.cart.value.filter((line) => line.itemId !== itemId);
|
||||||
triggerRef(this.parts);
|
this.emit('cart-changed', [...this.cart.value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updatePart(part: MessagePartSchema): Promise<void> {
|
updateCartQuantity(itemId: string, quantity: number): void {
|
||||||
await this.mergePart(part);
|
const item = this.items.value.get(itemId);
|
||||||
triggerRef(this.parts);
|
if (!item) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
public removePart(part: MessagePartSchema): void {
|
|
||||||
this.parts.value.delete(part.id);
|
if (quantity <= 0) {
|
||||||
this.deps.storage
|
this.removeFromCart(itemId);
|
||||||
.child('message-parts')
|
return;
|
||||||
.child(part.id)
|
}
|
||||||
.clear()
|
|
||||||
.catch((error) => console.error('Failed to clear deleted message part from storage:', error));
|
const line = this.cart.value.find((entry) => entry.itemId === itemId);
|
||||||
triggerRef(this.parts);
|
if (line) {
|
||||||
|
line.quantity = Math.min(quantity, item.quantity);
|
||||||
|
this.emit('cart-changed', [...this.cart.value]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(): Promise<void> {
|
clearCart(): void {
|
||||||
await this.deps.apiClient.requestRaw(`/messages/${this.data.value.id}`, {
|
this.cart.value = [];
|
||||||
method: 'DELETE',
|
this.emit('cart-changed', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Do we want getters here? Maybe computed properties?
|
||||||
|
get cartTotal(): number {
|
||||||
|
return this.cart.value.reduce((total, line) => {
|
||||||
|
const item = this.items.value.get(line.itemId);
|
||||||
|
return total + (item ? item.price * line.quantity : 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Do we want getters here? Maybe computed properties?
|
||||||
|
get cartQuantity(): number {
|
||||||
|
return this.cart.value.reduce((total, line) => total + line.quantity, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCartLineItems(): Array<{ id: string; name: string; quantity: number; price: number }> {
|
||||||
|
return this.cart.value.flatMap((line) => {
|
||||||
|
const item = this.items.value.get(line.itemId);
|
||||||
|
if (!item) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ id: item.id, name: item.name, quantity: line.quantity, price: item.price }];
|
||||||
});
|
});
|
||||||
await this.deps.storage.delete();
|
|
||||||
this.parts.value.forEach(part => part.delete());
|
|
||||||
this.parts.value.clear();
|
|
||||||
triggerRef(this.parts);
|
|
||||||
|
|
||||||
this.emit('deleted', undefined);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +1,134 @@
|
|||||||
import { EventEmitter } from "../utils/event-emitter.js";
|
import { reactive, type UnwrapNestedRefs } from 'vue';
|
||||||
import z from "zod";
|
|
||||||
import { ref, shallowRef, triggerRef, type Ref, type ShallowRef } from "vue";
|
|
||||||
|
|
||||||
import type { BaseStore } from "./storage/base-store.js";
|
import { EventEmitter } from '../../utils/event-emitter.js';
|
||||||
import type { ApiClient } from "./api-client.js";
|
import {
|
||||||
|
ApiClient,
|
||||||
|
CreateOrderResponseSchema,
|
||||||
|
type CreateOrderResponse,
|
||||||
|
type Order,
|
||||||
|
} from '../api-client.js';
|
||||||
|
import {
|
||||||
|
InvitationSyncClient,
|
||||||
|
type InvitationSyncSubscription,
|
||||||
|
} from '../invitation-sync-client.js';
|
||||||
|
|
||||||
import { ExtMap } from "../utils/ext-map.js";
|
export type OrderInvitationEventMap = {
|
||||||
import { MessagePart, type MessagePartSchema } from './message-part.js';
|
'status-changed': Order;
|
||||||
|
'invitation-updated': unknown;
|
||||||
|
completed: Order;
|
||||||
|
error: Error;
|
||||||
|
};
|
||||||
|
|
||||||
export type MessageEventMap = {
|
export type OrderInvitationDeps = {
|
||||||
'deleted': void;
|
apiClient: ApiClient;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type MessageDependencies = {
|
export type OrderInvitationBootstrap = {
|
||||||
storage: BaseStore
|
order: Order;
|
||||||
apiClient: ApiClient
|
invitation: string;
|
||||||
}
|
syncServerUrl: string;
|
||||||
|
receipt: {
|
||||||
|
summary: string;
|
||||||
|
lineItems: Array<{ id: string; name: string; quantity: number; price: number }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type MessageCreateOptions = {
|
export type Receipt = {
|
||||||
hydratePartsFromStorage?: boolean
|
summary: string;
|
||||||
}
|
lineItems: Array<{ id: string; name: string; quantity: number; price: number }>;
|
||||||
|
totalSatoshis: number;
|
||||||
|
orderId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MessageSchema = z.infer<typeof Message.MESSAGE_SCHEMA>
|
export type OrderInvitationState = UnwrapNestedRefs<{
|
||||||
|
order: Order;
|
||||||
|
invitation: string;
|
||||||
|
syncServerUrl: string;
|
||||||
|
receipt: Receipt;
|
||||||
|
}>;
|
||||||
|
|
||||||
export class Message extends EventEmitter<MessageEventMap> {
|
export class OrderInvitation extends EventEmitter<OrderInvitationEventMap> {
|
||||||
static MESSAGE_SCHEMA = z.object({
|
static CreateOrderResponseSchema = CreateOrderResponseSchema;
|
||||||
id: z.string(),
|
|
||||||
chatId: z.string(),
|
|
||||||
branchId: z.string().nullable().optional(),
|
|
||||||
branchOrdinal: z.number().nullable().optional(),
|
|
||||||
parentMessageId: z.string().nullable().optional(),
|
|
||||||
role: z.enum(["user", "assistant", "system", "tool"]),
|
|
||||||
status: z.string().nullable().optional(),
|
|
||||||
createdAt: z.number(),
|
|
||||||
updatedAt: z.number().optional(),
|
|
||||||
|
|
||||||
contentParts: z.array(MessagePart.MESSAGE_PART_SCHEMA).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async generator that yields messages one-by-one in reverse chronological
|
|
||||||
* order (newest first). Raw message data is bulk-loaded and sorted upfront
|
|
||||||
* (fast), then each Message instance is created and yielded individually so
|
|
||||||
* the consumer can render recent messages immediately.
|
|
||||||
*/
|
|
||||||
static async *fromStorage(deps: MessageDependencies): AsyncGenerator<Message> {
|
|
||||||
const { storage } = deps
|
|
||||||
const messageIds = await storage.keys()
|
|
||||||
if (messageIds.length === 0) return
|
|
||||||
|
|
||||||
// Bulk-load raw message data so we can sort before creating instances.
|
|
||||||
// IndexedDB reads are fast; the expensive work is creating Message
|
|
||||||
// instances and loading their parts.
|
|
||||||
const rawMessages = (await Promise.all(
|
|
||||||
messageIds.map(async (messageId) => storage.get<MessageSchema>(messageId))
|
|
||||||
)).filter((m): m is MessageSchema => m !== null)
|
|
||||||
|
|
||||||
// Sort newest-first so the most recent messages are yielded first
|
|
||||||
rawMessages.sort((a, b) => b.createdAt - a.createdAt)
|
|
||||||
|
|
||||||
for (const message of rawMessages) {
|
|
||||||
yield await Message.create(deps, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(
|
static async create(
|
||||||
deps: MessageDependencies,
|
deps: OrderInvitationDeps,
|
||||||
message: MessageSchema,
|
response: CreateOrderResponse | OrderInvitationBootstrap,
|
||||||
options: MessageCreateOptions = {},
|
): Promise<OrderInvitation> {
|
||||||
): Promise<Message> {
|
const instance = new OrderInvitation(deps, response);
|
||||||
const { storage } = deps
|
await instance.start();
|
||||||
|
return instance;
|
||||||
const messageStore = storage.child(message.id)
|
|
||||||
|
|
||||||
const messageInstance = new Message({ ...deps, storage: messageStore }, message)
|
|
||||||
if (message.contentParts?.length) {
|
|
||||||
await messageInstance.mergeInitialParts(message.contentParts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.hydratePartsFromStorage === false) {
|
|
||||||
await messageInstance.saveToStorage()
|
|
||||||
return messageInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
await messageInstance.start();
|
|
||||||
return messageInstance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private deps: MessageDependencies;
|
public state: OrderInvitationState;
|
||||||
|
|
||||||
public data: Ref<MessageSchema> = ref<MessageSchema>({
|
private deps: OrderInvitationDeps;
|
||||||
id: '',
|
private syncClient: InvitationSyncClient;
|
||||||
chatId: '',
|
private subscription: InvitationSyncSubscription | null = null;
|
||||||
branchId: null,
|
|
||||||
branchOrdinal: null,
|
|
||||||
parentMessageId: null,
|
|
||||||
role: 'user',
|
|
||||||
status: 'pending',
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
public parts: ShallowRef<ExtMap<MessagePart>> = shallowRef<ExtMap<MessagePart>>(new ExtMap<MessagePart>())
|
private constructor(
|
||||||
|
deps: OrderInvitationDeps,
|
||||||
constructor(deps: MessageDependencies, message: MessageSchema) {
|
response: CreateOrderResponse | OrderInvitationBootstrap,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.deps = deps;
|
this.deps = deps;
|
||||||
|
this.syncClient = new InvitationSyncClient(response.syncServerUrl);
|
||||||
const messageData: MessageSchema = { ...message }
|
this.state = reactive({
|
||||||
delete messageData.contentParts
|
order: response.order,
|
||||||
// Set the message's data
|
invitation: response.invitation,
|
||||||
this.data.value = messageData;
|
syncServerUrl: response.syncServerUrl,
|
||||||
|
receipt: {
|
||||||
|
summary: response.receipt.summary,
|
||||||
|
lineItems: response.receipt.lineItems,
|
||||||
|
totalSatoshis: response.order.total_price,
|
||||||
|
orderId: response.order.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Load all parts in parallel but merge each one as soon as its data
|
const invitationId = this.state.order.invitation_identifier;
|
||||||
// resolves. This is faster than sequential iteration (parts per message
|
if (!invitationId) {
|
||||||
// are few) while still triggering reactivity incrementally.
|
return;
|
||||||
const partsStorage = this.deps.storage.child('message-parts')
|
|
||||||
const partIds = await partsStorage.keys()
|
|
||||||
|
|
||||||
await Promise.all(partIds.map(async (partId) => {
|
|
||||||
const partData = await partsStorage.get<MessagePartSchema>(partId)
|
|
||||||
if (!partData) return
|
|
||||||
await this.mergePart(partData)
|
|
||||||
}))
|
|
||||||
|
|
||||||
this.saveToStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async mergeInitialParts(parts: MessagePartSchema[]): Promise<void> {
|
|
||||||
for (const part of parts) {
|
|
||||||
await this.mergePart(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async saveToStorage(): Promise<void> {
|
|
||||||
await this.deps.storage.set(this.data.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async mergePart(part: MessagePartSchema): Promise<void> {
|
|
||||||
const existing = this.parts.value.get(part.id);
|
|
||||||
if (existing) {
|
|
||||||
existing.data.value = part;
|
|
||||||
await existing.saveToStorage();
|
|
||||||
} else {
|
|
||||||
const partInstance = await MessagePart.create({ ...this.deps, storage: this.deps.storage.child('message-parts') }, part);
|
|
||||||
this.parts.value.set(partInstance.data.value.id, partInstance);
|
|
||||||
|
|
||||||
partInstance.on('deleted', () => this.removePart(partInstance.data.value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerRef(this.parts);
|
this.subscription = this.syncClient.subscribe(invitationId, {
|
||||||
}
|
onInvitationUpdated: (invitation) => {
|
||||||
|
this.emit('invitation-updated', invitation);
|
||||||
public async addPart(part: MessagePartSchema): Promise<void> {
|
// Backend order status (paid/completed) is updated when the sync server
|
||||||
await this.mergePart(part);
|
// broadcasts invitation changes — refresh once per SSE event, not on a timer.
|
||||||
triggerRef(this.parts);
|
void this.refreshOrderFromBackend();
|
||||||
}
|
},
|
||||||
|
onError: (error) => this.emit('error', error),
|
||||||
public async updatePart(part: MessagePartSchema): Promise<void> {
|
|
||||||
await this.mergePart(part);
|
|
||||||
triggerRef(this.parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public removePart(part: MessagePartSchema): void {
|
|
||||||
this.parts.value.delete(part.id);
|
|
||||||
this.deps.storage
|
|
||||||
.child('message-parts')
|
|
||||||
.child(part.id)
|
|
||||||
.clear()
|
|
||||||
.catch((error) => console.error('Failed to clear deleted message part from storage:', error));
|
|
||||||
triggerRef(this.parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async delete(): Promise<void> {
|
|
||||||
await this.deps.apiClient.requestRaw(`/messages/${this.data.value.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
});
|
||||||
await this.deps.storage.delete();
|
|
||||||
this.parts.value.forEach(part => part.delete());
|
|
||||||
this.parts.value.clear();
|
|
||||||
triggerRef(this.parts);
|
|
||||||
|
|
||||||
this.emit('deleted', undefined);
|
try {
|
||||||
|
await this.subscription.connect();
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.subscription?.close();
|
||||||
|
this.subscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshOrderFromBackend(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await this.deps.apiClient.getOrder(this.state.order.id);
|
||||||
|
const previousStatus = this.state.order.status;
|
||||||
|
this.state.order = order;
|
||||||
|
|
||||||
|
if (previousStatus !== order.status) {
|
||||||
|
this.emit('status-changed', order);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'completed') {
|
||||||
|
this.emit('completed', order);
|
||||||
|
await this.stop();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,31 @@
|
|||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-serif);
|
--font-heading: var(--font-serif);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(0.985 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
30
src/utils/invitation-import-url.ts
Normal file
30
src/utils/invitation-import-url.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/** Custom URL protocol for XO Wallet invitation import (includes sync server). */
|
||||||
|
export const XO_INVITATION_PROTOCOL = 'xo-invitation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a wallet-import URL that bundles the invitation ID and sync server.
|
||||||
|
*
|
||||||
|
* Wallets can parse this to connect to the correct state-sync server before
|
||||||
|
* fetching or accepting the invitation. The serialized invitation alone does
|
||||||
|
* not yet carry sync-server metadata.
|
||||||
|
*/
|
||||||
|
export function buildXoInvitationImportUrl(
|
||||||
|
syncServerUrl: string,
|
||||||
|
invitationIdentifier: string,
|
||||||
|
): string {
|
||||||
|
const url = new URL(`${XO_INVITATION_PROTOCOL}:`);
|
||||||
|
url.searchParams.set('id', invitationIdentifier);
|
||||||
|
url.searchParams.set('sync', syncServerUrl.replace(/\/$/, ''));
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct HTTP URL on the sync server for fetching this invitation snapshot.
|
||||||
|
*/
|
||||||
|
export function buildInvitationSyncFetchUrl(
|
||||||
|
syncServerUrl: string,
|
||||||
|
invitationIdentifier: string,
|
||||||
|
): string {
|
||||||
|
const base = syncServerUrl.replace(/\/$/, '');
|
||||||
|
return `${base}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`;
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
|
|||||||
@@ -10,4 +10,14 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// TODO: Figure out what the fuck this is doing?
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user