diff --git a/package-lock.json b/package-lock.json index 29cb2dd643..b3074ec3a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,15 @@ "@stylistic/eslint-plugin": "^5.7.1", "@types/jquery": "^3.5.33", "@types/moment": "^2.11.29", + "@types/node": "^25.2.2", "@vitejs/plugin-vue": "^6.0.4", "@vue/eslint-config-typescript": "^14.6.0", + "@vue/language-plugin-pug": "^3.2.4", "eslint": "^9.39.2", "eslint-plugin-vue": "^10.7.0", "eslint-plugin-vue-pug": "^1.0.0-alpha.5", "pug": "^3.0.3", + "sass-embedded": "^1.97.3", "stylus": "^0.64.0", "typescript-eslint": "^8.54.0", "vite": "^7.3.1" @@ -80,6 +83,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -828,6 +838,316 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1249,6 +1569,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.2.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", + "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/sizzle": { "version": "2.3.10", "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", @@ -1504,6 +1835,13 @@ "vue": "^3.2.25" } }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vue/compiler-core": { "version": "3.5.27", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", @@ -1580,6 +1918,20 @@ } } }, + "node_modules/@vue/language-plugin-pug": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vue/language-plugin-pug/-/language-plugin-pug-3.2.4.tgz", + "integrity": "sha512-DpvubnPdc8e8NHKpuipf9hq7v/bfrwb7QY0ltPRfWtIR7uNXIMoQ97kndrRNFjnZvJJM6bFjB+0HEHYJ07p/2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.27", + "muggle-string": "^0.4.1", + "pug-lexer": "^5.0.1", + "pug-parser": "^6.0.0", + "vscode-languageserver-textdocument": "^1.0.11" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.27", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", @@ -1839,6 +2191,23 @@ "is-regex": "^1.0.3" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1859,6 +2228,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1936,6 +2312,17 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/doctypes": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", @@ -2649,6 +3036,13 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3003,6 +3397,13 @@ "dev": true, "license": "MIT" }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3028,6 +3429,14 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -3413,6 +3822,21 @@ ], "license": "MIT" }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3524,6 +3948,402 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.3.tgz", + "integrity": "sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.97.3", + "sass-embedded-android-arm": "1.97.3", + "sass-embedded-android-arm64": "1.97.3", + "sass-embedded-android-riscv64": "1.97.3", + "sass-embedded-android-x64": "1.97.3", + "sass-embedded-darwin-arm64": "1.97.3", + "sass-embedded-darwin-x64": "1.97.3", + "sass-embedded-linux-arm": "1.97.3", + "sass-embedded-linux-arm64": "1.97.3", + "sass-embedded-linux-musl-arm": "1.97.3", + "sass-embedded-linux-musl-arm64": "1.97.3", + "sass-embedded-linux-musl-riscv64": "1.97.3", + "sass-embedded-linux-musl-x64": "1.97.3", + "sass-embedded-linux-riscv64": "1.97.3", + "sass-embedded-linux-x64": "1.97.3", + "sass-embedded-unknown-all": "1.97.3", + "sass-embedded-win32-arm64": "1.97.3", + "sass-embedded-win32-x64": "1.97.3" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.3.tgz", + "integrity": "sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.97.3" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.3.tgz", + "integrity": "sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.3.tgz", + "integrity": "sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.3.tgz", + "integrity": "sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.3.tgz", + "integrity": "sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.97.3.tgz", + "integrity": "sha512-fuqMTqO4gbOmA/kC5b9y9xxNYw6zDEyfOtMgabS7Mz93wimSk2M1quQaTJnL98Mkcsl2j+7shNHxIS/qpcIDDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.3.tgz", + "integrity": "sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.3.tgz", + "integrity": "sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.3.tgz", + "integrity": "sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.3.tgz", + "integrity": "sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.3.tgz", + "integrity": "sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.3.tgz", + "integrity": "sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.3.tgz", + "integrity": "sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.3.tgz", + "integrity": "sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.3.tgz", + "integrity": "sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.3.tgz", + "integrity": "sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.97.3" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.3.tgz", + "integrity": "sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.3.tgz", + "integrity": "sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/sax": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", @@ -3769,6 +4589,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", + "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3819,6 +4662,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3871,6 +4721,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3888,6 +4745,13 @@ "dev": true, "license": "MIT" }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3974,6 +4838,13 @@ "node": ">=0.10.0" } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, "node_modules/vue": { "version": "3.5.27", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", diff --git a/package.json b/package.json index c4db318a45..256f7d5161 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "scripts": { "dev": "vite", "build": "vite build", + "dev:widget": "vite src/pretix/static/pretixpresale/widget", + "build:widget": "vite build --config src/pretix/static/pretixpresale/widget/vite.config.ts", "lint:eslint": "eslint . --ext .js,.ts,.vue", "test": "echo \"Error: no test specified\" && exit 1" }, @@ -31,12 +33,15 @@ "@stylistic/eslint-plugin": "^5.7.1", "@types/jquery": "^3.5.33", "@types/moment": "^2.11.29", + "@types/node": "^25.2.2", "@vitejs/plugin-vue": "^6.0.4", "@vue/eslint-config-typescript": "^14.6.0", + "@vue/language-plugin-pug": "^3.2.4", "eslint": "^9.39.2", "eslint-plugin-vue": "^10.7.0", "eslint-plugin-vue-pug": "^1.0.0-alpha.5", "pug": "^3.0.3", + "sass-embedded": "^1.97.3", "stylus": "^0.64.0", "typescript-eslint": "^8.54.0", "vite": "^7.3.1" diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index e3d6086f84..0ee7174cbe 100644 --- a/src/pretix/base/middleware.py +++ b/src/pretix/base/middleware.py @@ -283,7 +283,7 @@ class SecurityMiddleware(MiddlewareMixin): 'script-src': ["{static}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []), 'object-src': ["'none'"], 'frame-src': ['{static}'], - 'style-src': ["{static}", "{media}"], + 'style-src': ["{static}", "{media}"]+ (["unsafe-inline"] if settings.VITE_DEV_MODE else []), 'connect-src': ["{dynamic}", "{media}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []), 'img-src': ["{static}", "{media}", "data:"] + img_src, 'font-src': ["{static}"] + list(font_src), diff --git a/src/pretix/static/pretixpresale/widget/TODOS.md b/src/pretix/static/pretixpresale/widget/TODOS.md new file mode 100644 index 0000000000..ae1281503a --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/TODOS.md @@ -0,0 +1,2 @@ +- modernize the sometimes native form submitting? +- destructure props? diff --git a/src/pretix/static/pretixpresale/widget/index.html b/src/pretix/static/pretixpresale/widget/index.html new file mode 100644 index 0000000000..7bcdde1b2a --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/index.html @@ -0,0 +1,15 @@ + + + + + + Pretix Widget + + + + + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/api.ts b/src/pretix/static/pretixpresale/widget/src/api.ts new file mode 100644 index 0000000000..be419ed6dd --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/api.ts @@ -0,0 +1,85 @@ +import type { Category, DayEntry, EventEntry, MetaFilterField } from '~/types' + +export class ApiError extends Error { + status: number + responseUrl: string + + constructor (status: number, responseUrl: string) { + super(`HTTP ${status}`) + this.status = status + this.responseUrl = responseUrl + } +} + +// --- Product list --- + +export interface ProductListResponse { + target_url?: string + subevent?: string | number + name?: string + frontpage_text?: string + date_range?: string + location?: string + items_by_category?: Category[] + currency?: string + display_net_prices?: boolean + voucher_explanation_text?: string + error?: string + display_add_to_cart?: boolean + waiting_list_enabled?: boolean + show_variations_expanded?: boolean + cart_exists?: boolean + vouchers_exist?: boolean + has_seating_plan?: boolean + has_seating_plan_waitinglist?: boolean + itemnum?: number + poweredby?: string + events?: EventEntry[] + has_more_events?: boolean + meta_filter_fields?: MetaFilterField[] + weeks?: DayEntry[][] + date?: string + days?: DayEntry[] + week?: [number, number] +} + +export async function fetchProductList (url: string) { + const response = await fetch(url) + if (!response.ok) { + throw new ApiError(response.status, response.url) + } + return { + data: await response.json() as ProductListResponse, + responseUrl: response.url, + } +} + +export interface CartResponse { + redirect?: string + cart_id?: string + success?: boolean + message?: string + has_cart?: boolean + async_id?: string + check_url?: string +} + +export async function submitCart (endpoint: string, formData: FormData) { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(formData as any).toString(), + }) + if (!response.ok) { + throw new ApiError(response.status, response.url) + } + return await response.json() as CartResponse +} + +export async function checkAsyncTask (url: string) { + const response = await fetch(url) + if (!response.ok) { + throw new ApiError(response.status, response.url) + } + return await response.json() as CartResponse +} diff --git a/src/pretix/static/pretixpresale/widget/src/button.ts b/src/pretix/static/pretixpresale/widget/src/button.ts new file mode 100644 index 0000000000..a04d687710 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/button.ts @@ -0,0 +1,73 @@ +import { createApp, type App } from 'vue' +import ButtonComponent from '~/components/Button.vue' +import { createWidgetStore, StoreKey } from '~/sharedStore' +import { makeid } from '~/utils' +import type { WidgetData } from '~/types' + +export function createButtonInstance (element: Element, htmlId?: string): App { + let targetUrl = element.attributes.event.value + if (!targetUrl.match(/\/$/)) { + targetUrl += '/' + } + + const widgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data)) + + for (const attr of Array.from(element.attributes)) { + if (attr.name.match(/^data-.*$/)) { + widgetData[attr.name.replace(/^data-/, '')] = attr.value + } + } + + const rawItems = element.attributes.items?.value || '' + + // Parse items string (format: "item_1=2,item_3=1") + const buttonItems: { item: string; count: string }[] = [] + for (const itemStr of rawItems.split(',')) { + if (itemStr.includes('=')) { + const [item, count] = itemStr.split('=') + buttonItems.push({ item, count }) + } + } + + const store = createWidgetStore({ + targetUrl, + voucher: element.attributes.voucher?.value || null, + subevent: element.attributes.subevent?.value || null, + skipSsl: 'skip-ssl-check' in element.attributes, + disableIframe: 'disable-iframe' in element.attributes, + widgetData, + htmlId: htmlId || element.id || makeid(16), + isButton: true, + buttonItems, + buttonText: element.innerHTML + }) + + const observer = new MutationObserver((mutationList) => { + for (const mutation of mutationList) { + if (mutation.type === 'attributes' && mutation.attributeName?.startsWith('data-')) { + const attrName = mutation.attributeName.substring(5) + const attrValue = (mutation.target as Element).getAttribute(mutation.attributeName) + if (attrValue !== null) { + store.widgetData[attrName] = attrValue + } + } + } + }) + + // TODO I don't think we need this anymore in vue3 + // if (element.tagName !== 'pretix-button') { + // element.innerHTML = '' + element.innerHTML + '' + // // Vue does not replace the container, so watch container as well + // observer.observe(element, observerOptions) + // } + + const app = createApp(ButtonComponent) + app.provide(StoreKey, store) + app.config.errorHandler = (error, _vm, info) => { + console.error('[pretix-button]', info, error) + } + app.mount(element) + observer.observe(element, { attributes: true }) + + return app +} diff --git a/src/pretix/static/pretixpresale/widget/src/components/AvailBox.vue b/src/pretix/static/pretixpresale/widget/src/components/AvailBox.vue new file mode 100644 index 0000000000..2914367bf4 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/AvailBox.vue @@ -0,0 +1,155 @@ + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/Button.vue b/src/pretix/static/pretixpresale/widget/src/components/Button.vue new file mode 100644 index 0000000000..c5774a4640 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/Button.vue @@ -0,0 +1,81 @@ + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/Category.vue b/src/pretix/static/pretixpresale/widget/src/components/Category.vue new file mode 100644 index 0000000000..deb99c1cbd --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/Category.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventCalendar.vue b/src/pretix/static/pretixpresale/widget/src/components/EventCalendar.vue new file mode 100644 index 0000000000..adfc05223b --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventCalendar.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventCalendarCell.vue b/src/pretix/static/pretixpresale/widget/src/components/EventCalendarCell.vue new file mode 100644 index 0000000000..cba2c3ac43 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventCalendarCell.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventCalendarEvent.vue b/src/pretix/static/pretixpresale/widget/src/components/EventCalendarEvent.vue new file mode 100644 index 0000000000..6b978bebb5 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventCalendarEvent.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventCalendarRow.vue b/src/pretix/static/pretixpresale/widget/src/components/EventCalendarRow.vue new file mode 100644 index 0000000000..850f0d8b16 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventCalendarRow.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventForm.vue b/src/pretix/static/pretixpresale/widget/src/components/EventForm.vue new file mode 100644 index 0000000000..5779a5e683 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventForm.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventList.vue b/src/pretix/static/pretixpresale/widget/src/components/EventList.vue new file mode 100644 index 0000000000..991c056327 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventList.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventListEntry.vue b/src/pretix/static/pretixpresale/widget/src/components/EventListEntry.vue new file mode 100644 index 0000000000..d99279c3ec --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventListEntry.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventListFilterField.vue b/src/pretix/static/pretixpresale/widget/src/components/EventListFilterField.vue new file mode 100644 index 0000000000..b366eb8dfb --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventListFilterField.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventListFilterForm.vue b/src/pretix/static/pretixpresale/widget/src/components/EventListFilterForm.vue new file mode 100644 index 0000000000..5bc4987735 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventListFilterForm.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventWeekCalendar.vue b/src/pretix/static/pretixpresale/widget/src/components/EventWeekCalendar.vue new file mode 100644 index 0000000000..28e5ca11d4 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventWeekCalendar.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/EventWeekCell.vue b/src/pretix/static/pretixpresale/widget/src/components/EventWeekCell.vue new file mode 100644 index 0000000000..4da1e86151 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/EventWeekCell.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/Item.vue b/src/pretix/static/pretixpresale/widget/src/components/Item.vue new file mode 100644 index 0000000000..b353cdaee3 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/Item.vue @@ -0,0 +1,218 @@ + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/Overlay.vue b/src/pretix/static/pretixpresale/widget/src/components/Overlay.vue new file mode 100644 index 0000000000..f619c939fb --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/Overlay.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue b/src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue new file mode 100644 index 0000000000..a89750f85a --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue @@ -0,0 +1,114 @@ + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/Variation.vue b/src/pretix/static/pretixpresale/widget/src/components/Variation.vue new file mode 100644 index 0000000000..b75336ba0e --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/Variation.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/pretix/static/pretixpresale/widget/src/components/Widget.vue b/src/pretix/static/pretixpresale/widget/src/components/Widget.vue new file mode 100644 index 0000000000..9751d41d6e --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/components/Widget.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/pretix/static/pretixpresale/widget/src/globals.d.ts b/src/pretix/static/pretixpresale/widget/src/globals.d.ts new file mode 100644 index 0000000000..717269b4c1 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/globals.d.ts @@ -0,0 +1,3 @@ +interface NamedNodeMap { + [key: string]: Attr | undefined; +} diff --git a/src/pretix/static/pretixpresale/widget/src/i18n.ts b/src/pretix/static/pretixpresale/widget/src/i18n.ts new file mode 100644 index 0000000000..ad2a40d01b --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/i18n.ts @@ -0,0 +1,117 @@ +// Internationalization strings for the pretix widget +// Django's i18n file expects `this` to be the global object, but ES modules +// have `this` as undefined. Import as raw text and execute with a local context. + +// TODO hack +import djangoI18nScript from '../../../jsi18n/en/djangojs.js?raw' + +interface Django { + pgettext: (context: string, text: string) => string + gettext: (text: string) => string + interpolate: (fmt: string, obj: Record | unknown[], named?: boolean) => string + get_format: (formatType: string) => string | number +} + +// Create a local context object to capture django without polluting window +const context: { django?: Django } = {} +new Function(djangoI18nScript).call(context) +const django = context.django! + +export const STRINGS = { + quantity: django.pgettext('widget', 'Quantity'), + quantity_dec: django.pgettext('widget', 'Decrease quantity'), + quantity_inc: django.pgettext('widget', 'Increase quantity'), + filter_events_by: django.pgettext('widget', 'Filter events by'), + filter: django.pgettext('widget', 'Filter'), + price: django.pgettext('widget', 'Price'), + original_price: django.pgettext('widget', 'Original price: %s'), + new_price: django.pgettext('widget', 'New price: %s'), + select: django.pgettext('widget', 'Select'), + select_item: django.pgettext('widget', 'Select %s'), + select_variant: django.pgettext('widget', 'Select variant %s'), + sold_out: django.pgettext('widget', 'Sold out'), + buy: django.pgettext('widget', 'Buy'), + register: django.pgettext('widget', 'Register'), + reserved: django.pgettext('widget', 'Reserved'), + free: django.pgettext('widget', 'FREE'), + price_from: django.pgettext('widget', 'from %(currency)s %(price)s'), + image_of: django.pgettext('widget', 'Image of %s'), + tax_incl: django.pgettext('widget', 'incl. %(rate)s% %(taxname)s'), + tax_plus: django.pgettext('widget', 'plus %(rate)s% %(taxname)s'), + tax_incl_mixed: django.pgettext('widget', 'incl. taxes'), + tax_plus_mixed: django.pgettext('widget', 'plus taxes'), + quota_left: django.pgettext('widget', 'currently available: %s'), + unavailable_require_voucher: django.pgettext('widget', 'Only available with a voucher'), + unavailable_available_from: django.pgettext('widget', 'Not yet available'), + unavailable_available_until: django.pgettext('widget', 'Not available anymore'), + unavailable_active: django.pgettext('widget', 'Currently not available'), + unavailable_hidden_if_item_available: django.pgettext('widget', 'Not yet available'), + order_min: django.pgettext('widget', 'minimum amount to order: %s'), + exit: django.pgettext('widget', 'Close ticket shop'), + loading_error: django.pgettext('widget', 'The ticket shop could not be loaded.'), + loading_error_429: django.pgettext('widget', 'There are currently a lot of users in this ticket shop. Please open the shop in a new tab to continue.'), + open_new_tab: django.pgettext('widget', 'Open ticket shop'), + checkout: django.pgettext('widget', 'Checkout'), + cart_error: django.pgettext('widget', 'The cart could not be created. Please try again later'), + cart_error_429: django.pgettext('widget', 'We could not create your cart, since there are currently too many users in this ticket shop. Please click "Continue" to retry in a new tab.'), + waiting_list: django.pgettext('widget', 'Waiting list'), + cart_exists: django.pgettext('widget', 'You currently have an active cart for this event. If you select more products, they will be added to your existing cart.'), + resume_checkout: django.pgettext('widget', 'Resume checkout'), + redeem_voucher: django.pgettext('widget', 'Redeem a voucher'), + redeem: django.pgettext('widget', 'Redeem'), + voucher_code: django.pgettext('widget', 'Voucher code'), + close: django.pgettext('widget', 'Close'), + close_checkout: django.pgettext('widget', 'Close checkout'), + cancel_blocked: django.pgettext('widget', 'You cannot cancel this operation. Please wait for loading to finish.'), + continue: django.pgettext('widget', 'Continue'), + variations: django.pgettext('widget', 'Show variants'), + hide_variations: django.pgettext('widget', 'Hide variants'), + back_to_list: django.pgettext('widget', 'Choose a different event'), + back_to_dates: django.pgettext('widget', 'Choose a different date'), + back: django.pgettext('widget', 'Back'), + next_month: django.pgettext('widget', 'Next month'), + previous_month: django.pgettext('widget', 'Previous month'), + next_week: django.pgettext('widget', 'Next week'), + previous_week: django.pgettext('widget', 'Previous week'), + show_seating: django.pgettext('widget', 'Open seat selection'), + seating_plan_waiting_list: django.pgettext('widget', 'Some or all ticket categories are currently sold out. If you want, you can add yourself to the waiting list. We will then notify if seats are available again.'), + load_more: django.pgettext('widget', 'Load more'), + days: { + MO: django.gettext('Mo'), + TU: django.gettext('Tu'), + WE: django.gettext('We'), + TH: django.gettext('Th'), + FR: django.gettext('Fr'), + SA: django.gettext('Sa'), + SU: django.gettext('Su'), + MONDAY: django.gettext('Monday'), + TUESDAY: django.gettext('Tuesday'), + WEDNESDAY: django.gettext('Wednesday'), + THURSDAY: django.gettext('Thursday'), + FRIDAY: django.gettext('Friday'), + SATURDAY: django.gettext('Saturday'), + SUNDAY: django.gettext('Sunday'), + }, + months: { + '01': django.gettext('January'), + '02': django.gettext('February'), + '03': django.gettext('March'), + '04': django.gettext('April'), + '05': django.gettext('May'), + '06': django.gettext('June'), + '07': django.gettext('July'), + '08': django.gettext('August'), + '09': django.gettext('September'), + 10: django.gettext('October'), + 11: django.gettext('November'), + 12: django.gettext('December'), + } as Record, +} as const + +export function interpolate (fmt: string, obj: Record | unknown[], named = false): string { + return django.interpolate(fmt, obj, named) +} + +export function getFormat (formatType: string): string | number { + return django.get_format(formatType) +} diff --git a/src/pretix/static/pretixpresale/widget/src/lib/store.ts b/src/pretix/static/pretixpresale/widget/src/lib/store.ts new file mode 100644 index 0000000000..b22b8c2387 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/lib/store.ts @@ -0,0 +1,80 @@ +import { reactive, computed, watch } from 'vue' +import type { WatchCallback, WatchOptions, UnwrapNestedRefs } from 'vue' + +interface StoreMethods { + $reset: () => void + $watch: (source: () => T, callback: WatchCallback, options?: WatchOptions) => void +} + +type GetterReturnTypes = { + readonly [K in keyof G]: G[K] extends () => infer R ? R : never +} + +type Store = UnwrapNestedRefs & GetterReturnTypes & A & StoreMethods +type GettersTree = Record any> | Record any> +type ActionsTree = Record any> + +export function createStore< + S extends object, + G extends GettersTree, + A extends ActionsTree +> ( + // name: string, + config: { + state: () => S + getters?: G & ThisType & GetterReturnTypes & A & StoreMethods> + actions?: A & ThisType & GetterReturnTypes & A & StoreMethods> + } +): Store { + type StoreType = Store + const store = reactive(config.state()) as StoreType + + // Add getters as computed properties + if (config.getters) { + for (const key of Object.keys(config.getters) as (keyof G)[]) { + const getter = config.getters[key] + const computedRef = computed(() => (getter as () => unknown).call(store)) + Object.defineProperty(store, key, { + get: () => computedRef.value, + enumerable: true + }) + } + } + + // Add actions bound to the store + if (config.actions) { + for (const key of Object.keys(config.actions) as (keyof A)[]) { + const action = config.actions[key] + ;(store as Record)[key as string] = (action as (...args: unknown[]) => unknown).bind(store) + } + } + + store.$reset = function () { + const cleanState = config.state() + const cleanKeys = new Set([ + '$reset', + '$watch', + ...Object.keys(cleanState), + ...Object.keys(config.getters ?? {}), + ...Object.keys(config.actions ?? {}) + ]) + + // Delete any keys that aren't in clean state and aren't known non-state keys + for (const key of Object.keys(store)) { + if (!cleanKeys.has(key)) { + delete (store as Record)[key] + } + } + + // Set all state values from clean state + for (const key of Object.keys(cleanState) as (keyof S)[]) { + ;(store as S)[key] = cleanState[key] + } + } + + store.$watch = function (source: () => T, callback: WatchCallback, options?: WatchOptions) { + watch(source.bind(store), callback.bind(store), options) + } + + return store +} diff --git a/src/pretix/static/pretixpresale/widget/src/main.ts b/src/pretix/static/pretixpresale/widget/src/main.ts new file mode 100644 index 0000000000..3626d9f20b --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/main.ts @@ -0,0 +1,130 @@ +import { createApp, nextTick, type App } from 'vue' +import { createWidgetInstance } from '~/widget' +import { createButtonInstance } from '~/button' +import { createWidgetStore, StoreKey } from '~/sharedStore' +import ButtonComponent from '~/components/Button.vue' +import { docReady, makeid } from '~/utils' +import type { WidgetData } from '~/types' + +declare global { + interface Window { + PretixWidget: PretixWidgetAPI + pretixWidgetCallback?: () => void + } +} + +interface PretixWidgetAPI { + build_widgets: boolean + widget_data: WidgetData + buildWidgets: () => void + open: ( + targetUrl: string, + voucher?: string | null, + subevent?: string | number | null, + items?: { item: string; count: string }[], + widgetData?: Record, + skipSslCheck?: boolean, + disableIframe?: boolean + ) => void + addLoadListener: (callback: () => void) => void + addCloseListener: (callback: () => void) => void + _loaded: Array<() => void> + _closed: Array<() => void> +} + +const widgetlist: App[] = [] +const buttonlist: App[] = [] + +window.PretixWidget = { + build_widgets: true, + widget_data: { referer: location.href }, + // TODO move somewhere else and rename? + _loaded: [], + _closed: [], + buildWidgets, + open: openWidget, + addLoadListener (f) { this._loaded.push(f) }, + addCloseListener (f) { this._closed.push(f) }, +} + +async function buildWidgets () { + // TODO what does this do? + document.createElement('pretix-widget') + document.createElement('pretix-button') + + await docReady() + const widgetElements = document.querySelectorAll('pretix-widget, div.pretix-widget-compat') + for (const [i, el] of Array.from(widgetElements).entries()) { + widgetlist.push(createWidgetInstance(el, el.id || `pretix-widget-${i}`)) + } + const buttonElements = document.querySelectorAll('pretix-button, div.pretix-button-compat') + for (const [i, el] of Array.from(buttonElements).entries()) { + buttonlist.push(createButtonInstance(el, el.id || `pretix-button-${i}`)) + } +} + +function openWidget ( + targetUrl: string, + voucher?: string | null, + subevent?: string | number | null, + items?: { item: string; count: string }[], + widgetData?: Record, + skipSslCheck?: boolean, + disableIframe?: boolean +): void { + if (!targetUrl.match(/\/$/)) { + targetUrl += '/' + } + + const allWidgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data)) + if (widgetData) { + Object.assign(allWidgetData, widgetData) + } + + const root = document.createElement('div') + document.body.appendChild(root) + root.classList.add('pretix-widget-hidden') + + const store = createWidgetStore({ + targetUrl, + voucher: voucher ?? null, + subevent: subevent ?? null, + skipSsl: skipSslCheck ?? false, + disableIframe: disableIframe ?? false, + widgetData: allWidgetData, + htmlId: makeid(16), + isButton: true, + buttonItems: items ?? [], + buttonText: '', + }) + + const app = createApp(ButtonComponent) + app.provide(StoreKey, store) + app.config.errorHandler = (error, _vm, info) => { + console.error('[pretix-widget-open]', info, error) + } + app.mount(root) + + nextTick(() => { + if (store.useIframe) { + const form = root.querySelector('form') as HTMLFormElement + if (form) { + const formData = new FormData(form) + store.buy(formData) + } + } else { + const form = root.querySelector('form') as HTMLFormElement + if (form) form.submit() + } + }) +} + +if (typeof window.pretixWidgetCallback !== 'undefined') { + window.pretixWidgetCallback() +} + +if (window.PretixWidget.build_widgets) { + window.PretixWidget.buildWidgets() +} + +// TODO debug exposes diff --git a/src/pretix/static/pretixpresale/widget/src/sharedStore.ts b/src/pretix/static/pretixpresale/widget/src/sharedStore.ts new file mode 100644 index 0000000000..6d38ad32d8 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/sharedStore.ts @@ -0,0 +1,519 @@ +import { nextTick, type InjectionKey } from 'vue' +import { createStore } from '~/lib/store' +import { fetchProductList, submitCart, checkAsyncTask, ApiError } from '~/api' +import type { CartResponse } from '~/api' +import { STRINGS } from '~/i18n' +import { setCookie, getCookie, makeid } from '~/utils' +import type { Category, DayEntry, EventEntry, LightboxState, MetaFilterField, WidgetData } from '~/types' + +export const globalWidgetId = makeid(16) + +export type WidgetStore = ReturnType +export const StoreKey: InjectionKey = Symbol('WidgetStore') + +export function createWidgetStore (config: { + targetUrl: string + isButton?: boolean + voucher?: string | null + subevent?: string | number | null + listType?: string | null + skipSsl?: boolean + disableIframe?: boolean + disableVouchers?: boolean + disableFilters?: boolean + displayEventInfo?: boolean | null + filter?: string | null + items?: string | null + categories?: string | null + variations?: string | null + widgetData: WidgetData + htmlId: string + // Button-specific + buttonItems?: { item: string; count: string }[] + buttonText?: string +}) { + return createStore({ + state: () => ({ + // Target/URL state + targetUrl: config.targetUrl, + parentStack: [] as string[], + subevent: config.subevent ?? null as string | number | null, + + // Configuration + voucherCode: config.voucher ?? null as string | null, + skipSsl: config.skipSsl ?? false, + disableIframe: config.disableIframe ?? false, + disableVouchers: config.disableVouchers ?? false, + disableFilters: config.disableFilters ?? false, + displayEventInfo: config.displayEventInfo ?? null as boolean | null, + filter: config.filter ?? null as string | null, + itemFilter: config.items ?? null as string | null, + categoryFilter: config.categories ?? null as string | null, + variationFilter: config.variations ?? null as string | null, + style: config.listType ?? null as string | null, + widgetData: config.widgetData, + widgetId: `pretix-widget-${globalWidgetId}`, + htmlId: config.htmlId, + + // View state + view: null as 'event' | 'events' | 'weeks' | 'days' | null, + loading: config.isButton ? 0 : 1, + error: null as string | null, + connectionError: false, + frameDismissed: false, + + // Event data + name: null as string | null, + dateRange: null as string | null, + location: null as string | null, + frontpageText: null as string | null, + categories: [] as Category[], + currency: '', + displayNetPrices: false, + voucherExplanationText: null as string | null, + displayAddToCart: false, + waitingListEnabled: false, + showVariationsExpanded: !!config.variations, + cartId: null as string | null, + cartExists: false, + vouchersExist: false, + hasSeatingPlan: false, + hasSeatingPlanWaitinglist: false, + itemnum: 0, + poweredby: '', + + // Calendar/list data + events: null as EventEntry[] | null, + weeks: null as DayEntry[][] | null, + days: null as DayEntry[] | null, + date: null as string | null, + week: null as [number, number] | null, + hasMoreEvents: false, + offset: 0, + appendEvents: false, + metaFilterFields: [] as MetaFilterField[], + + // UI state + mobile: false, + + // Button-specific + isButton: config.isButton ?? false, + items: (config.buttonItems ?? []) as { item: string; count: string }[], + buttonText: config.buttonText ?? '', + + // Overlay (always initialized, no null guards) + overlay: { + frameSrc: '', + frameLoading: false, + frameShown: false, + errorMessage: null as string | null, + errorUrlAfter: null as string | null, + errorUrlAfterNewTab: false, + lightbox: null as LightboxState | null, + }, + + // Async task state + asyncTaskId: null as string | null, + asyncTaskCheckUrl: null as string | null, + asyncTaskTimeout: null as ReturnType | null, + asyncTaskInterval: 100, + voucher: null as string | null, + }), + + getters: { + useIframe (): boolean { + if ((window as any).crossOriginIsolated === true) return false + return !this.disableIframe && (this.skipSsl || /https.*/.test(document.location.protocol)) + }, + + cookieName (): string { + return `pretix_widget_${this.targetUrl.replace(/[^a-zA-Z0-9]+/g, '_')}` + }, + + cartIdFromCookie (): string | null { + return getCookie(this.cookieName) ?? null + }, + + consentParameter (): string { + if (this.widgetData.consent) { + return `&consent=${encodeURIComponent(this.widgetData.consent)}` + } + return '' + }, + + additionalURLParams (): string { + if (!window.location.search.includes('utm_')) { + return '' + } + const params = new URLSearchParams(window.location.search) + for (const [key] of params.entries()) { + if (!key.startsWith('utm_')) { + params.delete(key) + } + } + return params.toString() + }, + + newTabTarget (): string { + return this.subevent ? `${this.targetUrl}${this.subevent}/` : this.targetUrl + }, + }, + + actions: { + triggerLoadCallback () { + nextTick(() => { + for (const callback of (window as any).PretixWidget._loaded || []) { + callback() + } + }) + }, + + async reload (opt: { focus?: string } = {}) { + if (this.isButton) return + + let url: string + if (this.subevent) { + url = `${this.targetUrl}${this.subevent}/widget/product_list?lang=${LANG}` + } else { + url = `${this.targetUrl}widget/product_list?lang=${LANG}` + } + + if (this.offset) url += `&offset=${this.offset}` + if (this.filter) url += `&${this.filter}` + if (this.itemFilter) url += `&items=${encodeURIComponent(this.itemFilter)}` + if (this.categoryFilter) url += `&categories=${encodeURIComponent(this.categoryFilter)}` + if (this.variationFilter) url += `&variations=${encodeURIComponent(this.variationFilter)}` + if (this.voucherCode) url += `&voucher=${encodeURIComponent(this.voucherCode)}` + + const cartIdCookie = this.cartIdFromCookie + if (cartIdCookie) url += `&cart_id=${encodeURIComponent(cartIdCookie)}` + if (this.date !== null) { + url += `&date=${this.date.substring(0, 7)}` + } else if (this.week !== null) { + url += `&date=${this.week[0]}-W${this.week[1]}` + } + if (this.style !== null) url += `&style=${encodeURIComponent(this.style)}` + + try { + const { data, responseUrl } = await fetchProductList(url) + + // Check for redirect + const newUrl = responseUrl.substring(0, responseUrl.indexOf('/widget/product_list?') + 1) + const oldUrl = url.substring(0, url.indexOf('/widget/product_list?') + 1) + if (newUrl !== oldUrl) { + let adjustedUrl = newUrl + if (this.subevent) { + adjustedUrl = adjustedUrl.substring(0, adjustedUrl.lastIndexOf('/', adjustedUrl.length - 1) + 1) + } + this.targetUrl = adjustedUrl + this.reload() + return + } + + this.connectionError = false + + if (data.weeks !== undefined) { + this.weeks = data.weeks + this.date = data.date ?? null + this.week = null + this.events = null + this.view = 'weeks' + this.name = data.name ?? null + this.frontpageText = data.frontpage_text ?? null + this.metaFilterFields = data.meta_filter_fields ?? [] + } else if (data.days !== undefined) { + this.days = data.days + this.date = null + this.week = data.week ?? null + this.events = null + this.view = 'days' + this.name = data.name ?? null + this.frontpageText = data.frontpage_text ?? null + this.metaFilterFields = data.meta_filter_fields ?? [] + } else if (data.events !== undefined) { + this.events = this.appendEvents && this.events + ? this.events.concat(data.events) + : data.events + this.appendEvents = false + this.weeks = null + this.view = 'events' + this.name = data.name ?? null + this.frontpageText = data.frontpage_text ?? null + this.hasMoreEvents = data.has_more_events ?? false + this.metaFilterFields = data.meta_filter_fields ?? [] + } else { + this.view = 'event' + this.targetUrl = data.target_url ?? this.targetUrl + this.subevent = data.subevent ?? null + this.name = data.name ?? null + this.frontpageText = data.frontpage_text ?? null + this.dateRange = data.date_range ?? null + this.location = data.location ?? null + this.categories = data.items_by_category ?? [] + this.currency = data.currency ?? '' + this.displayNetPrices = data.display_net_prices ?? false + this.voucherExplanationText = data.voucher_explanation_text ?? null + this.error = data.error ?? null + this.displayAddToCart = data.display_add_to_cart ?? false + this.waitingListEnabled = data.waiting_list_enabled ?? false + this.showVariationsExpanded = data.show_variations_expanded || !!this.variationFilter + this.cartId = cartIdCookie + this.cartExists = data.cart_exists ?? false + this.vouchersExist = data.vouchers_exist ?? false + this.hasSeatingPlan = data.has_seating_plan ?? false + this.hasSeatingPlanWaitinglist = data.has_seating_plan_waitinglist ?? false + this.itemnum = data.itemnum ?? 0 + } + + this.poweredby = data.poweredby ?? '' + + if (this.loading > 0) { + this.loading-- + this.triggerLoadCallback() + } + + // Auto-open seating plan if applicable + if ( + this.parentStack.length > 0 + && this.hasSeatingPlan + && this.categories.length === 0 + && !this.frameDismissed + && this.useIframe + && !this.error + && !this.hasSeatingPlanWaitinglist + ) { + this.startseating() + } else if (opt.focus) { + nextTick(() => { + document.querySelector(opt.focus!)?.focus() + }) + } + } catch (e) { + this.categories = [] + this.currency = '' + if (e instanceof ApiError && e.status === 429) { + this.error = STRINGS.loading_error_429 + } else { + this.error = STRINGS.loading_error + } + this.connectionError = true + if (this.loading > 0) { + this.loading-- + this.triggerLoadCallback() + } + } + }, + getFormAction (): string { + if (!this.useIframe && this.isButton && this.items.length === 0) { + if (this.voucherCode) return `${this.targetUrl}redeem` + if (this.subevent) return `${this.targetUrl}${this.subevent}/` + return this.targetUrl + } + + let checkoutUrl = `/${this.targetUrl.replace(/^[^/]+:\/\/([^/]+)\//, '')}w/${globalWidgetId}/` + if (!this.cartExists) { + checkoutUrl += 'checkout/start' + } + if (this.additionalURLParams) { + checkoutUrl += `?${this.additionalURLParams}` + } + + let formTarget = `${this.targetUrl}w/${globalWidgetId}/cart/add?iframe=1&next=${encodeURIComponent(checkoutUrl)}` + if (this.cartIdFromCookie) { + formTarget += `&take_cart_id=${this.cartIdFromCookie}` + } + formTarget += this.consentParameter + return formTarget + }, + getVoucherFormTarget (): string { + let formTarget = `${this.targetUrl}w/${globalWidgetId}/redeem?iframe=1&locale=${LANG}` + if (this.cartIdFromCookie) { + formTarget += `&take_cart_id=${this.cartIdFromCookie}` + } + if (this.subevent) { + formTarget += `&subevent=${this.subevent}` + } + if (this.widgetData) { + formTarget += `&widget_data=${encodeURIComponent(JSON.stringify(this.widgetData))}` + } + formTarget += this.consentParameter + if (this.additionalURLParams) { + formTarget += `&${this.additionalURLParams}` + } + return formTarget + }, + handleCartResponse (data: CartResponse) { + if (data.redirect) { + if (data.cart_id) { + this.cartId = data.cart_id + setCookie(this.cookieName, data.cart_id, 30) + } + + let redirectUrl = data.redirect + if (redirectUrl.substring(0, 1) === '/') { + redirectUrl = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, '$1')}${redirectUrl}` + } + + let url = redirectUrl + if (url.includes('?')) { + url = `${url}&iframe=1&locale=${LANG}&take_cart_id=${this.cartId}` + } else { + url = `${url}?iframe=1&locale=${LANG}&take_cart_id=${this.cartId}` + } + url += this.consentParameter + if (this.additionalURLParams) { + url += `&${this.additionalURLParams}` + } + + if (data.success === false) { + url = url.replace(/checkout\/start/g, '') + this.overlay.errorMessage = data.message ?? null + if (data.has_cart) { + this.overlay.errorUrlAfter = url + } + this.overlay.frameLoading = false + } else { + this.overlay.frameSrc = url + } + } else if (data.async_id && data.check_url) { + this.asyncTaskId = data.async_id + this.asyncTaskCheckUrl = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, '$1')}${data.check_url}` + this.asyncTaskTimeout = window.setTimeout(() => this.pollAsyncTask(), this.asyncTaskInterval) + this.asyncTaskInterval = 250 + } + }, + async pollAsyncTask () { + if (!this.asyncTaskCheckUrl) return + try { + const data = await checkAsyncTask(this.asyncTaskCheckUrl) + this.handleCartResponse(data) + } catch (e) { + if (e instanceof ApiError && (e.status === 200 || (e.status >= 400 && e.status < 500))) { + this.overlay.errorMessage = STRINGS.cart_error + this.overlay.frameLoading = false + } else { + this.asyncTaskTimeout = window.setTimeout(() => this.pollAsyncTask(), 1000) + } + } + }, + async buy (formData: FormData, event?: Event) { + if (this.useIframe) { + if (event) event.preventDefault() + } else { + return + } + + if (this.isButton && this.items.length === 0) { + if (this.voucherCode) { + this.voucherOpen(this.voucherCode) + } else { + this.resume() + } + return + } + + const url = `${this.getFormAction()}&locale=${LANG}&ajax=1` + this.overlay.frameLoading = true + this.asyncTaskInterval = 100 + + try { + const data = await submitCart(url, formData) + this.handleCartResponse(data) + } catch (e) { + if (e instanceof ApiError) { + if (e.status === 429) { + this.overlay.errorMessage = STRINGS.cart_error_429 + this.overlay.frameLoading = false + this.overlay.errorUrlAfter = this.newTabTarget + this.overlay.errorUrlAfterNewTab = true + } else if (e.status === 405) { + this.targetUrl = e.responseUrl.substring(0, e.responseUrl.indexOf('/cart/add') - 18) + this.overlay.frameLoading = false + } else { + this.overlay.errorMessage = STRINGS.cart_error + this.overlay.frameLoading = false + } + } else { + this.overlay.errorMessage = STRINGS.cart_error + this.overlay.frameLoading = false + } + } + }, + redeem (voucherCode: string, event?: Event) { + if (this.useIframe) { + if (event) event.preventDefault() + this.voucherOpen(voucherCode) + } + }, + voucherOpen (voucherCode: string) { + const redirectUrl = `${this.getVoucherFormTarget()}&voucher=${encodeURIComponent(voucherCode)}` + if (this.useIframe) { + this.overlay.frameSrc = redirectUrl + } else { + window.open(redirectUrl) + } + }, + resume () { + let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/` + if (this.subevent && !this.cartId) { + redirectUrl += `${this.subevent}/` + } + redirectUrl += `?iframe=1&locale=${LANG}` + if (this.cartId) { + redirectUrl += `&take_cart_id=${this.cartId}` + } + if (this.widgetData) { + redirectUrl += `&widget_data=${encodeURIComponent(JSON.stringify(this.widgetData))}` + } + redirectUrl += this.consentParameter + if (this.additionalURLParams) { + redirectUrl += `&${this.additionalURLParams}` + } + if (this.useIframe) { + this.overlay.frameSrc = redirectUrl + } else { + window.open(redirectUrl) + } + }, + startwaiting () { + let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/waitinglist/?iframe=1&locale=${LANG}` + if (this.subevent) { + redirectUrl += `&subevent=${this.subevent}` + } + if (this.additionalURLParams) { + redirectUrl += `&${this.additionalURLParams}` + } + if (this.useIframe) { + this.overlay.frameSrc = redirectUrl + } else { + window.open(redirectUrl) + } + }, + startseating () { + let redirectUrl = `${this.targetUrl}w/${globalWidgetId}` + if (this.subevent) { + redirectUrl += `/${this.subevent}` + } + redirectUrl += `/seatingframe/?iframe=1&locale=${LANG}` + if (this.voucherCode) { + redirectUrl += `&voucher=${encodeURIComponent(this.voucherCode)}` + } + if (this.cartId) { + redirectUrl += `&take_cart_id=${this.cartId}` + } + if (this.widgetData) { + redirectUrl += `&widget_data=${encodeURIComponent(JSON.stringify(this.widgetData))}` + } + redirectUrl += this.consentParameter + if (this.additionalURLParams) { + redirectUrl += `&${this.additionalURLParams}` + } + if (this.useIframe) { + this.overlay.frameSrc = redirectUrl + } else { + window.open(redirectUrl) + } + } + } + }) +} diff --git a/src/pretix/static/pretixpresale/widget/src/types.ts b/src/pretix/static/pretixpresale/widget/src/types.ts new file mode 100644 index 0000000000..f8790ccef0 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/types.ts @@ -0,0 +1,92 @@ +// Domain model types for the pretix widget + +export interface Price { + gross: string + net: string + rate: string + name: string + includes_mixed_tax_rate?: boolean +} + +export interface Availability { + color: 'green' | 'orange' | 'red' + text?: string + reason?: string +} + +export interface Variation { + id: number + value: string + description?: string + price: Price + suggested_price?: Price + original_price?: string + avail: [number, number | null] + order_max: number + current_unavailability_reason?: string + allow_waitinglist?: boolean +} + +export interface Item { + id: number + name: string + description?: string + picture?: string + picture_fullsize?: string + price: Price + suggested_price?: Price + original_price?: string + avail: [number, number | null] + order_min?: number + order_max: number + has_variations: boolean + variations: Variation[] + free_price: boolean + min_price?: string + max_price?: string + mandatory_priced_addons?: boolean + current_unavailability_reason?: string + allow_waitinglist?: boolean +} + +export interface Category { + id: number + name?: string + description?: string + items: Item[] +} + +export interface EventEntry { + name: string + event_url: string + subevent?: number | string + date_range: string + location: string + time?: string + continued?: boolean + availability: Availability +} + +export interface DayEntry { + date: string + day_formatted: string + events: EventEntry[] +} + +export interface MetaFilterField { + key: string + label: string + choices: [string, string][] +} + +export interface LightboxState { + image: string + description: string + loading?: boolean +} + +export interface WidgetData { + referer: string + consent?: string + [key: string]: string | undefined +} diff --git a/src/pretix/static/pretixpresale/widget/src/utils.ts b/src/pretix/static/pretixpresale/widget/src/utils.ts new file mode 100644 index 0000000000..e4968ab2b0 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/utils.ts @@ -0,0 +1,100 @@ +// Utility functions for the pretix widget + +import { getFormat } from '~/i18n' + +// Cookie utilities +export function setCookie (cname: string, cvalue: string, exdays: number): void { + const d = new Date() + d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000) + const expires = `expires=${d.toUTCString()}` + document.cookie = `${cname}=${cvalue};${expires};path=/` +} + +export function getCookie (name: string): string | null { + const value = `; ${document.cookie}` + const parts = value.split(`; ${name}=`) + if (parts.length === 2) { + return parts.pop()?.split(';').shift() || null + } + return null +} + +// Number formatting +export function roundTo (n: number, digits = 0): number { + const multiplicator = Math.pow(10, digits) + n = parseFloat((n * multiplicator).toFixed(11)) + return Math.round(n) / multiplicator +} + +// TODO use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat instead? +export function floatformat (val: number | string, places = 2): string { + if (typeof val === 'string') { + val = parseFloat(val) + } + const parts = roundTo(val, places).toFixed(places).split('.') + if (places === 0) { + return parts[0] + } + const grouping = getFormat('NUMBER_GROUPING') as number + const thousandSep = getFormat('THOUSAND_SEPARATOR') as string + const decimalSep = getFormat('DECIMAL_SEPARATOR') as string + parts[0] = parts[0].replace( + new RegExp(`\\B(?=(\\d{${grouping}})+(?!\\d))`, 'g'), + thousandSep + ) + return `${parts[0]}${decimalSep}${parts[1]}` +} + +export function autofloatformat (val: number | string, places = 2): string { + const numVal = typeof val === 'string' ? parseFloat(val) : val + if (numVal === roundTo(numVal, 0)) { + places = 0 + } + return floatformat(numVal, places) +} + +// String/number utilities +export function padNumber (number: number, size = 2): string { + let s = String(number) + while (s.length < size) { + s = `0${s}` + } + return s +} + +export function getISOWeeks (year: number): number { + const d = new Date(year, 0, 1) + const isLeap = new Date(year, 1, 29).getMonth() === 1 + // Check for a Jan 1 that's a Thursday or a leap year that has a Wednesday Jan 1 + return d.getDay() === 4 || (isLeap && d.getDay() === 3) ? 53 : 52 +} + +export function makeid (length: number): string { + let text = '' + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + for (let i = 0; i < length; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)) + } + return text +} + +export function siteIsSecure (): boolean { + return /https.*/.test(document.location.protocol) +} + +// HTML utility +export function stripHTML (s: string): string { + const div = document.createElement('div') + div.innerHTML = s + return div.textContent || div.innerText || '' +} + +// docReady - DOM ready detection (returns a Promise) +export function docReady (): Promise { + if (document.readyState === 'complete' || document.readyState === 'interactive') { + return Promise.resolve() + } + return new Promise(resolve => { + document.addEventListener('DOMContentLoaded', () => resolve(), { once: true }) + }) +} diff --git a/src/pretix/static/pretixpresale/widget/src/vite-env.d.ts b/src/pretix/static/pretixpresale/widget/src/vite-env.d.ts new file mode 100644 index 0000000000..17158237e0 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/vite-env.d.ts @@ -0,0 +1 @@ +declare const LANG: string diff --git a/src/pretix/static/pretixpresale/widget/src/widget.ts b/src/pretix/static/pretixpresale/widget/src/widget.ts new file mode 100644 index 0000000000..c4094ab96d --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/src/widget.ts @@ -0,0 +1,69 @@ +import { createApp, type App } from 'vue' +import WidgetComponent from '~/components/Widget.vue' +import { createWidgetStore, StoreKey } from '~/sharedStore' +import { makeid } from '~/utils' +import type { WidgetData } from '~/types' + +export function createWidgetInstance (element: Element, htmlId?: string): App { + let targetUrl = element.attributes.event.value + if (!targetUrl.match(/\/$/)) { + targetUrl += '/' + } + + const displayEventInfoAttr = element.attributes['display-event-info']?.value + // null means "auto" (as before), everything other than "false" is true + const displayEventInfo: boolean | null + = 'display-event-info' in element.attributes && displayEventInfoAttr !== 'auto' ? displayEventInfoAttr !== 'false' : null + + const widgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data)) + for (const attr of Array.from(element.attributes)) { + if (attr.name.match(/^data-.*$/)) { + widgetData[attr.name.replace(/^data-/, '')] = attr.value + } + } + + const store = createWidgetStore({ + targetUrl, + voucher: element.attributes.voucher?.value || null, + subevent: element.attributes.subevent?.value || null, + listType: element.attributes['list-type']?.value || element.attributes.style?.value || null, + skipSsl: 'skip-ssl-check' in element.attributes, + disableIframe: 'disable-iframe' in element.attributes, + disableVouchers: 'disable-vouchers' in element.attributes, + disableFilters: 'disable-filters' in element.attributes, + displayEventInfo, + filter: element.attributes.filter?.value || null, + items: element.attributes.items?.value || null, + categories: element.attributes.categories?.value || null, + variations: element.attributes.variations?.value || null, + widgetData, + htmlId: htmlId || element.id || makeid(16), + }) + + const observer = new MutationObserver((mutationList) => { + for (const mutation of mutationList) { + if (mutation.type === 'attributes' && mutation.attributeName?.startsWith('data-')) { + const attrName = mutation.attributeName.substring(5) + const attrValue = (mutation.target as Element).getAttribute(mutation.attributeName) + store.widgetData[attrName] = attrValue + } + } + }) + + // TODO I don't think we need this anymore in vue3 + // if (element.tagName !== 'pretix-widget') { + // element.innerHTML = '' + // // we need to watch the container as well as the replaced root-node (see mounted()) + // observer.observe(element, observerOptions) + // } + + const app = createApp(WidgetComponent) + app.provide(StoreKey, store) + app.config.errorHandler = (error, _vm, info) => { + console.error('[pretix-widget]', info, error) + } + app.mount(element) + observer.observe(element, { attributes: true }) + + return app +} diff --git a/src/pretix/static/pretixpresale/widget/tsconfig.json b/src/pretix/static/pretixpresale/widget/tsconfig.json new file mode 100644 index 0000000000..3c1b8075ae --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../../tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "types": ["node", "vite/client"] + } +} diff --git a/src/pretix/static/pretixpresale/widget/vite.config.ts b/src/pretix/static/pretixpresale/widget/vite.config.ts new file mode 100644 index 0000000000..62e05e75f7 --- /dev/null +++ b/src/pretix/static/pretixpresale/widget/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [ + vue() + ], + resolve: { + alias: { + '~': import.meta.dirname + '/src', + }, + }, + build: { + manifest: true, + outDir: import.meta.dirname + '/../../../../../static.dist/vite/widget', + rollupOptions: { + input: { + main: import.meta.dirname + '/src/main.ts', + }, + output: { + format: 'iife', + entryFileNames: 'widget.js', + assetFileNames: 'widget.[ext]', + }, + }, + }, + optimizeDeps: { + exclude: ['moment', 'jquery'] + }, + define: { + LANG: JSON.stringify(process.env.PRETIX_WIDGET_LANG || 'en') + } +}) diff --git a/tsconfig.json b/tsconfig.json index fee69853a8..dc84a598b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,13 @@ { - "include": ["src/pretix/static/**/*", "src/pretix/static/**/*.vue"], + "include": [ + "src/pretix/static/**/*", + "src/pretix/static/**/*.vue", + "src/pretix/plugins/webcheckin/**/*", + "src/pretix/plugins/webcheckin/**/*.vue" + ], "compilerOptions": { "baseUrl": ".", - "paths": { - }, + "paths": {}, "strict": false, "allowJs": true, "checkJs": true, @@ -15,5 +19,8 @@ "noImplicitThis": true, "isolatedModules": true, "types": ["node", "events"] + }, + "vueCompilerOptions": { + "plugins": ["@vue/language-plugin-pug"] } }