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 @@
+
+
+.pretix-widget-availability-box
+ .pretix-widget-availability-unavailable(v-if="item.current_unavailability_reason === 'require_voucher'")
+ small
+ a(:href="voucherJumpLink", :aria-describedby="ariaLabelledby") {{ unavailabilityReasonMessage }}
+ .pretix-widget-availability-unavailable(v-else-if="unavailabilityReasonMessage")
+ small {{ unavailabilityReasonMessage }}
+ .pretix-widget-availability-unavailable(v-else-if="avail[0] < 100 && avail[0] > 10") {{ STRINGS.reserved }}
+ .pretix-widget-availability-gone(v-else-if="avail[0] <= 10") {{ STRINGS.sold_out }}
+ .pretix-widget-waiting-list-link(v-if="waitingListShow && !unavailabilityReasonMessage")
+ a(:href="waitingListUrl", target="_blank", @click="$root.open_link_in_frame") {{ STRINGS.waiting_list }}
+ .pretix-widget-availability-available(v-if="!unavailabilityReasonMessage && avail[0] === 100")
+ label.pretix-widget-item-count-single-label.pretix-widget-btn-checkbox(v-if="orderMax === 1")
+ input(
+ ref="quantity",
+ type="checkbox",
+ value="1",
+ :name="inputName",
+ :aria-label="labelSelectItem"
+ )
+ span.pretix-widget-icon-cart(aria-hidden="true")
+ | {{ STRINGS.select }}
+
+ .pretix-widget-item-count-group(v-else, role="group", :aria-label="item.name")
+ button.pretix-widget-btn-default.pretix-widget-item-count-dec(
+ type="button",
+ data-step="-1",
+ :data-controls="`input_${inputName}`",
+ :aria-label="decLabel",
+ @click.prevent.stop="onStep"
+ )
+ span -
+ input.pretix-widget-item-count-multiple(
+ :id="`input_${inputName}`",
+ ref="quantity",
+ type="number",
+ inputmode="numeric",
+ pattern="\\d*",
+ placeholder="0",
+ min="0",
+ :max="orderMax",
+ :name="inputName",
+ :aria-labelledby="ariaLabelledby"
+ )
+ button.pretix-widget-btn-default.pretix-widget-item-count-inc(
+ type="button",
+ data-step="1",
+ :data-controls="`input_${inputName}`",
+ :aria-label="incLabel",
+ @click.prevent.stop="onStep"
+ )
+ span +
+
+
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 @@
+
+
+.pretix-widget-wrapper
+ .pretix-widget-button-container
+ form(ref="form", :method="formMethod", :action="formAction", :target="formTarget")
+ input(v-if="store.voucherCode", type="hidden", name="_voucher_code", :value="store.voucherCode")
+ input(v-if="store.voucherCode", type="hidden", name="voucher", :value="store.voucherCode")
+ input(type="hidden", name="subevent", :value="store.subevent")
+ input(type="hidden", name="locale", :value="lang")
+ input(type="hidden", name="widget_data", :value="widgetDataJson")
+ input(v-if="consentParameterValue", type="hidden", name="consent", :value="consentParameterValue")
+ input(
+ v-for="item in store.items",
+ :key="item.item",
+ type="hidden",
+ :name="item.item",
+ :value="item.count"
+ )
+ button.pretix-button(@click="handleBuy", v-html="store.buttonText")
+ .pretix-widget-clear
+
+ Overlay
+
+
+
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 @@
+
+
+
+.pretix-widget-category(:data-id="category.id")
+ h3.pretix-widget-category-name(v-if="category.name") {{ category.name }}
+ .pretix-widget-category-description(v-if="category.description", v-html="category.description")
+ .pretix-widget-category-items
+ Item(
+ v-for="item in category.items",
+ :key="item.id",
+ :item="item",
+ :category="category"
+ )
+
+
+
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 @@
+
+
+
+.pretix-widget-event-calendar(ref="calendar")
+ //- Back navigation
+ .pretix-widget-back(v-if="store.events !== null")
+ a(href="#", role="button", @click.prevent.stop="backToList")
+ | ‹ {{ STRINGS.back }}
+
+ //- Headline
+ .pretix-widget-event-header(v-if="displayEventInfo")
+ strong {{ store.name }}
+ .pretix-widget-event-description(
+ v-if="displayEventInfo && store.frontpageText",
+ v-html="store.frontpageText"
+ )
+
+ //- Filter
+ EventListFilterForm(v-if="showFilters")
+
+ //- Calendar navigation
+ .pretix-widget-event-calendar-head
+ a.pretix-widget-event-calendar-previous-month(href="#", @click.prevent.stop="prevmonth")
+ | « {{ STRINGS.previous_month }}
+ |
+ strong(:id="ariaLabelledby") {{ monthname }}
+ |
+ a.pretix-widget-event-calendar-next-month(href="#", @click.prevent.stop="nextmonth")
+ | {{ STRINGS.next_month }} »
+
+ //- Calendar table
+ table.pretix-widget-event-calendar-table(
+ :id="id",
+ tabindex="0",
+ :aria-labelledby="ariaLabelledby"
+ )
+ thead
+ tr
+ th(:aria-label="STRINGS.days.MONDAY") {{ STRINGS.days.MO }}
+ th(:aria-label="STRINGS.days.TUESDAY") {{ STRINGS.days.TU }}
+ th(:aria-label="STRINGS.days.WEDNESDAY") {{ STRINGS.days.WE }}
+ th(:aria-label="STRINGS.days.THURSDAY") {{ STRINGS.days.TH }}
+ th(:aria-label="STRINGS.days.FRIDAY") {{ STRINGS.days.FR }}
+ th(:aria-label="STRINGS.days.SATURDAY") {{ STRINGS.days.SA }}
+ th(:aria-label="STRINGS.days.SUNDAY") {{ STRINGS.days.SU }}
+ tbody
+ EventCalendarRow(
+ v-for="(week, idx) in store.weeks",
+ :key="idx",
+ :week="week",
+ :mobile="mobile"
+ )
+
+
+
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 @@
+
+
+
+td(
+ ref="cellEl",
+ :class="classObject",
+ :role="role",
+ :tabindex="tabindex",
+ :aria-label="dateStr"
+)
+ .pretix-widget-event-calendar-day(v-if="day", :aria-label="dateStr") {{ daynum }}
+ .pretix-widget-event-calendar-events(v-if="day")
+ EventCalendarEvent(v-for="e in day.events", :key="e.event_url", :event="e")
+
+
+
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 @@
+
+
+
+a.pretix-widget-event-calendar-event(
+ href="#",
+ :class="classObject",
+ @click.prevent.stop="select",
+ :aria-describedby="describedby"
+)
+ strong.pretix-widget-event-calendar-event-name {{ event.name }}
+ .pretix-widget-event-calendar-event-date(v-if="!event.continued && event.time") {{ event.time }}
+ .pretix-widget-event-calendar-event-availability(v-if="!event.continued && event.availability.text") {{ event.availability.text }}
+
+
+
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 @@
+
+
+
+tr
+ EventCalendarCell(v-for="(d, idx) in week", :key="idx", :day="d", :mobile="mobile")
+
+
+
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 @@
+
+
+
+.pretix-widget-event-form
+ //- Back navigation
+ .pretix-widget-event-list-back(v-if="store.events || store.weeks || store.days")
+ a(v-if="!store.subevent", href="#", rel="back", @click.prevent.stop="backToList")
+ | ‹ {{ STRINGS.back_to_list }}
+ a(v-if="store.subevent", href="#", rel="back", @click.prevent.stop="backToList")
+ | ‹ {{ STRINGS.back_to_dates }}
+
+ //- Event name
+ .pretix-widget-event-header(v-if="displayEventInfo")
+ strong(role="heading", aria-level="2") {{ store.name }}
+
+ //- Date range
+ .pretix-widget-event-details(v-if="displayEventInfo && store.dateRange") {{ store.dateRange }}
+
+ //- Location
+ .pretix-widget-event-location(
+ v-if="displayEventInfo && store.location",
+ v-html="store.location"
+ )
+
+ //- Description
+ .pretix-widget-event-description(
+ v-if="displayEventInfo && store.frontpageText",
+ v-html="store.frontpageText"
+ )
+
+ //- Form start
+ form(
+ ref="form",
+ method="post",
+ :action="formAction",
+ :target="formTarget",
+ @submit="handleBuy"
+ )
+ input(v-if="store.voucherCode", type="hidden", name="_voucher_code", :value="store.voucherCode")
+ input(type="hidden", name="subevent", :value="store.subevent")
+ input(type="hidden", name="widget_data", :value="widgetDataJson")
+ input(v-if="consentParameterValue", type="hidden", name="consent", :value="consentParameterValue")
+
+ //- Error message
+ .pretix-widget-error-message(v-if="store.error") {{ store.error }}
+
+ //- Resume cart
+ .pretix-widget-info-message.pretix-widget-clickable(v-if="store.cartExists")
+ span(:id="idCartExistsMsg") {{ STRINGS.cart_exists }}
+ button.pretix-widget-resume-button(
+ type="button",
+ :aria-describedby="idCartExistsMsg",
+ @click.prevent.stop="store.resume()"
+ ) {{ STRINGS.resume_checkout }}
+
+ //- Seating plan
+ .pretix-widget-seating-link-wrapper(v-if="store.hasSeatingPlan")
+ button.pretix-widget-seating-link(type="button", @click.prevent.stop="store.startseating()")
+ | {{ STRINGS.show_seating }}
+
+ //- Waiting list for seating plan
+ .pretix-widget-seating-waitinglist(v-if="store.hasSeatingPlan && store.hasSeatingPlanWaitinglist")
+ .pretix-widget-seating-waitinglist-text {{ STRINGS.seating_plan_waiting_list }}
+ .pretix-widget-seating-waitinglist-button-wrap
+ button.pretix-widget-seating-waitinglist-button(@click.prevent.stop="store.startwaiting()")
+ | {{ STRINGS.waiting_list }}
+ .pretix-widget-clear
+
+ //- Product list
+ Category(v-for="category in store.categories", :key="category.id", :category="category")
+
+ //- Buy button
+ .pretix-widget-action(v-if="store.displayAddToCart")
+ button(
+ v-if="!store.cartExists || isItemsSelected",
+ type="submit",
+ :aria-describedby="idCartExistsMsg"
+ ) {{ buyLabel }}
+ button(
+ v-else,
+ type="button",
+ :aria-describedby="idCartExistsMsg",
+ @click.prevent.stop="store.resume()"
+ ) {{ STRINGS.resume_checkout }}
+
+ //- Voucher form
+ form(
+ v-if="showVoucherForm",
+ method="get",
+ :action="store.getVoucherFormTarget()",
+ target="_blank"
+ )
+ .pretix-widget-voucher
+ h3.pretix-widget-voucher-headline(:id="ariaLabelledby") {{ STRINGS.redeem_voucher }}
+ .pretix-widget-voucher-text(
+ v-if="store.voucherExplanationText",
+ v-html="store.voucherExplanationText"
+ )
+ .pretix-widget-voucher-input-wrap
+ input.pretix-widget-voucher-input(
+ :id="idVoucherInput",
+ ref="voucherinput",
+ v-model="localVoucher",
+ type="text",
+ name="voucher",
+ :placeholder="STRINGS.voucher_code",
+ :aria-labelledby="ariaLabelledby"
+ )
+ input(
+ v-for="p in hiddenParams",
+ :key="p[0]",
+ type="hidden",
+ :name="p[0]",
+ :value="p[1]"
+ )
+ .pretix-widget-voucher-button-wrap
+ button(@click="handleRedeem") {{ STRINGS.redeem }}
+ .pretix-widget-clear
+
+
+
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 @@
+
+
+
+.pretix-widget-event-list
+ .pretix-widget-back(v-if="showBackButton")
+ a(href="#", rel="prev", @click.prevent.stop="backToCalendar")
+ | ‹ {{ STRINGS.back }}
+
+ .pretix-widget-event-header(v-if="displayEventInfo")
+ strong {{ store.name }}
+
+ .pretix-widget-event-description(
+ v-if="displayEventInfo && store.frontpageText",
+ v-html="store.frontpageText"
+ )
+
+ EventListFilterForm(v-if="showFilters")
+
+ EventListEntry(
+ v-for="event in store.events",
+ :key="event.event_url",
+ :event="event"
+ )
+
+ p.pretix-widget-event-list-load-more(v-if="store.hasMoreEvents")
+ button(@click.prevent.stop="loadMore") {{ STRINGS.load_more }}
+
+
+
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 @@
+
+
+
+a.pretix-widget-event-list-entry(href="#", :class="classObject", @click.prevent.stop="select")
+ .pretix-widget-event-list-entry-name {{ event.name }}
+ .pretix-widget-event-list-entry-date {{ event.date_range }}
+ .pretix-widget-event-list-entry-location {{ location }}
+ .pretix-widget-event-list-entry-availability
+ span {{ event.availability.text }}
+
+
+
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 @@
+
+
+
+.pretix-widget-event-list-filter-field
+ label(:for="id") {{ field.label }}
+ select(:id="id", :name="field.key", :value="currentValue")
+ option(v-for="choice in field.choices", :key="choice[0]", :value="choice[0]") {{ choice[1] }}
+
+
+
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 @@
+
+
+
+form.pretix-widget-event-list-filter-form(ref="filterform", @submit="onSubmit")
+ fieldset.pretix-widget-event-list-filter-fieldset
+ legend {{ STRINGS.filter_events_by }}
+ EventListFilterField(
+ v-for="field in store.metaFilterFields",
+ :key="field.key",
+ :field="field"
+ )
+ button {{ STRINGS.filter }}
+
+
+
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 @@
+
+
+
+.pretix-widget-event-calendar.pretix-widget-event-week-calendar(ref="weekcalendar")
+ //- Back navigation
+ .pretix-widget-back(v-if="store.events !== null")
+ a(href="#", @click.prevent.stop="backToList", role="button")
+ | ‹ {{ STRINGS.back }}
+
+ //- Event header
+ .pretix-widget-event-header(v-if="displayEventInfo")
+ strong {{ store.name }}
+
+ //- Filter
+ EventListFilterForm(v-if="showFilters")
+
+ //- Calendar navigation
+ .pretix-widget-event-description(
+ v-if="store.frontpageText && displayEventInfo",
+ v-html="store.frontpageText"
+ )
+ .pretix-widget-event-calendar-head
+ a.pretix-widget-event-calendar-previous-month(href="#", @click.prevent.stop="prevweek", role="button")
+ | « {{ STRINGS.previous_week }}
+ |
+ strong {{ weekname }}
+ |
+ a.pretix-widget-event-calendar-next-month(href="#", @click.prevent.stop="nextweek", role="button")
+ | {{ STRINGS.next_week }} »
+
+ //- Actual calendar
+ .pretix-widget-event-week-table(:id="id", tabindex="0", :aria-label="weekname")
+ .pretix-widget-event-week-col(v-for="d in store.days", :key="d?.date || ''")
+ EventWeekCell(:day="d", :mobile="mobile")
+
+
+
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 @@
+
+
+
+div(:class="classObject", @click.prevent.stop="selectDay")
+ .pretix-widget-event-calendar-day(v-if="day", :id="id") {{ dayhead }}
+ .pretix-widget-event-calendar-events(v-if="day")
+ EventCalendarEvent(
+ v-for="e in day.events",
+ :key="e.event_url",
+ :event="e",
+ :describedby="id"
+ )
+
+
+
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 @@
+
+
+div(
+ :class="classObject",
+ :data-id="item.id",
+ role="group",
+ :aria-labelledby="ariaLabelledby",
+ :aria-describedby="itemDescId"
+)
+ .pretix-widget-item-row.pretix-widget-main-item-row
+ //- Product description
+ .pretix-widget-item-info-col
+ a.pretix-widget-item-picture-link(
+ v-if="item.picture",
+ :href="item.picture_fullsize",
+ @click.prevent.stop="lightbox"
+ )
+ img.pretix-widget-item-picture(:src="item.picture", :alt="pictureAltText")
+ .pretix-widget-item-title-and-description
+ strong.pretix-widget-item-title(
+ :id="itemLabelId",
+ role="heading",
+ :aria-level="headingLevel"
+ ) {{ item.name }}
+ .pretix-widget-item-description(
+ v-if="item.description",
+ :id="itemDescId",
+ v-html="item.description"
+ )
+ p.pretix-widget-item-meta(v-if="item.order_min && item.order_min > 1")
+ small {{ minOrderStr }}
+ p.pretix-widget-item-meta(
+ v-if="!item.has_variations && item.avail[1] !== null && item.avail[0] === 100"
+ )
+ small {{ quotaLeftStr }}
+
+ //- Price
+ .pretix-widget-item-price-col(:id="itemPriceId")
+ PriceBox(
+ v-if="!item.has_variations && showPrices",
+ :price="item.price",
+ :freePrice="item.free_price",
+ :mandatoryPricedAddons="item.mandatory_priced_addons",
+ :suggestedPrice="item.suggested_price",
+ :fieldName="`price_${item.id}`",
+ :originalPrice="item.original_price",
+ :itemId="item.id"
+ )
+ .pretix-widget-pricebox(v-if="item.has_variations && showPrices", v-html="pricerange")
+ span(v-if="!showPrices")
+
+ //- Availability
+ .pretix-widget-item-availability-col
+ button.pretix-widget-collapse-indicator(
+ v-if="showToggle",
+ type="button",
+ :aria-expanded="expanded ? 'true' : 'false'",
+ :aria-controls="`${item.id}-variants`",
+ :aria-describedby="itemDescId",
+ @click.prevent.stop="expand"
+ ) {{ variationsToggleLabel }}
+ AvailBox(v-if="!item.has_variations", :item="item")
+
+ .pretix-widget-clear
+
+ //- Variations
+ div(
+ v-if="item.has_variations",
+ :id="`${item.id}-variants`",
+ ref="variations",
+ :class="varClasses"
+ )
+ Variation(
+ v-for="variation in item.variations",
+ :key="variation.id",
+ :variation="variation",
+ :item="item",
+ :category="category"
+ )
+
+
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 @@
+
+
+
+Teleport(to="body")
+ .pretix-widget-overlay
+ //- Iframe dialog
+ dialog(ref="frameDialog", :class="frameClasses", :aria-label="STRINGS.checkout", @close="close", @cancel="cancel")
+ .pretix-widget-frame-loading(v-show="store.overlay.frameLoading")
+ svg(width="256", height="256", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
+ path.pretix-widget-primary-color(d="M1152 896q0-106-75-181t-181-75-181 75-75 181 75 181 181 75 181-75 75-181zm512-109v222q0 12-8 23t-20 13l-185 28q-19 54-39 91 35 50 107 138 10 12 10 25t-9 23q-27 37-99 108t-94 71q-12 0-26-9l-138-108q-44 23-91 38-16 136-29 186-7 28-36 28h-222q-14 0-24.5-8.5t-11.5-21.5l-28-184q-49-16-90-37l-141 107q-10 9-25 9-14 0-25-11-126-114-165-168-7-10-7-23 0-12 8-23 15-21 51-66.5t54-70.5q-27-50-41-99l-183-27q-13-2-21-12.5t-8-23.5v-222q0-12 8-23t19-13l186-28q14-46 39-92-40-57-107-138-10-12-10-24 0-10 9-23 26-36 98.5-107.5t94.5-71.5q13 0 26 10l138 107q44-23 91-38 16-136 29-186 7-28 36-28h222q14 0 24.5 8.5t11.5 21.5l28 184q49 16 90 37l142-107q9-9 24-9 13 0 25 10 129 119 165 170 7 8 7 22 0 12-8 23-15 21-51 66.5t-54 70.5q26 50 41 98l183 28q13 2 21 12.5t8 23.5z")
+ p(:class="cancelBlockedClasses")
+ strong {{ STRINGS.cancel_blocked }}
+ .pretix-widget-frame-inner(v-show="store.overlay.frameShown")
+ form.pretix-widget-frame-close(method="dialog")
+ button(ref="closeButton", :aria-label="STRINGS.close_checkout", autofocus)
+ svg(:alt="STRINGS.close", height="16", viewBox="0 0 512 512", width="16", xmlns="http://www.w3.org/2000/svg")
+ path(fill="#fff", d="M437.5,386.6L306.9,256l130.6-130.6c14.1-14.1,14.1-36.8,0-50.9c-14.1-14.1-36.8-14.1-50.9,0L256,205.1L125.4,74.5 c-14.1-14.1-36.8-14.1-50.9,0c-14.1,14.1-14.1,36.8,0,50.9L205.1,256L74.5,386.6c-14.1,14.1-14.1,36.8,0,50.9 c14.1,14.1,36.8,14.1,50.9,0L256,306.9l130.6,130.6c14.1,14.1,36.8,14.1,50.9,0C451.5,423.4,451.5,400.6,437.5,386.6z")
+ iframe(
+ ref="iframe",
+ frameborder="0",
+ width="650",
+ height="650",
+ :name="store.widgetId",
+ src="about:blank",
+ allow="autoplay *; camera *; fullscreen *; payment *",
+ :title="STRINGS.checkout",
+ referrerpolicy="origin",
+ @load="iframeLoaded"
+ ) Please enable frames in your browser!
+
+ //- Alert dialog
+ dialog(ref="alertDialog", :class="alertClasses", role="alertdialog", :aria-labelledby="errorMessageId", @close="errorClose")
+ form.pretix-widget-alert-box(method="dialog")
+ p(:id="errorMessageId") {{ store.overlay.errorMessage }}
+ p
+ button(v-if="store.overlay.errorUrlAfter", value="continue", autofocus, :aria-describedby="errorMessageId")
+ | {{ STRINGS.continue }}
+ button(v-else, autofocus, :aria-describedby="errorMessageId") {{ STRINGS.close }}
+ transition(name="bounce")
+ svg.pretix-widget-alert-icon(v-if="store.overlay.errorMessage", width="64", height="64", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
+ path(style="fill:#ffffff;", d="M 599.86438,303.72882 H 1203.5254 V 1503.4576 H 599.86438 Z")
+ path.pretix-widget-primary-color(d="M896 128q209 0 385.5 103t279.5 279.5 103 385.5-103 385.5-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103zm128 1247v-190q0-14-9-23.5t-22-9.5h-192q-13 0-23 10t-10 23v190q0 13 10 23t23 10h192q13 0 22-9.5t9-23.5zm-2-344l18-621q0-12-10-18-10-8-24-8h-220q-14 0-24 8-10 6-10 18l17 621q0 10 10 17.5t24 7.5h185q14 0 23.5-7.5t10.5-17.5z")
+
+ //- Lightbox dialog
+ dialog(ref="lightboxDialog", :class="lightboxClasses", role="alertdialog", @close="lightboxClose")
+ .pretix-widget-lightbox-loading(v-if="store.overlay.lightbox?.loading")
+ svg(width="256", height="256", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
+ path.pretix-widget-primary-color(d="M1152 896q0-106-75-181t-181-75-181 75-75 181 75 181 181 75 181-75 75-181zm512-109v222q0 12-8 23t-20 13l-185 28q-19 54-39 91 35 50 107 138 10 12 10 25t-9 23q-27 37-99 108t-94 71q-12 0-26-9l-138-108q-44 23-91 38-16 136-29 186-7 28-36 28h-222q-14 0-24.5-8.5t-11.5-21.5l-28-184q-49-16-90-37l-141 107q-10 9-25 9-14 0-25-11-126-114-165-168-7-10-7-23 0-12 8-23 15-21 51-66.5t54-70.5q-27-50-41-99l-183-27q-13-2-21-12.5t-8-23.5v-222q0-12 8-23t19-13l186-28q14-46 39-92-40-57-107-138-10-12-10-24 0-10 9-23 26-36 98.5-107.5t94.5-71.5q13 0 26 10l138 107q44-23 91-38 16-136 29-186 7-28 36-28h222q14 0 24.5 8.5t11.5 21.5l28 184q49 16 90 37l142-107q9-9 24-9 13 0 25 10 129 119 165 170 7 8 7 22 0 12-8 23-15 21-51 66.5t-54 70.5q26 50 41 98l183 28q13 2 21 12.5t8 23.5z")
+ .pretix-widget-lightbox-inner(v-if="store.overlay.lightbox")
+ form.pretix-widget-lightbox-close(method="dialog")
+ button(:aria-label="STRINGS.close", autofocus)
+ svg(:alt="STRINGS.close", height="16", viewBox="0 0 512 512", width="16", xmlns="http://www.w3.org/2000/svg")
+ path(fill="#fff", d="M437.5,386.6L306.9,256l130.6-130.6c14.1-14.1,14.1-36.8,0-50.9c-14.1-14.1-36.8-14.1-50.9,0L256,205.1L125.4,74.5 c-14.1-14.1-36.8-14.1-50.9,0c-14.1,14.1-14.1,36.8,0,50.9L205.1,256L74.5,386.6c-14.1,14.1-14.1,36.8,0,50.9 c14.1,14.1,36.8,14.1,50.9,0L256,306.9l130.6,130.6c14.1,14.1,36.8,14.1,50.9,0C451.5,423.4,451.5,400.6,437.5,386.6z")
+ figure.pretix-widget-lightbox-image
+ img(
+ ref="lightboxImage",
+ :src="store.overlay.lightbox.image",
+ :alt="store.overlay.lightbox.description",
+ crossorigin,
+ @load="lightboxLoaded"
+ )
+ figcaption(v-if="store.overlay.lightbox.description") {{ store.overlay.lightbox.description }}
+
+
+
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 @@
+
+
+.pretix-widget-pricebox
+ span(v-if="!freePrice && !originalPrice", v-html="priceline")
+ span(v-if="!freePrice && originalPrice")
+ del.pretix-widget-pricebox-original-price(:aria-label="originalPriceAriaLabel", v-html="originalLine")
+ |
+ ins.pretix-widget-pricebox-new-price(:aria-label="newPriceAriaLabel", v-html="priceline")
+ div(v-if="freePrice")
+ span.pretix-widget-pricebox-currency(:id="priceBoxId") {{ store.currency }}
+ |
+ input.pretix-widget-pricebox-price-input(
+ type="number",
+ placeholder="0",
+ :min="displayPriceNonlocalized",
+ :value="suggestedPriceNonlocalized",
+ :name="fieldName",
+ step="any",
+ :aria-labelledby="ariaLabelledby",
+ :aria-describedby="priceDescId"
+ )
+ small.pretix-widget-pricebox-tax(v-if="showTaxline", :id="priceDescId") {{ taxline }}
+
+
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 @@
+
+
+.pretix-widget-variation(
+ :data-id="variation.id",
+ role="group",
+ :aria-labelledby="ariaLabelledby",
+ :aria-describedby="variationDescId"
+)
+ .pretix-widget-item-row
+ //- Variation description
+ .pretix-widget-item-info-col
+ .pretix-widget-item-title-and-description
+ strong.pretix-widget-item-title(
+ :id="variationLabelId",
+ role="heading",
+ :aria-level="headingLevel"
+ ) {{ variation.value }}
+ .pretix-widget-item-description(
+ v-if="variation.description",
+ :id="variationDescId",
+ v-html="variation.description"
+ )
+ p.pretix-widget-item-meta(v-if="showQuotaLeft")
+ small {{ quotaLeftStr }}
+
+ //- Price
+ .pretix-widget-item-price-col(:id="variationPriceId")
+ PriceBox(
+ v-if="showPrices",
+ :price="variation.price",
+ :freePrice="item.free_price",
+ :originalPrice="origPrice",
+ :mandatoryPricedAddons="item.mandatory_priced_addons",
+ :suggestedPrice="variation.suggested_price",
+ :fieldName="`price_${item.id}_${variation.id}`",
+ :itemId="item.id"
+ )
+ span(v-if="!showPrices")
+
+ //- Availability
+ .pretix-widget-item-availability-col
+ AvailBox(:item="item", :variation="variation")
+
+ .pretix-widget-clear
+
+
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 @@
+
+
+.pretix-widget-wrapper(ref="wrapper", tabindex="0", role="article", :aria-label="store.name")
+ div(:class="classObject")
+ .pretix-widget-loading(v-show="store.loading > 0")
+ svg(width="128", height="128", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
+ path.pretix-widget-primary-color(d="M1152 896q0-106-75-181t-181-75-181 75-75 181 75 181 181 75 181-75 75-181zm512-109v222q0 12-8 23t-20 13l-185 28q-19 54-39 91 35 50 107 138 10 12 10 25t-9 23q-27 37-99 108t-94 71q-12 0-26-9l-138-108q-44 23-91 38-16 136-29 186-7 28-36 28h-222q-14 0-24.5-8.5t-11.5-21.5l-28-184q-49-16-90-37l-141 107q-10 9-25 9-14 0-25-11-126-114-165-168-7-10-7-23 0-12 8-23 15-21 51-66.5t54-70.5q-27-50-41-99l-183-27q-13-2-21-12.5t-8-23.5v-222q0-12 8-23t19-13l186-28q14-46 39-92-40-57-107-138-10-12-10-24 0-10 9-23 26-36 98.5-107.5t94.5-71.5q13 0 26 10l138 107q44-23 91-38 16-136 29-186 7-28 36-28h222q14 0 24.5 8.5t11.5 21.5l28 184q49 16 90 37l142-107q9-9 24-9 13 0 25 10 129 119 165 170 7 8 7 22 0 12-8 23-15 21-51 66.5t-54 70.5q26 50 41 98l183 28q13 2 21 12.5t8 23.5z")
+
+ .pretix-widget-error-message(v-if="store.error && store.view !== 'event'") {{ store.error }}
+ .pretix-widget-error-action(v-if="store.error && store.connectionError")
+ a.pretix-widget-button(:href="store.newTabTarget", target="_blank") {{ STRINGS.open_new_tab }}
+
+ EventForm(v-if="store.view === 'event'", ref="formcomp")
+ EventList(v-if="store.view === 'events'")
+ EventCalendar(v-if="store.view === 'weeks'", :mobile="mobile")
+ EventWeekCalendar(v-if="store.view === 'days'", :mobile="mobile")
+
+ .pretix-widget-clear
+ .pretix-widget-attribution(v-if="store.poweredby", v-html="store.poweredby")
+
+ Overlay
+
+
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"]
}
}