Migrate vue2 control components and widget to vue3 and vite (#5989)

* setup vite and integrate fully with django

- vite starts with `python manage.py runserver`
- add templatetags to simply load vite hmr and entry points
- add eslint (recheck rules)
- enable non-strict ts

* better syntax for cors header setting

* migrate checkin rules editor to vue3

- move constants to a module
- move reading from and writing to non-vue html to django interop module
- switch to composition api and script setup sfc with pug
- use optional chaining operators a lot to simplify code

* migrate webcheckin plugin to vite+vue3

- migrate vue sfcs to script setup and pug
- move fetch calls into a api.ts module
- move common formatting and i18n strings into module

* fix migration error

* first draft migrating widget to vue3/vite

* first couple widget e2e tests

courtesy of claude
most of the tests don't work yet

* test file is not actually used

* drop widget_ prefix from e2e test fixtures

* add test for complete widget journey for simple event

* switch timezone in e2e tests to Europe/Berlin

* make dates in e2e tests relative

* migrate widget bugfix #5886

* start testing event series widget

* working vite widget setup for prod (untested), local dev (with or without dev server) and pytests, with flags for running the old version or the vite version

* simplify e2e test iframe check

* less flaky e2e tests

* top level await in iife build mode is not supported, so let's do import.meta.glob instead (we just need the build step not to see await, the code doesn't actually ever get loaded because it's DEV only)

* fix inconsistencies from automatic migration

* Allow gradual rollout of new vite-based widget by adding urls to an allowlist that gets checked against the "Origin" http header of request fetching the widget js

* add e2e tests for widget button, testing empty cart, adding specific items, and subevents

* remove janky claude testts again

* resolve migration TODOs: properly refocus parent on navigations

* use `npm run dev:control` for the vite dev server for admin components

* upgrade npm dependencies

* fix js linter errors

* fix python linter errors

* build all control vue components

* add new js config files to check-manifest ignore

* working prod build

acutal serving of built assets not tested yet

* fix templatetag paths to match what's in the vite mantifest

* add missing quotes around 'unsafe-eval' cors value

* remove now unused old vue2 tooling

* try fixing e2e test ci

* fix flake8 error

* check if vite build artefacts are in the wheel

* add license headers

* remove dom manipilation code necessary for `div.pretix-widget-compat` to work. No longer needed for vue3

* remove superfluous `createElement` calls

They might have been there because of IE, which is no longer relevant

* make widget dev mode parametizable through query params and document the usage and those params

* fix rst syntax

* remove migration todos file

Co-authored-by: luelista <mira@teamwiki.de>

* rearrange dockerfile commands for smaller image, thanks @luelista

* Update .gitignore, adding .vite

Co-authored-by: luelista <mira@teamwiki.de>

* add eslint CI

* make vue dev work in plugins

* fix docker build

* rebuild vite setup to support static prod plugins and dynamic hmr plugin development

* use toml for vite plugin config instead of standalone json file

* Add widget changes from #6047, #6149

* Allow buttons to reuse cart (Z#23226853)

* Always keep cart of buttons with items set

* widget: handle cart if not same-site (#6149)

---------

Co-authored-by: luelista <mira@teamwiki.de>
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
This commit is contained in:
rash
2026-05-11 15:05:06 +02:00
committed by GitHub
parent 1640ddd497
commit f04df7a6ee
99 changed files with 13462 additions and 9125 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
{
"name": "pretix",
"version": "0.0.0",
"private": true,
"scripts": {},
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.29.3",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"vue": "^2.7.16",
"rollup": "^2.79.1",
"rollup-plugin-vue": "^5.0.1",
"vue-template-compiler": "^2.7.16"
}
}

View File

@@ -1,23 +0,0 @@
import vue from 'rollup-plugin-vue'
import { getBabelOutputPlugin } from '@rollup/plugin-babel'
export default {
output: {
format: 'iife',
exports: 'named',
},
plugins: [
getBabelOutputPlugin({
presets: ['@babel/preset-env'],
// Running babel on iife output is apparently discouraged since it can lead to global
// variable leaks. Since we didn't get it to work on inputs, let's take that risk.
// (In our tests, it did not leak anything.)
allowAllFormats: true
}),
vue({
css: true,
compileTemplate: true,
needMap: false,
}),
],
};

View File

@@ -1,318 +0,0 @@
$(function () {
var TYPEOPS = {
// Every change to our supported JSON logic must be done
// * in pretix.base.services.checkin
// * in pretix.base.models.checkin
// * in pretix.helpers.jsonlogic_boolalg
// * in checkinrules.js
// * in libpretixsync
// * in pretixscan-ios
'product': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'variation': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'gate': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'datetime': {
'isBefore': {
'label': gettext('is before'),
'cardinality': 2,
},
'isAfter': {
'label': gettext('is after'),
'cardinality': 2,
},
},
'enum_entry_status': {
'==': {
'label': gettext('='),
'cardinality': 2,
},
},
'int_by_datetime': {
'<': {
'label': '<',
'cardinality': 2,
},
'<=': {
'label': '≤',
'cardinality': 2,
},
'>': {
'label': '>',
'cardinality': 2,
},
'>=': {
'label': '≥',
'cardinality': 2,
},
'==': {
'label': '=',
'cardinality': 2,
},
'!=': {
'label': '≠',
'cardinality': 2,
},
},
'int': {
'<': {
'label': '<',
'cardinality': 2,
},
'<=': {
'label': '≤',
'cardinality': 2,
},
'>': {
'label': '>',
'cardinality': 2,
},
'>=': {
'label': '≥',
'cardinality': 2,
},
'==': {
'label': '=',
'cardinality': 2,
},
'!=': {
'label': '≠',
'cardinality': 2,
},
},
};
var VARS = {
'product': {
'label': gettext('Product'),
'type': 'product',
},
'variation': {
'label': gettext('Product variation'),
'type': 'variation',
},
'gate': {
'label': gettext('Gate'),
'type': 'gate',
},
'now': {
'label': gettext('Current date and time'),
'type': 'datetime',
},
'now_isoweekday': {
'label': gettext('Current day of the week (1 = Monday, 7 = Sunday)'),
'type': 'int',
},
'entry_status': {
'label': gettext('Current entry status'),
'type': 'enum_entry_status',
},
'entries_number': {
'label': gettext('Number of previous entries'),
'type': 'int',
},
'entries_today': {
'label': gettext('Number of previous entries since midnight'),
'type': 'int',
},
'entries_since': {
'label': gettext('Number of previous entries since'),
'type': 'int_by_datetime',
},
'entries_before': {
'label': gettext('Number of previous entries before'),
'type': 'int_by_datetime',
},
'entries_days': {
'label': gettext('Number of days with a previous entry'),
'type': 'int',
},
'entries_days_since': {
'label': gettext('Number of days with a previous entry since'),
'type': 'int_by_datetime',
},
'entries_days_before': {
'label': gettext('Number of days with a previous entry before'),
'type': 'int_by_datetime',
},
'minutes_since_last_entry': {
'label': gettext('Minutes since last entry (-1 on first entry)'),
'type': 'int',
},
'minutes_since_first_entry': {
'label': gettext('Minutes since first entry (-1 on first entry)'),
'type': 'int',
},
};
var components = {
CheckinRulesVisualization: CheckinRulesVisualization.default,
}
if (typeof CheckinRule !== "undefined") {
Vue.component('checkin-rule', CheckinRule.default);
components = {
CheckinRulesEditor: CheckinRulesEditor.default,
CheckinRulesVisualization: CheckinRulesVisualization.default,
}
}
var app = new Vue({
el: '#rules-editor',
components: components,
data: function () {
return {
rules: {},
items: [],
all_products: false,
limit_products: [],
TYPEOPS: TYPEOPS,
VARS: VARS,
texts: {
and: gettext('All of the conditions below (AND)'),
or: gettext('At least one of the conditions below (OR)'),
date_from: gettext('Event start'),
date_to: gettext('Event end'),
date_admission: gettext('Event admission'),
date_custom: gettext('custom date and time'),
date_customtime: gettext('custom time'),
date_tolerance: gettext('Tolerance (minutes)'),
condition_add: gettext('Add condition'),
minutes: gettext('minutes'),
duplicate: gettext('Duplicate'),
status_present: pgettext('entry_status', 'present'),
status_absent: pgettext('entry_status', 'absent'),
},
hasRules: false,
};
},
computed: {
missingItems: function () {
// This computed property contains list of item or variation names that
// a) Are allowed on the checkin list according to all_products or include_products
// b) Are not matched by ANY logical branch of the rule.
// The list will be empty if there is a "catch-all" rule.
var products_seen = {};
var variations_seen = {};
var rules = convert_to_dnf(this.rules);
var branch_without_product_filter = false;
if (!rules["or"]) {
rules = {"or": [rules]}
}
for (var part of rules["or"]) {
if (!part["and"]) {
part = {"and": [part]}
}
var this_branch_without_product_filter = true;
for (var subpart of part["and"]) {
if (subpart["inList"]) {
if (subpart["inList"][0]["var"] === "product" && subpart["inList"][1]) {
this_branch_without_product_filter = false;
for (var listentry of subpart["inList"][1]["objectList"]) {
products_seen[parseInt(listentry["lookup"][1])] = true
}
} else if (subpart["inList"][0]["var"] === "variation" && subpart["inList"][1]) {
this_branch_without_product_filter = false;
for (var listentry_ of subpart["inList"][1]["objectList"]) {
variations_seen[parseInt(listentry_["lookup"][1])] = true
}
}
}
}
if (this_branch_without_product_filter) {
branch_without_product_filter = true;
break;
}
}
if (branch_without_product_filter || (!Object.keys(products_seen).length && !Object.keys(variations_seen).length)) {
// At least one branch with no product filters at all that's fine.
return [];
}
var missing = [];
for (var item of this.items) {
if (products_seen[item.id]) continue;
if (!this.all_products && !this.limit_products.includes(item.id)) continue;
if (item.variations.length > 0) {
for (var variation of item.variations) {
if (variations_seen[variation.id]) continue;
missing.push(item.name + " " + variation.name)
}
} else {
missing.push(item.name)
}
}
return missing;
}
},
created: function () {
this.rules = JSON.parse($("#id_rules").val());
if ($("#items").length) {
this.items = JSON.parse($("#items").html());
var root = this.$root
function _update() {
root.all_products = $("#id_all_products").prop("checked")
root.limit_products = $("input[name=limit_products]:checked").map(function () {
return parseInt($(this).val())
}).toArray()
}
$("#id_all_products, input[name=limit_products]").on("change", function () {
_update();
})
_update()
function check_for_invalid_ids(valid_products, valid_variations, rule) {
if (rule["and"]) {
for(const child of rule["and"])
check_for_invalid_ids(valid_products, valid_variations, child);
} else if (rule["or"]) {
for(const child of rule["or"])
check_for_invalid_ids(valid_products, valid_variations, child);
} else if (rule["inList"] && rule["inList"][0]["var"] === "product") {
for(const item of rule["inList"][1]["objectList"]) {
if (!valid_products[item["lookup"][1]])
item["lookup"][2] = "[" + gettext('Error: Product not found!') + "]";
else
item["lookup"][2] = valid_products[item["lookup"][1]];
}
} else if (rule["inList"] && rule["inList"][0]["var"] === "variation") {
for(const item of rule["inList"][1]["objectList"]) {
if (!valid_variations[item["lookup"][1]])
item["lookup"][2] = "[" + gettext('Error: Variation not found!') + "]";
else
item["lookup"][2] = valid_variations[item["lookup"][1]];
}
}
}
check_for_invalid_ids(
Object.fromEntries(this.items.map(p => [p.id, p.name])),
Object.fromEntries(this.items.flatMap(p => p.variations?.map(v => [v.id, p.name + ' ' + v.name]))),
this.rules
);
}
},
watch: {
rules: {
deep: true,
handler: function (newval) {
$("#id_rules").val(JSON.stringify(newval));
}
},
}
})
});

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rules as rawRules, items, allProducts, limitProducts } from './django-interop'
import { convertToDNF } from './jsonlogic-boolalg'
import RulesEditor from './checkin-rules-editor.vue'
import RulesVisualization from './checkin-rules-visualization.vue'
const gettext = (window as any).gettext
const missingItems = computed(() => {
// This computed variable contains list of item or variation names that
// a) Are allowed on the checkin list according to all_products or include_products
// b) Are not matched by ANY logical branch of the rule.
// The list will be empty if there is a "catch-all" rule.
let productsSeen = {}
let variationsSeen = {}
let rules = convertToDNF(rawRules.value)
let branchWithoutProductFilter = false
if (!rules.or) {
rules = { or: [rules] }
}
for (let part of rules.or) {
if (!part.and) {
part = { and: [part] }
}
let thisBranchWithoutProductFilter = true
for (let subpart of part.and) {
if (subpart.inList) {
if (subpart.inList[0].var === 'product' && subpart.inList[1]) {
thisBranchWithoutProductFilter = false
for (let listentry of subpart.inList[1].objectList) {
productsSeen[parseInt(listentry.lookup[1])] = true
}
} else if (subpart.inList[0].var === 'variation' && subpart.inList[1]) {
thisBranchWithoutProductFilter = false
for (let listentry_ of subpart.inList[1].objectList) {
variationsSeen[parseInt(listentry_.lookup[1])] = true
}
}
}
}
if (thisBranchWithoutProductFilter) {
branchWithoutProductFilter = true
break
}
}
if (branchWithoutProductFilter || (!Object.keys(productsSeen).length && !Object.keys(variationsSeen).length)) {
// At least one branch with no product filters at all that's fine.
return []
}
let missing = []
for (const item of items.value) {
if (productsSeen[item.id]) continue
if (!allProducts.value && !limitProducts.value.includes(item.id)) continue
if (item.variations.length > 0) {
for (let variation of item.variations) {
if (variationsSeen[variation.id]) continue
missing.push(item.name + ' ' + variation.name)
}
} else {
missing.push(item.name)
}
}
return missing
})
</script>
<template lang="pug">
#rules-editor.form-inline
div
ul.nav.nav-tabs(role="tablist")
li.active(role="presentation")
a(href="#rules-edit", role="tab", data-toggle="tab")
span.fa.fa-edit
| {{ gettext("Edit") }}
li(role="presentation")
a(href="#rules-viz", role="tab", data-toggle="tab")
span.fa.fa-eye
| {{ gettext("Visualize") }}
//- Tab panes
.tab-content
#rules-edit.tab-pane.active(v-if="items", role="tabpanel")
RulesEditor
#rules-viz.tab-pane(role="tabpanel")
RulesVisualization
.alert.alert-info(v-if="missingItems.length")
p {{ gettext("Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:") }}
ul
li(v-for="h in missingItems", :key="h") {{ h }}
p {{ gettext("Please double-check if this was intentional.") }}
</template>
<style lang="stylus">
</style>

View File

@@ -1,355 +1,365 @@
<template>
<div v-bind:class="classObject">
<div class="btn-group pull-right">
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="duplicate"
v-if="level > 0" data-toggle="tooltip" :title="texts.duplicate">
<span class="fa fa-copy"></span>
</button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithOR">OR
</button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithAND">AND
</button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="cutOut"
v-if="operands && operands.length === 1 && (operator === 'or' || operator === 'and')"><span
class="fa fa-cut"></span></button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="remove"
v-if="level > 0"><span class="fa fa-trash"></span></button>
</div>
<select v-bind:value="variable" v-on:input="setVariable" required class="form-control">
<option value="and">{{texts.and}}</option>
<option value="or">{{texts.or}}</option>
<option v-for="(v, name) in vars" :value="name">{{ v.label }}</option>
</select>
<select v-bind:value="operator" v-on:input="setOperator" required class="form-control"
v-if="operator !== 'or' && operator !== 'and' && vartype !== 'int_by_datetime'">
<option></option>
<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>
</select>
<select v-bind:value="timeType" v-on:input="setTimeType" required class="form-control"
v-if="vartype === 'datetime' || vartype === 'int_by_datetime'">
<option value="date_from">{{texts.date_from}}</option>
<option value="date_to">{{texts.date_to}}</option>
<option value="date_admission">{{texts.date_admission}}</option>
<option value="custom">{{texts.date_custom}}</option>
<option value="customtime">{{texts.date_customtime}}</option>
</select>
<datetimefield v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'custom'" :value="timeValue"
v-on:input="setTimeValue"></datetimefield>
<timefield v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'customtime'" :value="timeValue"
v-on:input="setTimeValue"></timefield>
<input class="form-control" required type="number"
v-if="vartype === 'datetime' && timeType && timeType !== 'customtime' && timeType !== 'custom'" v-bind:value="timeTolerance"
v-on:input="setTimeTolerance" :placeholder="texts.date_tolerance">
<select v-bind:value="operator" v-on:input="setOperator" required class="form-control"
v-if="vartype === 'int_by_datetime'">
<option></option>
<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>
</select>
<input class="form-control" required type="number" v-if="(vartype === 'int' || vartype === 'int_by_datetime') && cardinality > 1"
v-bind:value="rightoperand" v-on:input="setRightOperandNumber">
<lookup-select2 required v-if="vartype === 'product' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandProductList"
:url="productSelectURL"></lookup-select2>
<lookup-select2 required v-if="vartype === 'variation' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandVariationList"
:url="variationSelectURL"></lookup-select2>
<lookup-select2 required v-if="vartype === 'gate' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandGateList"
:url="gateSelectURL"></lookup-select2>
<select required v-if="vartype === 'enum_entry_status' && operator === '=='"
:value="rightoperand" v-on:input="setRightOperandEnum" class="form-control">
<option value="absent">{{ texts.status_absent }}</option>
<option value="present">{{ texts.status_present }}</option>
</select>
<div class="checkin-rule-childrules" v-if="operator === 'or' || operator === 'and'">
<div v-for="(op, opi) in operands">
<checkin-rule :rule="op" :index="opi" :level="level + 1" v-if="typeof op === 'object'"></checkin-rule>
</div>
<button type="button" class="checkin-rule-addchild btn btn-xs btn-default" @click.prevent="addOperand"><span
class="fa fa-plus-circle"></span> {{ texts.condition_add }}
</button>
</div>
</div>
</template>
<script>
export default {
components: {
LookupSelect2: LookupSelect2.default,
Datetimefield: Datetimefield.default,
Timefield: Timefield.default,
},
props: {
rule: Object,
level: Number,
index: Number,
},
computed: {
texts: function () {
return this.$root.texts;
},
variable: function () {
var op = this.operator;
if (op === "and" || op === "or") {
return op;
} else if (this.rule[op] && this.rule[op][0]) {
if (this.rule[op][0]["entries_since"]) {
return "entries_since";
}
if (this.rule[op][0]["entries_before"]) {
return "entries_before";
}
if (this.rule[op][0]["entries_days_since"]) {
return "entries_days_since";
}
if (this.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return this.rule[op][0]["var"];
} else {
return null;
}
},
rightoperand: function () {
var op = this.operator;
if (op === "and" || op === "or") {
return null;
} else if (this.rule[op] && typeof this.rule[op][1] !== "undefined") {
return this.rule[op][1];
} else {
return null;
}
},
operator: function () {
return Object.keys(this.rule)[0];
},
operands: function () {
return this.rule[this.operator];
},
classObject: function () {
var c = {
'checkin-rule': true
};
c['checkin-rule-' + this.variable] = true;
return c;
},
vartype: function () {
if (this.variable && this.$root.VARS[this.variable]) {
return this.$root.VARS[this.variable]['type'];
}
},
timeType: function () {
if (this.vartype === 'int_by_datetime') {
if (this.rule[this.operator][0][this.variable] && this.rule[this.operator][0][this.variable][0]['buildTime']) {
return this.rule[this.operator][0][this.variable][0]['buildTime'][0];
}
} else if (this.rightoperand && this.rightoperand['buildTime']) {
return this.rightoperand['buildTime'][0];
}
},
timeTolerance: function () {
var op = this.operator;
if ((op === "isBefore" || op === "isAfter") && this.rule[op] && typeof this.rule[op][2] !== "undefined") {
return this.rule[op][2];
} else {
return null;
}
},
timeValue: function () {
if (this.vartype === 'int_by_datetime') {
if (this.rule[this.operator][0][this.variable][0]['buildTime']) {
return this.rule[this.operator][0][this.variable][0]['buildTime'][1];
}
} else if (this.rightoperand && this.rightoperand['buildTime']) {
return this.rightoperand['buildTime'][1];
}
},
cardinality: function () {
if (this.vartype && this.$root.TYPEOPS[this.vartype] && this.$root.TYPEOPS[this.vartype][this.operator]) {
return this.$root.TYPEOPS[this.vartype][this.operator]['cardinality'];
}
},
operators: function () {
return this.$root.TYPEOPS[this.vartype];
},
productSelectURL: function () {
return $("#product-select2").text();
},
variationSelectURL: function () {
return $("#variations-select2").text();
},
gateSelectURL: function () {
return $("#gates-select2").text();
},
vars: function () {
return this.$root.VARS;
},
},
methods: {
setVariable: function (event) {
var current_op = Object.keys(this.rule)[0];
var current_val = this.rule[current_op];
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import { computed } from 'vue'
import { TEXTS, VARS, TYPEOPS } from './constants'
import { productSelectURL, variationSelectURL, gateSelectURL } from './django-interop'
import LookupSelect2 from './lookup-select2.vue'
import Datetimefield from './datetimefield.vue'
import Timefield from './timefield.vue'
if (event.target.value === "and" || event.target.value === "or") {
if (current_val[0] && current_val[0]["var"]) {
current_val = [];
}
this.$set(this.rule, event.target.value, current_val);
this.$delete(this.rule, current_op);
} else {
if (current_val !== "and" && current_val !== "or" && current_val[0] && this.$root.VARS[event.target.value]['type'] === this.vartype) {
if (this.vartype === "int_by_datetime") {
var current_data = this.rule[current_op][0][this.variable];
var new_lhs = {};
new_lhs[event.target.value] = JSON.parse(JSON.stringify(current_data));
this.$set(this.rule[current_op], 0, new_lhs);
} else {
this.$set(this.rule[current_op][0], "var", event.target.value);
}
} else if (this.$root.VARS[event.target.value]['type'] === 'int_by_datetime') {
this.$delete(this.rule, current_op);
var o = {};
o[event.target.value] = [{"buildTime": [null, null]}]
this.$set(this.rule, "!!", [o]);
} else {
this.$delete(this.rule, current_op);
this.$set(this.rule, "!!", [{"var": event.target.value}]);
}
}
},
setOperator: function (event) {
var current_op = Object.keys(this.rule)[0];
var current_val = this.rule[current_op];
this.$delete(this.rule, current_op);
this.$set(this.rule, event.target.value, current_val);
},
setRightOperandNumber: function (event) {
if (this.rule[this.operator].length === 1) {
this.rule[this.operator].push(parseInt(event.target.value));
} else {
this.$set(this.rule[this.operator], 1, parseInt(event.target.value));
}
},
setTimeTolerance: function (event) {
if (this.rule[this.operator].length === 2) {
this.rule[this.operator].push(parseInt(event.target.value));
} else {
this.$set(this.rule[this.operator], 2, parseInt(event.target.value));
}
},
setTimeType: function (event) {
var time = {
"buildTime": [event.target.value]
};
if (this.vartype === "int_by_datetime") {
this.$set(this.rule[this.operator][0][this.variable], 0, time);
} else {
if (this.rule[this.operator].length === 1) {
this.rule[this.operator].push(time);
} else {
this.$set(this.rule[this.operator], 1, time);
}
if (event.target.value === "custom") {
this.$set(this.rule[this.operator], 2, 0);
}
}
},
setTimeValue: function (val) {
if (this.vartype === "int_by_datetime") {
this.$set(this.rule[this.operator][0][this.variable][0]["buildTime"], 1, val);
} else {
this.$set(this.rule[this.operator][1]["buildTime"], 1, val);
}
},
setRightOperandProductList: function (val) {
var products = {
"objectList": []
};
for (var i = 0; i < val.length; i++) {
products["objectList"].push({
"lookup": [
"product",
val[i].id,
val[i].text
]
});
}
if (this.rule[this.operator].length === 1) {
this.rule[this.operator].push(products);
} else {
this.$set(this.rule[this.operator], 1, products);
}
},
setRightOperandVariationList: function (val) {
var products = {
"objectList": []
};
for (var i = 0; i < val.length; i++) {
products["objectList"].push({
"lookup": [
"variation",
val[i].id,
val[i].text
]
});
}
if (this.rule[this.operator].length === 1) {
this.rule[this.operator].push(products);
} else {
this.$set(this.rule[this.operator], 1, products);
}
},
setRightOperandGateList: function (val) {
var products = {
"objectList": []
};
for (var i = 0; i < val.length; i++) {
products["objectList"].push({
"lookup": [
"gate",
val[i].id,
val[i].text
]
});
}
if (this.rule[this.operator].length === 1) {
this.rule[this.operator].push(products);
} else {
this.$set(this.rule[this.operator], 1, products);
}
},
setRightOperandEnum: function (event) {
if (this.rule[this.operator].length === 1) {
this.rule[this.operator].push(event.target.value);
} else {
this.$set(this.rule[this.operator], 1, event.target.value);
}
},
addOperand: function () {
this.rule[this.operator].push({"": []});
},
wrapWithOR: function () {
var r = JSON.parse(JSON.stringify(this.rule));
this.$delete(this.rule, this.operator);
this.$set(this.rule, "or", [r]);
},
wrapWithAND: function () {
var r = JSON.parse(JSON.stringify(this.rule));
this.$delete(this.rule, this.operator);
this.$set(this.rule, "and", [r]);
},
cutOut: function () {
var cop = Object.keys(this.operands[0])[0];
var r = this.operands[0][cop];
this.$delete(this.rule, this.operator);
this.$set(this.rule, cop, r);
},
remove: function () {
this.$parent.rule[this.$parent.operator].splice(this.index, 1);
},
duplicate: function () {
var r = JSON.parse(JSON.stringify(this.rule));
this.$parent.rule[this.$parent.operator].splice(this.index, 0, r);
},
}
}
const props = defineProps<{
rule: any
level: number
index: number
}>()
const emit = defineEmits<{
remove: []
duplicate: []
}>()
const operator = computed(() => Object.keys(props.rule)[0])
const operands = computed(() => props.rule[operator.value])
const variable = computed(() => {
const op = operator.value
if (op === 'and' || op === 'or') {
return op
} else if (props.rule[op]?.[0]) {
if (props.rule[op][0]['entries_since']) return 'entries_since'
if (props.rule[op][0]['entries_before']) return 'entries_before'
if (props.rule[op][0]['entries_days_since']) return 'entries_days_since'
if (props.rule[op][0]['entries_days_before']) return 'entries_days_before'
return props.rule[op][0]['var']
}
return null
})
const rightoperand = computed(() => {
const op = operator.value
if (op === 'and' || op === 'or') return null
return props.rule[op]?.[1] ?? null
})
const classObject = computed(() => ({
'checkin-rule': true,
['checkin-rule-' + variable.value]: true
}))
const vartype = computed(() => VARS[variable.value]?.type)
const timeType = computed(() => {
if (vartype.value === 'int_by_datetime') {
return props.rule[operator.value]?.[0]?.[variable.value]?.[0]?.buildTime?.[0]
}
return rightoperand.value?.buildTime?.[0]
})
const timeTolerance = computed(() => {
const op = operator.value
if ((op === 'isBefore' || op === 'isAfter') && props.rule[op]?.[2] !== undefined) {
return props.rule[op][2]
}
return null
})
const timeValue = computed(() => {
if (vartype.value === 'int_by_datetime') {
return props.rule[operator.value]?.[0]?.[variable.value]?.[0]?.buildTime?.[1]
}
return rightoperand.value?.buildTime?.[1]
})
const cardinality = computed(() => TYPEOPS[vartype.value]?.[operator.value]?.cardinality)
const operators = computed(() => TYPEOPS[vartype.value])
function setVariable (event: Event) {
const target = event.target as HTMLSelectElement
const currentOp = Object.keys(props.rule)[0]
let currentVal = props.rule[currentOp]
if (target.value === 'and' || target.value === 'or') {
if (currentVal[0]?.var) currentVal = []
props.rule[target.value] = currentVal
delete props.rule[currentOp]
} else {
if (currentVal !== 'and' && currentVal !== 'or' && currentVal[0] && VARS[target.value]?.type === vartype.value) {
if (vartype.value === 'int_by_datetime') {
const currentData = props.rule[currentOp][0][variable.value]
props.rule[currentOp][0] = { [target.value]: JSON.parse(JSON.stringify(currentData)) }
} else {
props.rule[currentOp][0].var = target.value
}
} else if (VARS[target.value]?.type === 'int_by_datetime') {
delete props.rule[currentOp]
props.rule['!!'] = [{ [target.value]: [{ buildTime: [null, null] }] }]
} else {
delete props.rule[currentOp]
props.rule['!!'] = [{ var: target.value }]
}
}
}
function setOperator (event: Event) {
const target = event.target as HTMLSelectElement
const currentOp = Object.keys(props.rule)[0]
const currentVal = props.rule[currentOp]
delete props.rule[currentOp]
props.rule[target.value] = currentVal
}
function setRightOperandNumber (event: Event) {
const val = parseInt((event.target as HTMLInputElement).value)
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(val)
} else {
props.rule[operator.value][1] = val
}
}
function setTimeTolerance (event: Event) {
const val = parseInt((event.target as HTMLInputElement).value)
if (props.rule[operator.value].length === 2) {
props.rule[operator.value].push(val)
} else {
props.rule[operator.value][2] = val
}
}
function setTimeType (event: Event) {
const val = (event.target as HTMLSelectElement).value
const time = { buildTime: [val] }
if (vartype.value === 'int_by_datetime') {
props.rule[operator.value][0][variable.value][0] = time
} else {
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(time)
} else {
props.rule[operator.value][1] = time
}
if (val === 'custom') {
props.rule[operator.value][2] = 0
}
}
}
function setTimeValue (val: string) {
if (vartype.value === 'int_by_datetime') {
props.rule[operator.value][0][variable.value][0]['buildTime'][1] = val
} else {
props.rule[operator.value][1]['buildTime'][1] = val
}
}
function setRightOperandProductList (val: { id: any; text: string }[]) {
const products = { objectList: val.map(item => ({ lookup: ['product', item.id, item.text] })) }
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(products)
} else {
props.rule[operator.value][1] = products
}
}
function setRightOperandVariationList (val: { id: any; text: string }[]) {
const products = { objectList: val.map(item => ({ lookup: ['variation', item.id, item.text] })) }
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(products)
} else {
props.rule[operator.value][1] = products
}
}
function setRightOperandGateList (val: { id: any; text: string }[]) {
const products = { objectList: val.map(item => ({ lookup: ['gate', item.id, item.text] })) }
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(products)
} else {
props.rule[operator.value][1] = products
}
}
function setRightOperandEnum (event: Event) {
const val = (event.target as HTMLSelectElement).value
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(val)
} else {
props.rule[operator.value][1] = val
}
}
function addOperand () {
props.rule[operator.value].push({ '': [] })
}
function wrapWithOR () {
const r = JSON.parse(JSON.stringify(props.rule))
delete props.rule[operator.value]
props.rule.or = [r]
}
function wrapWithAND () {
const r = JSON.parse(JSON.stringify(props.rule))
delete props.rule[operator.value]
props.rule.and = [r]
}
function cutOut () {
const cop = Object.keys(operands.value[0])[0]
const r = operands.value[0][cop]
delete props.rule[operator.value]
props.rule[cop] = r
}
function remove () {
emit('remove')
}
function duplicate () {
emit('duplicate')
}
function removeChild (index: number) {
props.rule[operator.value].splice(index, 1)
}
function duplicateChild (index: number) {
const r = JSON.parse(JSON.stringify(props.rule[operator.value][index]))
props.rule[operator.value].splice(index, 0, r)
}
</script>
<template lang="pug">
div(:class="classObject")
.btn-group.pull-right
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="level > 0",
type="button",
data-toggle="tooltip",
:title="TEXTS.duplicate",
@click.prevent="duplicate"
)
span.fa.fa-copy
button.checkin-rule-remove.btn.btn-xs.btn-default(
type="button",
@click.prevent="wrapWithOR"
) OR
button.checkin-rule-remove.btn.btn-xs.btn-default(
type="button",
@click.prevent="wrapWithAND"
) AND
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="operands && operands.length === 1 && (operator === 'or' || operator === 'and')",
type="button",
@click.prevent="cutOut"
)
span.fa.fa-cut
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="level > 0",
type="button",
@click.prevent="remove"
)
span.fa.fa-trash
select.form-control(:value="variable", required, @input="setVariable")
option(value="and") {{ TEXTS.and }}
option(value="or") {{ TEXTS.or }}
option(v-for="(v, name) in VARS", :key="name", :value="name") {{ v.label }}
select.form-control(
v-if="operator !== 'or' && operator !== 'and' && vartype !== 'int_by_datetime'",
:value="operator",
required,
@input="setOperator"
)
option
option(v-for="(v, name) in operators", :key="name", :value="name") {{ v.label }}
select.form-control(
v-if="vartype === 'datetime' || vartype === 'int_by_datetime'",
:value="timeType",
required,
@input="setTimeType"
)
option(value="date_from") {{ TEXTS.date_from }}
option(value="date_to") {{ TEXTS.date_to }}
option(value="date_admission") {{ TEXTS.date_admission }}
option(value="custom") {{ TEXTS.date_custom }}
option(value="customtime") {{ TEXTS.date_customtime }}
Datetimefield(
v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'custom'",
:value="timeValue",
@input="setTimeValue"
)
Timefield(
v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'customtime'",
:value="timeValue",
@input="setTimeValue"
)
input.form-control(
v-if="vartype === 'datetime' && timeType && timeType !== 'customtime' && timeType !== 'custom'",
required,
type="number",
:value="timeTolerance",
:placeholder="TEXTS.date_tolerance",
@input="setTimeTolerance"
)
select.form-control(
v-if="vartype === 'int_by_datetime'",
:value="operator",
required,
@input="setOperator"
)
option
option(v-for="(v, name) in operators", :key="name", :value="name") {{ v.label }}
input.form-control(
v-if="(vartype === 'int' || vartype === 'int_by_datetime') && cardinality > 1",
required,
type="number",
:value="rightoperand",
@input="setRightOperandNumber"
)
LookupSelect2(
v-if="vartype === 'product' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="productSelectURL",
@input="setRightOperandProductList"
)
LookupSelect2(
v-if="vartype === 'variation' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="variationSelectURL",
@input="setRightOperandVariationList"
)
LookupSelect2(
v-if="vartype === 'gate' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="gateSelectURL",
@input="setRightOperandGateList"
)
select.form-control(
v-if="vartype === 'enum_entry_status' && operator === '=='",
required,
:value="rightoperand",
@input="setRightOperandEnum"
)
option(value="absent") {{ TEXTS.status_absent }}
option(value="present") {{ TEXTS.status_present }}
.checkin-rule-childrules(v-if="operator === 'or' || operator === 'and'")
div(v-for="(op, opi) in operands", :key="opi")
CheckinRule(
v-if="typeof op === 'object'",
:rule="op",
:index="opi",
:level="level + 1",
@remove="removeChild(opi)",
@duplicate="duplicateChild(opi)"
)
button.checkin-rule-addchild.btn.btn-xs.btn-default(
type="button",
@click.prevent="addOperand"
)
span.fa.fa-plus-circle
| {{ TEXTS.condition_add }}
</template>

View File

@@ -1,25 +1,23 @@
<template>
<div class="checkin-rules-editor">
<checkin-rule :rule="this.$root.rules" :level="0" :index="0" v-if="hasRules"></checkin-rule>
<button type="button" class="checkin-rule-addchild btn btn-xs btn-default" v-if="!hasRules"
@click.prevent="addRule"><span class="fa fa-plus-circle"></span> {{ this.$root.texts.condition_add }}
</button>
</div>
</template>
<script>
export default {
components: {
CheckinRule: CheckinRule.default,
},
computed: {
hasRules: function () {
return !!Object.keys(this.$root.rules).length;
}
},
methods: {
addRule: function () {
this.$set(this.$root.rules, "and", []);
},
},
}
<script setup lang="ts">
import { computed } from 'vue'
import { TEXTS } from './constants'
import { rules } from './django-interop'
import CheckinRule from './checkin-rule.vue'
const hasRules = computed(() => !!Object.keys(rules.value).length)
function addRule () {
rules.value.and = []
}
</script>
<template lang="pug">
.checkin-rules-editor
CheckinRule(v-if="hasRules", :rule="rules", :level="0", :index="0")
button.checkin-rule-addchild.btn.btn-xs.btn-default(
v-if="!hasRules",
type="button",
@click.prevent="addRule"
)
span.fa.fa-plus-circle
| {{ TEXTS.condition_add }}
</template>

View File

@@ -1,255 +1,276 @@
<template>
<div :class="'checkin-rules-visualization ' + (maximized ? 'maximized' : '')">
<div class="tools">
<button v-if="maximized" class="btn btn-default" type="button" @click.prevent="maximized = false"><span class="fa fa-window-close"></span></button>
<button v-if="!maximized" class="btn btn-default" type="button" @click.prevent="maximized = true"><span class="fa fa-window-maximize"></span></button>
</div>
<svg :width="graph.columns * (boxWidth + marginX) + 2 * paddingX" :height="graph.height * (boxHeight + marginY)"
:viewBox="viewBox" ref="svg">
<g :transform="zoomTransform.toString()">
<viz-node v-for="(node, nodeid) in graph.nodes_by_id" :key="nodeid" :node="node"
:children="node.children.map(n => graph.nodes_by_id[n])" :nodeid="nodeid"
:boxWidth="boxWidth" :boxHeight="boxHeight" :marginX="marginX" :marginY="marginY"
:paddingX="paddingX"></viz-node>
</g>
</svg>
</div>
</template>
<script>
export default {
components: {
VizNode: VizNode.default,
},
computed: {
boxWidth() {
return 300
},
boxHeight() {
return 62
},
paddingX() {
return 50
},
marginX() {
return 50
},
marginY() {
return 20
},
contentWidth() {
return this.graph.columns * (this.boxWidth + this.marginX) + 2 * this.paddingX
},
contentHeight() {
return this.graph.height * (this.boxHeight + this.marginY)
},
viewBox() {
return `0 0 ${this.contentWidth} ${this.contentHeight}`
},
graph() {
/**
* Converts a JSON logic rule into a "flow chart".
*
* A JSON logic rule has a structure like an operator tree:
*
* OR
* |-- AND
* |-- A
* |-- B
* |-- AND
* |-- OR
* |-- C
* |-- D
* |-- E
*
* For our visualization, we want to visualize that tree as a graph one can follow along to reach a
* decision, which has the structure of a directed graph:
*
* --- A --- B --- OK!
* /
* /
* /
* --
* \
* \ --- C ---
* \ / \
* --- --- E --- OK!
* \ /
* --- D ---
*/
const graph = {
nodes_by_id: {},
children: [],
columns: -1,
}
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { rules } from './django-interop'
import VizNode from './viz-node.vue'
// Step 1: Start building the graph by finding all nodes and edges
let counter = 0;
const _add_to_graph = (rule) => { // returns [heads, tails]
if (typeof rule !== 'object' || rule === null) {
const node_id = (counter++).toString()
graph.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
declare const d3: any
const operator = Object.keys(rule)[0]
const operands = rule[operator]
const svg = ref<SVGSVGElement | null>(null)
const maximized = ref(false)
const zoom = ref<any>(null)
const defaultScale = ref(1)
const zoomTransform = ref(d3.zoomTransform({ k: 1, x: 0, y: 0 }))
if (operator === "and") {
let children = []
let tails = null
operands.reverse()
for (let operand of operands) {
let [new_children, new_tails] = _add_to_graph(operand)
for (let new_child of new_tails) {
graph.nodes_by_id[new_child].children.push(...children)
for (let c of children) {
graph.nodes_by_id[c].parent = graph.nodes_by_id[new_child]
}
}
if (tails === null) {
tails = new_tails
}
children = new_children
}
return [children, tails]
} else if (operator === "or") {
const children = []
const tails = []
for (let operand of operands) {
let [new_children, new_tails] = _add_to_graph(operand)
children.push(...new_children)
tails.push(...new_tails)
}
return [children, tails]
} else {
const node_id = (counter++).toString()
graph.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
const boxWidth = 300
const boxHeight = 62
const paddingX = 50
const marginX = 50
const marginY = 20
}
graph.children = _add_to_graph(JSON.parse(JSON.stringify(this.$root.rules)))[0]
// Step 2: We compute the "column" of every node, which is the maximum number of hops required to reach the
// node from the root node
const _set_column_to_min = (nodes, mincol) => {
for (let node of nodes) {
if (mincol > node.column) {
node.column = mincol
graph.columns = Math.max(mincol + 1, graph.columns)
_set_column_to_min(node.children.map(nid => graph.nodes_by_id[nid]), mincol + 1)
}
}
}
_set_column_to_min(graph.children.map(nid => graph.nodes_by_id[nid]), 0)
// Step 3: Align each node on a grid. The x position is already given by the column computed above, but we still
// need the y position. This part of the algorithm is opinionated and probably not yet the nicest solution we
// can use!
const _set_y = (node, offset) => {
if (typeof node.y === "undefined") {
// We only take the first value we found for each node
node.y = offset
}
let used = 0
for (let cid of node.children) {
used += Math.max(0, _set_y(graph.nodes_by_id[cid], offset + used) - 1)
used++
}
return used
}
_set_y(graph, 0)
// Step 4: Compute the "height" of the graph by looking at the node with the highest y value
graph.height = 1
for (let node of [...Object.values(graph.nodes_by_id)]) {
graph.height = Math.max(graph.height, node.y + 1)
}
return graph
}
},
mounted() {
this.createZoom()
},
created() {
window.addEventListener('resize', this.createZoom)
},
destroyed() {
window.removeEventListener('resize', this.createZoom)
},
watch: {
maximized() {
this.$nextTick(() => {
this.createZoom()
})
}
},
methods: {
createZoom() {
if (!this.$refs.svg) return
const viewportHeight = this.$refs.svg.clientHeight
const viewportWidth = this.$refs.svg.clientWidth
this.defaultScale = 1
this.zoom = d3
.zoom()
.scaleExtent([Math.min(this.defaultScale * 0.5, 1), Math.max(5, this.contentHeight / viewportHeight, this.contentWidth / viewportWidth)])
.extent([[0, 0], [viewportWidth, viewportHeight]])
.filter(event => {
const wheeled = event.type === 'wheel'
const mouseDrag =
event.type === 'mousedown' ||
event.type === 'mouseup' ||
event.type === 'mousemove'
const touch =
event.type === 'touchstart' ||
event.type === 'touchmove' ||
event.type === 'touchstop'
return (wheeled || mouseDrag || touch) && this.maximized
})
.wheelDelta(event => {
// In contrast to default implementation, do not use a factor 10 if ctrl is pressed
return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002)
})
.on('zoom', (event) => {
this.zoomTransform = event.transform
})
const initTransform = d3.zoomIdentity
.scale(this.defaultScale)
.translate(
0,
0
)
this.zoomTransform = initTransform
// This sets correct d3 internal state for the initial centering
d3.select(this.$refs.svg)
.call(this.zoom.transform, initTransform)
const svg = d3.select(this.$refs.svg).call(this.zoom)
svg.on('touchmove.zoom', null)
// TODO touch support
},
},
data() {
return {
maximized: false,
zoom: null,
defaultScale: 1,
zoomTransform: d3.zoomTransform({k: 1, x: 0, y: 0}),
}
}
interface GraphNode {
rule: any
column: number
children: string[]
y?: number
parent?: GraphNode
}
interface Graph {
nodes_by_id: Record<string, GraphNode>
children: string[]
columns: number
height: number
y?: number
}
const graph = computed<Graph>(() => {
/**
* Converts a JSON logic rule into a "flow chart".
*
* A JSON logic rule has a structure like an operator tree:
*
* OR
* |-- AND
* |-- A
* |-- B
* |-- AND
* |-- OR
* |-- C
* |-- D
* |-- E
*
* For our visualization, we want to visualize that tree as a graph one can follow along to reach a
* decision, which has the structure of a directed graph:
*
* --- A --- B --- OK!
* /
* /
* /
* --
* \
* \ --- C ---
* \ / \
* --- --- E --- OK!
* \ /
* --- D ---
*/
const graphData: Graph = {
nodes_by_id: {},
children: [],
columns: -1,
height: 1,
}
// Step 1: Start building the graph by finding all nodes and edges
let counter = 0
const _add_to_graph = (rule: any): [string[], string[]] => { // returns [heads, tails]
if (typeof rule !== 'object' || rule === null) {
const node_id = (counter++).toString()
graphData.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
const operator = Object.keys(rule)[0]
const operands = rule[operator]
if (operator === 'and') {
let children: string[] = []
let tails: string[] | null = null
operands.reverse()
for (const operand of operands) {
const [new_children, new_tails] = _add_to_graph(operand)
for (const new_child of new_tails) {
graphData.nodes_by_id[new_child].children.push(...children)
for (const c of children) {
graphData.nodes_by_id[c].parent = graphData.nodes_by_id[new_child]
}
}
if (tails === null) {
tails = new_tails
}
children = new_children
}
return [children, tails!]
} else if (operator === 'or') {
const children: string[] = []
const tails: string[] = []
for (const operand of operands) {
const [new_children, new_tails] = _add_to_graph(operand)
children.push(...new_children)
tails.push(...new_tails)
}
return [children, tails]
} else {
const node_id = (counter++).toString()
graphData.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
}
graphData.children = _add_to_graph(JSON.parse(JSON.stringify(rules.value)))[0]
// Step 2: We compute the "column" of every node, which is the maximum number of hops required to reach the
// node from the root node
const _set_column_to_min = (nodes: GraphNode[], mincol: number) => {
for (const node of nodes) {
if (mincol > node.column) {
node.column = mincol
graphData.columns = Math.max(mincol + 1, graphData.columns)
_set_column_to_min(node.children.map(nid => graphData.nodes_by_id[nid]), mincol + 1)
}
}
}
_set_column_to_min(graphData.children.map(nid => graphData.nodes_by_id[nid]), 0)
// Step 3: Align each node on a grid. The x position is already given by the column computed above, but we still
// need the y position. This part of the algorithm is opinionated and probably not yet the nicest solution we
// can use!
const _set_y = (node: Graph | GraphNode, offset: number): number => {
if (typeof node.y === 'undefined') {
// We only take the first value we found for each node
node.y = offset
}
let used = 0
for (const cid of node.children) {
used += Math.max(0, _set_y(graphData.nodes_by_id[cid], offset + used) - 1)
used++
}
return used
}
_set_y(graphData, 0)
// Step 4: Compute the "height" of the graph by looking at the node with the highest y value
graphData.height = 1
for (const node of [...Object.values(graphData.nodes_by_id)]) {
graphData.height = Math.max(graphData.height, (node.y ?? 0) + 1)
}
return graphData
})
const contentWidth = computed(() => {
return graph.value.columns * (boxWidth + marginX) + 2 * paddingX
})
const contentHeight = computed(() => {
return graph.value.height * (boxHeight + marginY)
})
const viewBox = computed(() => {
return `0 0 ${contentWidth.value} ${contentHeight.value}`
})
function createZoom () {
if (!svg.value) return
const viewportHeight = svg.value.clientHeight
const viewportWidth = svg.value.clientWidth
defaultScale.value = 1
zoom.value = d3
.zoom()
.scaleExtent([Math.min(defaultScale.value * 0.5, 1), Math.max(5, contentHeight.value / viewportHeight, contentWidth.value / viewportWidth)])
.extent([[0, 0], [viewportWidth, viewportHeight]])
.filter((event: any) => {
const wheeled = event.type === 'wheel'
const mouseDrag
= event.type === 'mousedown'
|| event.type === 'mouseup'
|| event.type === 'mousemove'
const touch
= event.type === 'touchstart'
|| event.type === 'touchmove'
|| event.type === 'touchstop'
return (wheeled || mouseDrag || touch) && maximized.value
})
.wheelDelta((event: any) => {
// In contrast to default implementation, do not use a factor 10 if ctrl is pressed
return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002)
})
.on('zoom', (event: any) => {
zoomTransform.value = event.transform
})
const initTransform = d3.zoomIdentity
.scale(defaultScale.value)
.translate(0, 0)
zoomTransform.value = initTransform
// This sets correct d3 internal state for the initial centering
d3.select(svg.value)
.call(zoom.value.transform, initTransform)
const svgSelection = d3.select(svg.value).call(zoom.value)
svgSelection.on('touchmove.zoom', null)
// TODO touch support
}
watch(maximized, () => {
nextTick(() => {
createZoom()
})
})
onMounted(() => {
createZoom()
window.addEventListener('resize', createZoom)
})
onUnmounted(() => {
window.removeEventListener('resize', createZoom)
})
</script>
<template lang="pug">
div(:class="'checkin-rules-visualization ' + (maximized ? 'maximized' : '')")
.tools
button.btn.btn-default(
v-if="maximized",
type="button",
@click.prevent="maximized = false"
)
span.fa.fa-window-close
button.btn.btn-default(
v-if="!maximized",
type="button",
@click.prevent="maximized = true"
)
span.fa.fa-window-maximize
svg(
ref="svg",
:width="contentWidth",
:height="contentHeight",
:viewBox="viewBox"
)
g(:transform="zoomTransform.toString()")
VizNode(
v-for="(node, nodeid) in graph.nodes_by_id",
:key="nodeid",
:node="node",
:children="node.children.map((n: string) => graph.nodes_by_id[n])",
:nodeid="nodeid",
:boxWidth="boxWidth",
:boxHeight="boxHeight",
:marginX="marginX",
:marginY="marginY",
:paddingX="paddingX"
)
</template>

View File

@@ -0,0 +1,193 @@
/* global gettext, pgettext */
export const TEXTS = {
and: gettext('All of the conditions below (AND)'),
or: gettext('At least one of the conditions below (OR)'),
date_from: gettext('Event start'),
date_to: gettext('Event end'),
date_admission: gettext('Event admission'),
date_custom: gettext('custom date and time'),
date_customtime: gettext('custom time'),
date_tolerance: gettext('Tolerance (minutes)'),
condition_add: gettext('Add condition'),
minutes: gettext('minutes'),
duplicate: gettext('Duplicate'),
status_present: pgettext('entry_status', 'present'),
status_absent: pgettext('entry_status', 'absent'),
}
export const TYPEOPS = {
// Every change to our supported JSON logic must be done
// * in pretix.base.services.checkin
// * in pretix.base.models.checkin
// * in pretix.helpers.jsonlogic_boolalg
// * in checkinrules.js
// * in libpretixsync
// * in pretixscan-ios
product: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
variation: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
gate: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
datetime: {
isBefore: {
label: gettext('is before'),
cardinality: 2,
},
isAfter: {
label: gettext('is after'),
cardinality: 2,
},
},
enum_entry_status: {
'==': {
label: gettext('='),
cardinality: 2,
},
},
int_by_datetime: {
'<': {
label: '<',
cardinality: 2,
},
'<=': {
label: '≤',
cardinality: 2,
},
'>': {
label: '>',
cardinality: 2,
},
'>=': {
label: '≥',
cardinality: 2,
},
'==': {
label: '=',
cardinality: 2,
},
'!=': {
label: '≠',
cardinality: 2,
},
},
int: {
'<': {
label: '<',
cardinality: 2,
},
'<=': {
label: '≤',
cardinality: 2,
},
'>': {
label: '>',
cardinality: 2,
},
'>=': {
label: '≥',
cardinality: 2,
},
'==': {
label: '=',
cardinality: 2,
},
'!=': {
label: '≠',
cardinality: 2,
},
},
}
export const VARS = {
product: {
label: gettext('Product'),
type: 'product',
},
variation: {
label: gettext('Product variation'),
type: 'variation',
},
gate: {
label: gettext('Gate'),
type: 'gate',
},
now: {
label: gettext('Current date and time'),
type: 'datetime',
},
now_isoweekday: {
label: gettext('Current day of the week (1 = Monday, 7 = Sunday)'),
type: 'int',
},
entry_status: {
label: gettext('Current entry status'),
type: 'enum_entry_status',
},
entries_number: {
label: gettext('Number of previous entries'),
type: 'int',
},
entries_today: {
label: gettext('Number of previous entries since midnight'),
type: 'int',
},
entries_since: {
label: gettext('Number of previous entries since'),
type: 'int_by_datetime',
},
entries_before: {
label: gettext('Number of previous entries before'),
type: 'int_by_datetime',
},
entries_days: {
label: gettext('Number of days with a previous entry'),
type: 'int',
},
entries_days_since: {
label: gettext('Number of days with a previous entry since'),
type: 'int_by_datetime',
},
entries_days_before: {
label: gettext('Number of days with a previous entry before'),
type: 'int_by_datetime',
},
minutes_since_last_entry: {
label: gettext('Minutes since last entry (-1 on first entry)'),
type: 'int',
},
minutes_since_first_entry: {
label: gettext('Minutes since first entry (-1 on first entry)'),
type: 'int',
},
}
export const DATETIME_OPTIONS = {
format: document.body.dataset.datetimeformat,
locale: document.body.dataset.datetimelocale,
useCurrent: false,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
}

View File

@@ -1,55 +1,45 @@
<template>
<input class="form-control">
</template>
<script>
export default {
props: ["required", "value"],
template: (''),
mounted: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.datetimepicker(this.opts())
.trigger("change")
.on("dp.change", function (e) {
vm.$emit("input", $(this).data('DateTimePicker').date().toISOString());
});
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value));
}
},
methods: {
opts: function () {
return {
format: $("body").attr("data-datetimeformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: this.required,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { DATETIME_OPTIONS } from './constants'
const props = defineProps<{
required?: boolean
value?: string
}>()
const emit = defineEmits<{
input: [value: string]
}>()
const input = ref<HTMLInputElement | null>(null)
watch(() => props.value, (val) => {
$(input.value).data('DateTimePicker').date(moment(val))
})
onMounted(() => {
$(input.value)
.datetimepicker({
...DATETIME_OPTIONS,
showClear: props.required,
})
.trigger('change')
.on('dp.change', function (this: HTMLElement) {
emit('input', $(this).data('DateTimePicker').date().toISOString())
})
if (!props.value) {
$(input.value).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
} else {
$(input.value).data('DateTimePicker').date(moment(props.value))
}
})
onUnmounted(() => {
$(input.value)
.off()
.datetimepicker('destroy')
})
</script>
<template lang="pug">
input.form-control(ref="input")
</template>

View File

@@ -0,0 +1,68 @@
import { ref, watch } from 'vue'
export const allProducts = ref(false)
export const limitProducts = ref<number[]>([])
function updateProducts () {
allProducts.value = document.querySelector<HTMLInputElement>('#id_all_products')?.checked ?? false
limitProducts.value = Array.from(document.querySelectorAll<HTMLInputElement>('input[name=limit_products]:checked')).map(el => parseInt(el.value))
}
// listen to change events for products
document.querySelectorAll('#id_all_products, input[name=limit_products]').forEach(el => el.addEventListener('change', updateProducts))
updateProducts()
export const rules = ref<any>({})
// grab rules from hidden input
const rulesInput = document.querySelector<HTMLInputElement>('#id_rules')
if (rulesInput?.value) {
rules.value = JSON.parse(rulesInput.value)
}
// sync back to hidden input
watch(rules, (newVal) => {
if (!rulesInput) return
rulesInput.value = JSON.stringify(newVal)
}, { deep: true })
export const items = ref<any[]>([])
const itemsEl = document.querySelector('#items')
if (itemsEl?.textContent) {
items.value = JSON.parse(itemsEl.textContent || '[]')
function checkForInvalidIds (validProducts: Record<string, string>, validVariations: Record<string, string>, rule: any) {
if (rule['and']) {
for (const child of rule['and'])
checkForInvalidIds(validProducts, validVariations, child)
} else if (rule['or']) {
for (const child of rule['or'])
checkForInvalidIds(validProducts, validVariations, child)
} else if (rule['inList'] && rule['inList'][0]['var'] === 'product') {
for (const item of rule['inList'][1]['objectList']) {
if (!validProducts[item['lookup'][1]])
item['lookup'][2] = '[' + gettext('Error: Product not found!') + ']'
else
item['lookup'][2] = validProducts[item['lookup'][1]]
}
} else if (rule['inList'] && rule['inList'][0]['var'] === 'variation') {
for (const item of rule['inList'][1]['objectList']) {
if (!validVariations[item['lookup'][1]])
item['lookup'][2] = '[' + gettext('Error: Variation not found!') + ']'
else
item['lookup'][2] = validVariations[item['lookup'][1]]
}
}
}
checkForInvalidIds(
Object.fromEntries(items.value.map(p => [p.id, p.name])),
Object.fromEntries(items.value.flatMap(p => p.variations?.map(v => [v.id, p.name + ' ' + v.name]) ?? [])),
rules.value
)
}
export const productSelectURL = ref(document.querySelector('#product-select2')?.textContent)
export const variationSelectURL = ref(document.querySelector('#variations-select2')?.textContent)
export const gateSelectURL = ref(document.querySelector('#gate-select2')?.textContent)

View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#rules-editor')
app.config.errorHandler = (error, _vm, info) => {
// vue fatals on errors by default, which is a weird choice
// https://github.com/vuejs/core/issues/3525
// https://github.com/vuejs/router/discussions/2435
console.error('[VUE]', info, error)
}

View File

@@ -1,93 +1,93 @@
function convert_to_dnf(rules) {
// Converts a set of rules to disjunctive normal form, i.e. returns something of the form
// `(a AND b AND c) OR (a AND d AND f)`
// without further nesting.
if (typeof rules !== "object" || Array.isArray(rules) || rules === null) {
return rules
}
export function convertToDNF (rules) {
// Converts a set of rules to disjunctive normal form, i.e. returns something of the form
// `(a AND b AND c) OR (a AND d AND f)`
// without further nesting.
if (typeof rules !== 'object' || Array.isArray(rules) || rules === null) {
return rules
}
function _distribute_or_over_and(r) {
var operator = Object.keys(r)[0]
var values = r[operator]
if (operator === "and") {
var arg_to_distribute = null
var other_args = []
for (var arg of values) {
if (typeof arg === "object" && !Array.isArray(arg) && typeof arg["or"] !== "undefined" && arg_to_distribute === null) {
arg_to_distribute = arg
} else {
other_args.push(arg)
}
}
if (arg_to_distribute === null) {
return r
}
var or_operands = []
for (var dval of arg_to_distribute["or"]) {
or_operands.push({"and": other_args.concat([dval])})
}
return {
"or": or_operands
}
} else if (!operator) {
return r
} else if (operator === "!" || operator === "!!" || operator === "?:" || operator === "if") {
console.warn("Operator " + operator + " currently unsupported by convert_to_dnf")
return r
} else {
return r
}
}
function _distribute_or_over_and (r) {
let operator = Object.keys(r)[0]
let values = r[operator]
if (operator === 'and') {
let arg_to_distribute = null
let other_args = []
for (let arg of values) {
if (typeof arg === 'object' && !Array.isArray(arg) && typeof arg['or'] !== 'undefined' && arg_to_distribute === null) {
arg_to_distribute = arg
} else {
other_args.push(arg)
}
}
if (arg_to_distribute === null) {
return r
}
let or_operands = []
for (let dval of arg_to_distribute['or']) {
or_operands.push({ and: other_args.concat([dval]) })
}
return {
or: or_operands
}
} else if (!operator) {
return r
} else if (operator === '!' || operator === '!!' || operator === '?:' || operator === 'if') {
console.warn('Operator ' + operator + ' currently unsupported by convert_to_dnf')
return r
} else {
return r
}
}
function _simplify_chained_operators(r) {
// Simplify `(a OR b) OR (c or d)` to `a OR b OR c OR d` and the same with `AND`
if (typeof r !== "object" || Array.isArray(r)) {
return r
}
var operator = Object.keys(r)[0]
var values = r[operator]
if (operator !== "or" && operator !== "and") {
return r
}
var new_values = []
for (var v of values) {
if (typeof v !== "object" || Array.isArray(v) || typeof v[operator] === "undefined") {
new_values.push(v)
} else {
new_values.push(...v[operator])
}
}
var result = {}
result[operator] = new_values
return result
}
function _simplify_chained_operators (r) {
// Simplify `(a OR b) OR (c or d)` to `a OR b OR c OR d` and the same with `AND`
if (typeof r !== 'object' || Array.isArray(r)) {
return r
}
let operator = Object.keys(r)[0]
let values = r[operator]
if (operator !== 'or' && operator !== 'and') {
return r
}
let new_values = []
for (let v of values) {
if (typeof v !== 'object' || Array.isArray(v) || typeof v[operator] === 'undefined') {
new_values.push(v)
} else {
new_values.push(...v[operator])
}
}
let result = {}
result[operator] = new_values
return result
}
// Run _distribute_or_over_and on until it no longer changes anything. Do so recursively
// for the full expression tree.
var old_rules = rules
while (true) {
rules = _distribute_or_over_and(rules)
var operator = Object.keys(rules)[0]
var values = rules[operator]
var no_list = false
if (!Array.isArray(values)) {
values = [values]
no_list = true
}
rules = {}
if (!no_list) {
rules[operator] = []
for (var v of values) {
rules[operator].push(convert_to_dnf(v))
}
} else {
rules[operator] = convert_to_dnf(values[0])
}
if (JSON.stringify(old_rules) === JSON.stringify(rules)) { // Let's hope this is good enough...
break
}
old_rules = rules
}
rules = _simplify_chained_operators(rules)
return rules
}
// Run _distribute_or_over_and on until it no longer changes anything. Do so recursively
// for the full expression tree.
let old_rules = rules
while (true) {
rules = _distribute_or_over_and(rules)
let operator = Object.keys(rules)[0]
let values = rules[operator]
let no_list = false
if (!Array.isArray(values)) {
values = [values]
no_list = true
}
rules = {}
if (!no_list) {
rules[operator] = []
for (let v of values) {
rules[operator].push(convertToDNF(v))
}
} else {
rules[operator] = convertToDNF(values[0])
}
if (JSON.stringify(old_rules) === JSON.stringify(rules)) { // Let's hope this is good enough...
break
}
old_rules = rules
}
rules = _simplify_chained_operators(rules)
return rules
}

View File

@@ -1,97 +1,116 @@
<template>
<select>
<slot></slot>
</select>
</template>
<script>
export default {
props: ["required", "value", "placeholder", "url", "multiple"],
template: ('<select>\n' +
' <slot></slot>\n' +
' </select>'),
mounted: function () {
this.build();
},
methods: {
build: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.empty()
.select2(this.opts())
.val(this.value || "")
.trigger("change")
// emit event on change.
.on("change", function (e) {
vm.$emit("input", $(this).select2('data'));
});
if (vm.value) {
for (var i = 0; i < vm.value["objectList"].length; i++) {
var option = new Option(vm.value["objectList"][i]["lookup"][2], vm.value["objectList"][i]["lookup"][1], true, true);
$(vm.$el).append(option);
}
}
$(vm.$el).trigger("change");
},
opts: function () {
return {
theme: "bootstrap",
delay: 100,
width: '100%',
multiple: true,
allowClear: this.required,
language: $("body").attr("data-select2-locale"),
ajax: {
url: this.url,
data: function (params) {
return {
query: params.term,
page: params.page || 1
}
}
},
templateResult: function (res) {
if (!res.id) {
return res.text;
}
var $ret = $("<span>").append(
$("<span>").addClass("primary").append($("<div>").text(res.text).html())
);
return $ret;
},
};
}
},
watch: {
placeholder: function (val) {
$(this.$el).select2("destroy");
this.build();
},
required: function (val) {
$(this.$el).select2("destroy");
this.build();
},
url: function (val) {
$(this.$el).select2("destroy");
this.build();
},
value: function (newval, oldval) {
if (JSON.stringify(newval) !== JSON.stringify(oldval)) {
$(this.$el).empty();
if (newval) {
for (var i = 0; i < newval["objectList"].length; i++) {
var option = new Option(newval["objectList"][i]["lookup"][2], newval["objectList"][i]["lookup"][1], true, true);
$(this.$el).append(option);
}
}
$(this.$el).trigger("change");
}
},
},
destroyed: function () {
$(this.$el)
.off()
.select2("destroy");
}
}
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
declare const $: any
export interface ObjectListItem {
lookup: [string, number | string, string]
}
export interface ObjectList {
objectList: ObjectListItem[]
}
const props = defineProps<{
required?: boolean
value?: ObjectList
placeholder?: string
url?: string
multiple?: boolean
}>()
const emit = defineEmits<{
input: [value: any[]]
}>()
const select = ref<HTMLSelectElement | null>(null)
function opts () {
return {
theme: 'bootstrap',
delay: 100,
width: '100%',
multiple: true,
allowClear: props.required,
language: $('body').attr('data-select2-locale'),
ajax: {
url: props.url,
data: function (params: { term: string; page?: number }) {
return {
query: params.term,
page: params.page || 1
}
}
},
templateResult: function (res: { id?: string; text: string }) {
if (!res.id) {
return res.text
}
const $ret = $('<span>').append(
$('<span>').addClass('primary').append($('<div>').text(res.text).html())
)
return $ret
},
}
}
function build () {
$(select.value)
.empty()
.select2(opts())
.val(props.value || '')
.trigger('change')
.on('change', function (this: HTMLElement) {
emit('input', $(this).select2('data'))
})
if (props.value) {
for (let i = 0; i < props.value.objectList.length; i++) {
const option = new Option(props.value.objectList[i].lookup[2], String(props.value.objectList[i].lookup[1]), true, true)
$(select.value).append(option)
}
}
$(select.value).trigger('change')
}
watch(() => props.placeholder, () => {
$(select.value).select2('destroy')
build()
})
watch(() => props.required, () => {
$(select.value).select2('destroy')
build()
})
watch(() => props.url, () => {
$(select.value).select2('destroy')
build()
})
watch(() => props.value, (newval, oldval) => {
if (JSON.stringify(newval) !== JSON.stringify(oldval)) {
$(select.value).empty()
if (newval) {
for (let i = 0; i < newval.objectList.length; i++) {
const option = new Option(newval.objectList[i].lookup[2], String(newval.objectList[i].lookup[1]), true, true)
$(select.value).append(option)
}
}
$(select.value).trigger('change')
}
})
onMounted(() => {
build()
})
onUnmounted(() => {
$(select.value)
.off()
.select2('destroy')
})
</script>
<template lang="pug">
select(ref="select")
slot
</template>

View File

@@ -1,55 +1,45 @@
<template>
<input class="form-control">
</template>
<script>
export default {
props: ["required", "value"],
template: (''),
mounted: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.datetimepicker(this.opts())
.trigger("change")
.on("dp.change", function (e) {
vm.$emit("input", $(this).data('DateTimePicker').date().format("HH:mm:ss"));
});
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(this.$el).data("DateTimePicker").date(vm.value);
}
},
methods: {
opts: function () {
return {
format: $("body").attr("data-timeformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: this.required,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(val);
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { DATETIME_OPTIONS } from './constants'
const props = defineProps<{
required?: boolean
value?: string
}>()
const emit = defineEmits<{
input: [value: string]
}>()
const input = ref<HTMLInputElement | null>(null)
watch(() => props.value, (val) => {
$(input.value).data('DateTimePicker').date(val)
})
onMounted(() => {
$(input.value)
.datetimepicker({
...DATETIME_OPTIONS,
showClear: props.required,
})
.trigger('change')
.on('dp.change', function (this: HTMLElement) {
emit('input', $(this).data('DateTimePicker').date().format('HH:mm:ss'))
})
if (!props.value) {
$(input.value).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
} else {
$(input.value).data('DateTimePicker').date(props.value)
}
})
onUnmounted(() => {
$(input.value)
.off()
.datetimepicker('destroy')
})
</script>
<template lang="pug">
input.form-control(ref="input")
</template>

View File

@@ -1,255 +1,242 @@
<template>
<g>
<path v-for="e in edges" :d="e" class="edge"></path>
<path v-if="rootEdge" :d="rootEdge" class="edge"></path>
<path v-if="!node.children.length" :d="checkEdge" class="edge"></path>
<rect :width="boxWidth" :height="boxHeight" :x="x" :y="y" :class="nodeClass" rx="5">
</rect>
<script setup lang="ts">
import { computed } from 'vue'
import { TEXTS, VARS, TYPEOPS } from './constants'
<foreignObject :width="boxWidth - 10" :height="boxHeight - 10" :x="x + 5" :y="y + 5">
<div xmlns="http://www.w3.org/1999/xhtml" class="text">
<span v-if="vardata && vardata.type === 'int'">
<span v-if="variable.startsWith('entries_')" class="fa fa-sign-in"></span>
{{ vardata.label }}
<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
<span v-else-if="vardata && vardata.type === 'int_by_datetime'">
<span v-if="variable.startsWith('entries_')" class="fa fa-sign-in"></span>
{{ vardata.label }}
<span v-if="node.rule[operator][0][variable][0].buildTime[0] === 'custom'">
{{ df(node.rule[operator][0][variable][0].buildTime[1]) }}
</span>
<span v-else-if="node.rule[operator][0][variable][0].buildTime[0] === 'customtime'">
{{ tf(node.rule[operator][0][variable][0].buildTime[1]) }}
</span>
<span v-else>
{{ this.$root.texts[node.rule[operator][0][variable][0].buildTime[0]] }}
</span>
<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
<span v-else-if="vardata && variable === 'now'">
<span class="fa fa-clock-o"></span> {{ vardata.label }}<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }}<br>
<span v-if="rightoperand.buildTime[0] === 'custom'">
{{ df(rightoperand.buildTime[1]) }}
</span>
<span v-else-if="rightoperand.buildTime[0] === 'customtime'">
{{ tf(rightoperand.buildTime[1]) }}
</span>
<span v-else>
{{ this.$root.texts[rightoperand.buildTime[0]] }}
</span>
<span v-if="operands[2]">
<span v-if="operator === 'isBefore'">+</span>
<span v-else>-</span>
{{ operands[2] }}
{{ this.$root.texts.minutes }}
</span>
</strong>
</span>
<span v-else-if="vardata && operator === 'inList'">
<span class="fa fa-sign-in" v-if="variable === 'gate'"></span>
<span class="fa fa-ticket" v-else></span>
{{ vardata.label }}
<span v-if="varresult !== null">
({{varresult}})
</span>
<br>
<strong>
{{ rightoperand.objectList.map((o) => o.lookup[2]).join(", ") }}
</strong>
</span>
<span v-else-if="vardata && vardata.type === 'enum_entry_status'">
<span class="fa fa-check-circle-o"></span>
{{ vardata.label }}
<span v-if="varresult !== null">
({{varresult}})
</span>
<br>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
</div>
</foreignObject>
declare const $: any
declare const moment: any
<g v-if="result === false" :transform="`translate(${x + boxWidth - 15}, ${y - 10})`">
<ellipse fill="#fff" cx="14.685823" cy="14.318233" rx="12.140151" ry="11.55523" />
<path d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z"
class="error" />
</g>
<g v-if="result === true" :transform="`translate(${x + boxWidth - 15}, ${y - 10})`">
<ellipse fill="#fff" cx="14.685823" cy="14.318233" rx="12.140151" ry="11.55523" />
<path d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z"
class="check"/>
</g>
<g v-if="!node.children.length && (resultInclParents === null || resultInclParents === true)" :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`">
<path d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z"
class="check"/>
</g>
<g v-if="!node.children.length && (resultInclParents === false)" :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`">
<path d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z"
class="error" />
</g>
</g>
</template>
<script>
export default {
interface GraphNode {
rule: any
column: number
children: string[]
y: number
parent?: GraphNode
}
props: {
node: Object,
nodeid: String,
children: Array,
boxWidth: Number,
boxHeight: Number,
marginX: Number,
marginY: Number,
paddingX: Number,
},
computed: {
x() {
return this.node.column * (this.boxWidth + this.marginX) + this.marginX / 2 + this.paddingX
},
y() {
return this.node.y * (this.boxHeight + this.marginY) + this.marginY / 2
},
edges() {
const startX = this.x + this.boxWidth + 1
const startY = this.y + this.boxHeight / 2
return this.children.map((c) => {
const endX = (c.column * (this.boxWidth + this.marginX) + this.marginX / 2 + this.paddingX) - 1
const endY = (c.y * (this.boxHeight + this.marginY) + this.marginY / 2) + this.boxHeight / 2
const props = defineProps<{
node: GraphNode
nodeid: string
children: GraphNode[]
boxWidth: number
boxHeight: number
marginX: number
marginY: number
paddingX: number
}>()
return `
const x = computed(() => {
return props.node.column * (props.boxWidth + props.marginX) + props.marginX / 2 + props.paddingX
})
const y = computed(() => {
return props.node.y * (props.boxHeight + props.marginY) + props.marginY / 2
})
const edges = computed(() => {
const startX = x.value + props.boxWidth + 1
const startY = y.value + props.boxHeight / 2
return props.children.map((c) => {
const endX = (c.column * (props.boxWidth + props.marginX) + props.marginX / 2 + props.paddingX) - 1
const endY = (c.y * (props.boxHeight + props.marginY) + props.marginY / 2) + props.boxHeight / 2
return `
M ${startX} ${startY}
L ${endX - 50} ${startY}
C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)}
L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)}
C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY}
`
})
},
checkEdge() {
const startX = this.x + this.boxWidth + 1
const startY = this.y + this.boxHeight / 2
})
})
return `M ${startX} ${startY} L ${startX + 25} ${startY}`
},
rootEdge() {
if (this.node.column > 0) {
return
}
const startX = 0
const startY = this.boxHeight / 2 + this.marginY / 2
const endX = this.x - 1
const endY = this.y + this.boxHeight / 2
const checkEdge = computed(() => {
const startX = x.value + props.boxWidth + 1
const startY = y.value + props.boxHeight / 2
return `
return `M ${startX} ${startY} L ${startX + 25} ${startY}`
})
const rootEdge = computed(() => {
if (props.node.column > 0) {
return
}
const startX = 0
const startY = props.boxHeight / 2 + props.marginY / 2
const endX = x.value - 1
const endY = y.value + props.boxHeight / 2
return `
M ${startX} ${startY}
L ${endX - 50} ${startY}
C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)}
L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)}
C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY}
`
},
variable () {
const op = this.operator;
if (this.node.rule[op] && this.node.rule[op][0]) {
if (this.node.rule[op][0]["entries_since"]) {
return "entries_since";
}
if (this.node.rule[op][0]["entries_before"]) {
return "entries_before";
}
if (this.node.rule[op][0]["entries_days_since"]) {
return "entries_days_since";
}
if (this.node.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return this.node.rule[op][0]["var"];
} else {
return "";
}
},
vardata () {
return this.$root.VARS[this.variable];
},
varresult () {
const op = this.operator;
if (this.node.rule[op] && this.node.rule[op][0]) {
if (typeof this.node.rule[op][0]["__result"] === "undefined")
return null;
return this.node.rule[op][0]["__result"];
} else {
return "";
}
},
rightoperand () {
const op = this.operator;
if (this.node.rule[op] && typeof this.node.rule[op][1] !== "undefined") {
return this.node.rule[op][1];
} else {
return null;
}
},
op: function () {
return this.$root.TYPEOPS[this.vardata.type][this.operator]
},
operands: function () {
return this.node.rule[this.operator]
},
operator: function () {
return Object.keys(this.node.rule).filter(function (k) { return !k.startsWith("__") })[0];
},
result: function () {
return typeof this.node.rule.__result == "undefined" ? null : !!this.node.rule.__result
},
resultInclParents: function () {
if (typeof this.node.rule.__result == "undefined")
return null
})
function _p(node) {
if (node.parent) {
return node.rule.__result && _p(node.parent)
}
return node.rule.__result
}
return _p(this.node)
},
nodeClass: function () {
return {
"node": true,
"node-true": this.result === true,
"node-false": this.result === false,
}
}
},
methods: {
df (val) {
const format = $("body").attr("data-datetimeformat")
return moment(val).format(format)
},
tf (val) {
const format = $("body").attr("data-timeformat")
return moment(val, "HH:mm:ss").format(format)
}
},
}
const operator = computed(() => {
return Object.keys(props.node.rule).filter((k) => !k.startsWith('__'))[0]
})
const variable = computed(() => {
const op = operator.value
if (props.node.rule[op] && props.node.rule[op][0]) {
if (props.node.rule[op][0]['entries_since']) {
return 'entries_since'
}
if (props.node.rule[op][0]['entries_before']) {
return 'entries_before'
}
if (props.node.rule[op][0]['entries_days_since']) {
return 'entries_days_since'
}
if (props.node.rule[op][0]['entries_days_before']) {
return 'entries_days_before'
}
return props.node.rule[op][0]['var']
} else {
return ''
}
})
const vardata = computed(() => {
return VARS[variable.value as keyof typeof VARS]
})
const varresult = computed(() => {
const op = operator.value
if (props.node.rule[op] && props.node.rule[op][0]) {
if (typeof props.node.rule[op][0]['__result'] === 'undefined')
return null
return props.node.rule[op][0]['__result']
} else {
return ''
}
})
const rightoperand = computed(() => {
const op = operator.value
if (props.node.rule[op] && typeof props.node.rule[op][1] !== 'undefined') {
return props.node.rule[op][1]
} else {
return null
}
})
const op = computed(() => {
return TYPEOPS[vardata.value.type as keyof typeof TYPEOPS]?.[operator.value as any]
})
const operands = computed(() => {
return props.node.rule[operator.value]
})
const result = computed(() => {
return typeof props.node.rule.__result === 'undefined' ? null : !!props.node.rule.__result
})
const resultInclParents = computed(() => {
if (typeof props.node.rule.__result === 'undefined') return null
function _p (node: GraphNode): boolean {
if (node.parent) {
return node.rule.__result && _p(node.parent)
}
return node.rule.__result
}
return _p(props.node)
})
const nodeClass = computed(() => {
return {
node: true,
'node-true': result.value === true,
'node-false': result.value === false,
}
})
function df (val: string) {
const format = $('body').attr('data-datetimeformat')
return moment(val).format(format)
}
function tf (val: string) {
const format = $('body').attr('data-timeformat')
return moment(val, 'HH:mm:ss').format(format)
}
</script>
<template lang="pug">
g
path.edge(v-for="e in edges", :key="e", :d="e")
path.edge(v-if="rootEdge", :d="rootEdge")
path.edge(v-if="!node.children.length", :d="checkEdge")
rect(:width="boxWidth", :height="boxHeight", :x="x", :y="y", :class="nodeClass", rx="5")
foreignObject(:width="boxWidth - 10", :height="boxHeight - 10", :x="x + 5", :y="y + 5")
div.text(xmlns="http://www.w3.org/1999/xhtml")
span(v-if="vardata && vardata.type === 'int'")
span.fa.fa-sign-in(v-if="variable.startsWith('entries_')")
| {{ vardata.label }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op.label }} {{ rightoperand }}
span(v-else-if="vardata && vardata.type === 'int_by_datetime'")
span.fa.fa-sign-in(v-if="variable.startsWith('entries_')")
| {{ vardata.label }}
span(v-if="node.rule[operator][0][variable][0].buildTime[0] === 'custom'")
| {{ df(node.rule[operator][0][variable][0].buildTime[1]) }}
span(v-else-if="node.rule[operator][0][variable][0].buildTime[0] === 'customtime'")
| {{ tf(node.rule[operator][0][variable][0].buildTime[1]) }}
span(v-else)
| {{ TEXTS[node.rule[operator][0][variable][0].buildTime[0]] }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op.label }} {{ rightoperand }}
span(v-else-if="vardata && variable === 'now'")
span.fa.fa-clock-o
| {{ vardata.label }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op.label }}
br
span(v-if="rightoperand.buildTime[0] === 'custom'")
| {{ df(rightoperand.buildTime[1]) }}
span(v-else-if="rightoperand.buildTime[0] === 'customtime'")
| {{ tf(rightoperand.buildTime[1]) }}
span(v-else)
| {{ TEXTS[rightoperand.buildTime[0]] }}
span(v-if="operands[2]")
span(v-if="operator === 'isBefore'") +
span(v-else) -
| {{ operands[2] }}
| {{ TEXTS.minutes }}
span(v-else-if="vardata && operator === 'inList'")
span.fa.fa-sign-in(v-if="variable === 'gate'")
span.fa.fa-ticket(v-else)
| {{ vardata.label }}
span(v-if="varresult !== null") ({{ varresult }})
br
strong
| {{ rightoperand.objectList.map((o: any) => o.lookup[2]).join(", ") }}
span(v-else-if="vardata && vardata.type === 'enum_entry_status'")
span.fa.fa-check-circle-o
| {{ vardata.label }}
span(v-if="varresult !== null") ({{ varresult }})
br
strong
| {{ op.label }} {{ rightoperand }}
g(v-if="result === false", :transform="`translate(${x + boxWidth - 15}, ${y - 10})`")
ellipse(fill="#fff", cx="14.685823", cy="14.318233", rx="12.140151", ry="11.55523")
path.error(d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z")
g(v-if="result === true", :transform="`translate(${x + boxWidth - 15}, ${y - 10})`")
ellipse(fill="#fff", cx="14.685823", cy="14.318233", rx="12.140151", ry="11.55523")
path.check(d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z")
g(v-if="!node.children.length && (resultInclParents === null || resultInclParents === true)", :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`")
path.check(d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z")
g(v-if="!node.children.length && (resultInclParents === false)", :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`")
path.error(d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z")
</template>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pretix Widget</title>
<link id="widget-css" rel="stylesheet" type="text/css" crossorigin>
</head>
<body>
<div id="widget-container"></div>
<script>
{
const params = new URLSearchParams(window.location.search)
const knownParams = new Set(['type', 'host', 'org', 'event', 'mode', 'lang', 'button-text'])
const type = params.get('type') || 'widget'
const host = params.get('host') || 'http://localhost:8000'
const org = params.get('org') || 'testorg'
const event = params.get('event') || 'testevent'
const mode = params.get('mode') || 'dev'
const lang = params.get('lang') || 'de'
const baseUrl = `${host}/${org}/${event}`
document.getElementById('widget-css').href = `${baseUrl}/widget/v2.css`
const el = document.createElement(type === 'button' ? 'pretix-button' : 'pretix-widget')
el.setAttribute('event', `${baseUrl}/`)
if (type === 'button') {
el.textContent = params.get('button-text') || 'Buy tickets!'
}
for (const [key, value] of params) {
if (knownParams.has(key)) continue
el.setAttribute(key, value)
}
document.getElementById('widget-container').appendChild(el)
const script = document.createElement('script')
if (mode === 'prod') {
Object.assign(script, { type: 'text/javascript', src: `${host}/widget/v2.${lang}.js`, async: true, crossOrigin: 'anonymous' })
} else {
Object.assign(script, { type: 'module', src: '/src/main.ts' })
}
document.body.appendChild(script)
}
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
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
}
export async function createCart (url: string) {
const response = await fetch(url)
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return await response.json() as CartResponse
}

View File

@@ -0,0 +1,67 @@
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,
keepCart: 'keep-cart' in element.attributes || buttonItems.length > 0,
})
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
}
}
}
})
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
}

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, ref, inject, onMounted } from 'vue'
import type { Item, Variation } from '~/types'
import { StoreKey, globalWidgetId } from '~/sharedStore'
import { STRINGS } from '~/i18n'
const props = defineProps<{
item: Item
variation?: Variation
}>()
const store = inject(StoreKey)!
const quantity = ref<HTMLInputElement>()
const avail = computed(() => props.item.has_variations ? props.variation.avail : props.item.avail)
const orderMax = computed(() => props.item.has_variations ? props.variation.order_max : props.item.order_max)
const inputName = computed(() => {
if (props.item.has_variations) {
return `variation_${props.item.id}_${props.variation.id}`
}
return `item_${props.item.id}`
})
const unavailabilityReasonMessage = computed(() => {
const reason = props.item.current_unavailability_reason || props.variation?.current_unavailability_reason
if (reason) {
return STRINGS[`unavailable_${reason}`] || reason
}
return ''
})
const voucherJumpLink = computed(() => `#${store.htmlId}-voucher-input`)
const ariaLabelledby = computed(() => `${store.htmlId}-item-label-${props.item.id}`)
const decLabel = computed(() => {
// TODO
const name = props.item.has_variations ? props.variation.value : props.item.name
return `- ${name}: ${STRINGS.quantity_dec}`
})
const incLabel = computed(() => {
const name = props.item.has_variations ? props.variation.value : props.item.name
return `+ ${name}: ${STRINGS.quantity_inc}`
})
const labelSelectItem = computed(() => {
if (props.item.has_variations) return STRINGS.select_variant.replace('%s', props.variation.value)
return STRINGS.select_item.replace('%s', props.item.name)
})
const waitingListShow = computed(() => avail.value[0] < 100 && store.waitingListEnabled && props.item.allow_waitinglist)
const waitingListUrl = computed(() => {
let u = `${store.targetUrl}w/${globalWidgetId}/waitinglist/?locale=${LANG}&item=${props.item.id}`
if (props.item.has_variations && props.variation) {
u += `&var=${props.variation.id}`
}
if (store.subevent) {
u += `&subevent=${store.subevent}`
}
u += `&widget_data=${encodeURIComponent(store.widgetDataJson)}`
u += store.consentParameter
return u
})
function onStep (e: Event) {
const target = e.target as HTMLElement
const button = target.tagName === 'BUTTON' ? target : target.closest('button')
if (!button || !quantity.value) return
const step = parseFloat(button.getAttribute('data-step') || '0')
const input = quantity.value
const min = parseFloat(input.min) || 0
const max = parseFloat(input.max) || Number.MAX_SAFE_INTEGER
const currentValue = parseInt(input.value || '0')
input.value = String(Math.max(min, Math.min(max, currentValue + step)))
input.dispatchEvent(new CustomEvent('change', { bubbles: true }))
}
onMounted(() => {
// Auto-select first item if single item with no variations
if (
!store.cartExists
&& store.itemnum === 1
&& (!store.categories[0]?.items[0]?.has_variations || store.categories[0]?.items[0]?.variations.length < 2)
&& !store.hasSeatingPlan
&& quantity.value
) {
quantity.value.value = '1'
if (orderMax.value === 1 && quantity.value.type === 'checkbox') {
;(quantity.value as HTMLInputElement).checked = true
}
}
})
</script>
<template lang="pug">
.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 +
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import Overlay from './Overlay.vue'
const lang = LANG // we need this so the template sees the variable
const store = inject(StoreKey)!
const form = ref<HTMLFormElement>()
const formMethod = computed(() => {
if (!store.useIframe && store.isButton && store.items.length === 0) {
return 'get'
}
return 'post'
})
function handleBuy (event: Event) {
if (form.value) {
const formData = new FormData(form.value)
store.buy(formData, event)
}
}
defineExpose({
form,
buy: handleBuy,
})
</script>
<template lang="pug">
.pretix-widget-wrapper
.pretix-widget-button-container
form(ref="form", :method="formMethod", :action="store.formAction", :target="store.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="store.widgetDataJson")
input(v-if="store.consentParameterValue", type="hidden", name="consent", :value="store.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
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { Category } from '~/types'
import Item from './Item.vue'
defineProps<{
category: Category
}>()
</script>
<template lang="pug">
.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"
)
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import { padNumber } from '~/utils'
import EventCalendarRow from './EventCalendarRow.vue'
import EventListFilterForm from './EventListFilterForm.vue'
defineProps<{
mobile: boolean
}>()
const store = inject(StoreKey)!
const calendar = ref<HTMLDivElement>()
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && store.parentStack.length > 0))
const monthname = computed(() => {
// TODO proper date formatting?
if (!store.date) return ''
const monthNum = store.date.substr(5, 2)
const year = store.date.substr(0, 4)
return `${STRINGS.months[monthNum]} ${year}`
})
const id = computed(() => `${store.htmlId}-event-calendar-table`)
const ariaLabelledby = computed(() => `${store.htmlId}-event-calendar-table-label`)
function backToList () {
// TODO should be in store
store.weeks = null
store.view = 'events'
store.name = null
store.frontpageText = null
}
function prevmonth () {
if (!store.date) return
let curMonth = parseInt(store.date.substr(5, 2))
let curYear = parseInt(store.date.substr(0, 4))
curMonth--
if (curMonth < 1) {
curMonth = 12
curYear--
}
store.date = `${curYear}-${padNumber(curMonth, 2)}-01`
store.loading++
store.reload({ focus: `#${id.value}` })
}
function nextmonth () {
if (!store.date) return
let curMonth = parseInt(store.date.substr(5, 2))
let curYear = parseInt(store.date.substr(0, 4))
curMonth++
if (curMonth > 12) {
curMonth = 1
curYear++
}
store.date = `${curYear}-${padNumber(curMonth, 2)}-01`
store.loading++
store.reload({ focus: `#${id.value}` })
}
</script>
<template lang="pug">
.pretix-widget-event-calendar(ref="calendar")
//- Back navigation
.pretix-widget-back(v-if="store.events !== null")
a(href="#", role="button", @click.prevent.stop="backToList")
| &lsaquo; {{ 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="!store.disableFilters && store.metaFilterFields.length > 0")
//- Calendar navigation
.pretix-widget-event-calendar-head
a.pretix-widget-event-calendar-previous-month(href="#", @click.prevent.stop="prevmonth")
| &laquo; {{ STRINGS.previous_month }}
|
strong(:id="ariaLabelledby") {{ monthname }}
|
a.pretix-widget-event-calendar-next-month(href="#", @click.prevent.stop="nextmonth")
| {{ STRINGS.next_month }} &raquo;
//- Calendar
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"
)
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, inject, ref, onMounted, watch } from 'vue'
import type { DayEntry } from '~/types'
import { StoreKey } from '~/sharedStore'
import EventCalendarEvent from './EventCalendarEvent.vue'
const props = defineProps<{
day: DayEntry | null
mobile: boolean
}>()
const store = inject(StoreKey)!
const cellEl = ref<HTMLTableCellElement>()
const daynum = computed(() => {
if (!props.day) return ''
return props.day.date.substr(8)
})
const dateStr = computed(() => props.day ? new Date(props.day.date).toLocaleDateString() : '')
const role = computed(() => !props.day || !props.day.events.length || !props.mobile ? 'cell' : 'button')
const tabindex = computed(() => role.value === 'button' ? '0' : '-1')
const classObject = computed(() => {
const o: Record<string, boolean> = {}
if (props.day && props.day.events.length > 0) {
o['pretix-widget-has-events'] = true // TODO static
let best = 'red'
let allLow = true
// TODO decopypasta
for (const ev of props.day.events) {
if (ev.availability.color === 'green') {
best = 'green'
if (ev.availability.reason !== 'low') {
allLow = false
}
} else if (ev.availability.color === 'orange' && best !== 'green') {
best = 'orange'
}
}
o[`pretix-widget-day-availability-${best}`] = true
if (best === 'green' && allLow) {
o['pretix-widget-day-availability-low'] = true
}
}
return o
})
function selectDay (e: Event) {
if (!props.day || !props.day.events.length || !props.mobile) return
e.preventDefault()
e.stopPropagation()
// TODO decopypasta
if (props.day.events.length === 1) {
const ev = props.day.events[0]
store.parentStack.push(store.targetUrl)
store.targetUrl = ev.event_url
store.error = null
store.subevent = ev.subevent ?? null
store.loading++
store.reload()
} else {
store.events = props.day.events
store.view = 'events'
}
}
function onKeyDown (e: KeyboardEvent) {
const keyDown = e.key ?? e.keyCode
if (keyDown === 'Enter' || keyDown === 13 || ['Spacebar', ' '].includes(keyDown as string) || keyDown === 32) {
// (prevent default so the page doesn't scroll when pressing space)
e.preventDefault()
selectDay(e)
}
}
function attachListeners () {
if (role.value === 'button' && cellEl.value) {
cellEl.value.addEventListener('click', selectDay)
cellEl.value.addEventListener('keydown', onKeyDown)
}
}
function detachListeners () {
if (cellEl.value) {
cellEl.value.removeEventListener('click', selectDay)
cellEl.value.removeEventListener('keydown', onKeyDown)
}
}
onMounted(() => {
attachListeners()
})
// TODO why different from old version?
watch(role, (newValue, oldValue) => {
if (newValue === 'button' && oldValue !== 'button') {
attachListeners()
} else if (newValue !== 'button' && oldValue === 'button') {
detachListeners()
}
})
</script>
<template lang="pug">
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")
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { EventEntry } from '~/types'
import { StoreKey } from '~/sharedStore'
const props = defineProps<{
event: EventEntry
describedby?: string
}>()
const store = inject(StoreKey)!
const classObject = computed(() => {
const o: Record<string, boolean> = {
'pretix-widget-event-calendar-event': true,
}
o[`pretix-widget-event-availability-${props.event.availability.color}`] = true
if (props.event.availability.reason) {
o[`pretix-widget-event-availability-${props.event.availability.reason}`] = true
}
return o
})
function select () {
store.parentStack.push(store.targetUrl)
store.targetUrl = props.event.event_url
store.error = null
store.subevent = props.event.subevent ?? null
store.loading++
store.reload()
}
</script>
<template lang="pug">
a.pretix-widget-event-calendar-event(
href="#",
:class="classObject",
:aria-describedby="describedby",
@click.prevent.stop="select"
)
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 }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DayEntry } from '~/types'
import EventCalendarCell from './EventCalendarCell.vue'
defineProps<{
week: (DayEntry | null)[]
mobile: boolean
}>()
</script>
<template lang="pug">
tr
EventCalendarCell(v-for="(d, idx) in week", :key="idx", :day="d", :mobile="mobile")
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, inject, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import Category from './Category.vue'
const store = inject(StoreKey)!
const form = ref<HTMLFormElement>()
const voucherinput = ref<HTMLInputElement>()
const isItemsSelected = ref(false)
const localVoucher = ref('')
const idVoucherInput = computed(() => `${store.htmlId}-voucher-input`)
const ariaLabelledby = computed(() => `${store.htmlId}-voucher-headline`)
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && (store.events || store.weeks || store.days)))
const idCartExistsMsg = computed(() => `${store.htmlId}-cart-exists`)
const buyLabel = computed(() => {
let allFree = true
for (const cat of store.categories) {
for (const item of cat.items) {
for (const v of item.variations) {
if (v.price.gross !== '0.00') {
allFree = false
break
}
}
if ((item.variations.length === 0 && item.price.gross !== '0.00') || item.mandatory_priced_addons) {
allFree = false
break
}
}
if (!allFree) break
}
return allFree ? STRINGS.register : STRINGS.buy
})
const hiddenParams = computed(() => {
const params = new URL(store.getVoucherFormTarget()).searchParams
params.delete('iframe')
params.delete('take_cart_id')
return [...params.entries()]
})
const showVoucherForm = computed(() => store.vouchersExist && !store.disableVouchers && !store.voucherCode)
async function backToList () {
store.targetUrl = store.parentStack.pop() || store.targetUrl
store.error = null
if (!store.subevent) {
// reset if we are not in a series
store.name = null
store.frontpageText = null
}
store.subevent = null
store.offset = 0
store.appendEvents = false
store.triggerLoadCallback()
if (store.events !== undefined && store.events !== null) {
store.view = 'events'
} else if (store.days !== undefined && store.days !== null) {
store.view = 'days'
} else {
store.view = 'weeks'
}
// wait for redraw, then focus content element for better a11y
const rootEl = form.value?.closest('.pretix-widget-wrapper') as HTMLElement | null
await nextTick()
rootEl?.focus()
}
function calcItemsSelected () {
if (!form.value) return
const checkboxes = form.value.querySelectorAll<HTMLInputElement>('input[type=checkbox], input[type=radio]')
const hasChecked = Array.from(checkboxes).some((el) => el.checked)
const numberInputs = form.value.querySelectorAll<HTMLInputElement>('.pretix-widget-item-count-group input')
const hasQuantity = Array.from(numberInputs).some((el) => parseInt(el.value || '0') > 0)
isItemsSelected.value = hasChecked || hasQuantity
}
function _focusVoucherField () {
voucherinput.value?.focus()
}
function handleBuy (event: Event) {
if (form.value) {
const formData = new FormData(form.value)
store.buy(formData, event)
}
}
function handleRedeem (event: Event) {
store.redeem(localVoucher.value, event)
}
onMounted(() => {
if (form.value) {
form.value.addEventListener('change', calcItemsSelected)
}
})
onBeforeUnmount(() => {
if (form.value) {
form.value.removeEventListener('change', calcItemsSelected)
}
})
watch(() => store.overlay?.frameShown, (newValue) => {
if (!newValue && form.value) {
form.value.reset()
calcItemsSelected()
}
})
</script>
<template lang="pug">
.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")
| &lsaquo; {{ STRINGS.back_to_list }}
a(v-if="store.subevent", href="#", rel="back", @click.prevent.stop="backToList")
| &lsaquo; {{ 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="store.formAction",
:target="store.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="store.widgetDataJson")
input(v-if="store.consentParameterValue", type="hidden", name="consent", :value="store.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
//- Actual 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
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, inject, nextTick } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import EventListEntry from './EventListEntry.vue'
import EventListFilterForm from './EventListFilterForm.vue'
const store = inject(StoreKey)!
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && store.parentStack.length > 0))
async function backToCalendar (event: MouseEvent) {
// make sure to always focus content element
await nextTick()
const rootEl = (event.target as HTMLElement).closest('.pretix-widget-wrapper') as HTMLElement | null
rootEl?.focus()
store.offset = 0
store.appendEvents = false
if (store.weeks) {
store.events = null
store.view = 'weeks'
store.name = null
store.frontpageText = null
} else {
store.loading++
store.targetUrl = store.parentStack.pop() || store.targetUrl
store.error = null
store.reload()
}
}
function loadMore () {
store.appendEvents = true
store.offset += 50
store.loading++
store.reload()
}
console.log(store)
</script>
<template lang="pug">
.pretix-widget-event-list
.pretix-widget-back(v-if="store.weeks || store.parentStack.length > 0")
a(href="#", rel="prev", @click.prevent.stop="backToCalendar")
| &lsaquo; {{ 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="!store.disableFilters && store.metaFilterFields.length > 0")
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 }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { EventEntry } from '~/types'
import { StoreKey } from '~/sharedStore'
const props = defineProps<{
event: EventEntry
}>()
const store = inject(StoreKey)!
const classObject = computed(() => {
const o: Record<string, boolean> = {
'pretix-widget-event-list-entry': true,
}
o[`pretix-widget-event-availability-${props.event.availability.color}`] = true
if (props.event.availability.reason) {
o[`pretix-widget-event-availability-${props.event.availability.reason}`] = true
}
return o
})
const location = computed(() => props.event.location.replace(/\s*\n\s*/g, ', '))
function select () {
store.parentStack.push(store.targetUrl)
store.targetUrl = props.event.event_url
store.error = null
store.subevent = props.event.subevent ?? null
store.loading++
store.reload()
}
</script>
<template lang="pug">
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 }}
//- hidden by css for now, but used by a few people
.pretix-widget-event-list-entry-location {{ location }}
.pretix-widget-event-list-entry-availability
span {{ event.availability.text }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { MetaFilterField } from '~/types'
import { StoreKey, globalWidgetId } from '~/sharedStore'
const props = defineProps<{
field: MetaFilterField
}>()
const store = inject(StoreKey)!
const id = computed(() => `${globalWidgetId}_${props.field.key}`)
const currentValue = computed(() => {
const filterParams = new URLSearchParams(store.filter || '')
return filterParams.get(props.field.key) || ''
})
</script>
<template lang="pug">
.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] }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import EventListFilterField from './EventListFilterField.vue'
const store = inject(StoreKey)!
const filterform = ref<HTMLFormElement>()
function onSubmit (e: Event) {
e.preventDefault()
if (!filterform.value) return
const formData = new FormData(filterform.value)
const filterParams = new URLSearchParams()
formData.forEach((value, key) => {
if (value !== '') {
filterParams.set(key, value as string)
}
})
store.filter = filterParams.toString()
store.loading++
store.reload()
}
</script>
<template lang="pug">
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 }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import { getISOWeeks } from '~/utils'
import EventWeekCell from './EventWeekCell.vue'
import EventListFilterForm from './EventListFilterForm.vue'
defineProps<{
mobile: boolean
}>()
const store = inject(StoreKey)!
const weekcalendar = ref<HTMLDivElement>()
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && store.parentStack.length > 0))
const weekname = computed(() => {
if (!store.week) return ''
const curWeek = store.week[1]
const curYear = store.week[0]
return `${curWeek} / ${curYear}`
})
const id = computed(() => `${store.htmlId}-event-week-table`)
function backToList () {
store.weeks = null
store.name = null
store.frontpageText = null
store.view = 'events'
}
function prevweek () {
if (!store.week) return
let curWeek = store.week[1]
let curYear = store.week[0]
curWeek--
if (curWeek < 1) {
curYear--
curWeek = getISOWeeks(curYear)
}
store.week = [curYear, curWeek]
store.loading++
store.reload({ focus: `#${id.value}` })
}
function nextweek () {
if (!store.week) return
let curWeek = store.week[1]
let curYear = store.week[0]
curWeek++
if (curWeek > getISOWeeks(curYear)) {
curWeek = 1
curYear++
}
store.week = [curYear, curWeek]
store.loading++
store.reload({ focus: `#${id.value}` })
}
</script>
<template lang="pug">
.pretix-widget-event-calendar.pretix-widget-event-week-calendar(ref="weekcalendar")
//- Back navigation
.pretix-widget-back(v-if="store.events !== null")
a(href="#", role="button", @click.prevent.stop="backToList")
| &lsaquo; {{ STRINGS.back }}
//- Event header
.pretix-widget-event-header(v-if="displayEventInfo")
strong {{ store.name }}
//- Filter
EventListFilterForm(v-if="!store.disableFilters && store.metaFilterFields.length > 0")
//- 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="#", role="button", @click.prevent.stop="prevweek")
| &laquo; {{ STRINGS.previous_week }}
|
strong {{ weekname }}
|
a.pretix-widget-event-calendar-next-month(href="#", role="button", @click.prevent.stop="nextweek")
| {{ STRINGS.next_week }} &raquo;
//- 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")
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { DayEntry } from '~/types'
import { StoreKey } from '~/sharedStore'
import EventCalendarEvent from './EventCalendarEvent.vue'
const props = defineProps<{
day: DayEntry | null
mobile: boolean // TODO inject?
}>()
const store = inject(StoreKey)!
const id = computed(() => props.day ? `${store.htmlId}-${props.day.date}` : '')
const dayhead = computed(() => {
if (!props.day) return ''
return props.day.day_formatted
})
const classObject = computed(() => {
const o: Record<string, boolean> = {}
if (props.day && props.day.events.length > 0) {
o['pretix-widget-has-events'] = true
let best = 'red'
let allLow = true
for (const ev of props.day.events) {
if (ev.availability.color === 'green') {
best = 'green'
if (ev.availability.reason !== 'low') {
allLow = false
}
} else if (ev.availability.color === 'orange' && best !== 'green') {
best = 'orange'
}
}
o[`pretix-widget-day-availability-${best}`] = true
if (best === 'green' && allLow) {
o['pretix-widget-day-availability-low'] = true
}
}
return o
})
function selectDay () {
if (!props.day || !props.day.events.length || !props.mobile) return
if (props.day.events.length === 1) {
const ev = props.day.events[0]
// TODO store mutation bad
store.parentStack.push(store.targetUrl)
store.targetUrl = ev.event_url
store.error = null
store.subevent = ev.subevent ?? null
store.loading++
store.reload()
} else {
store.events = props.day.events
store.view = 'events'
}
}
</script>
<template lang="pug">
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"
)
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, inject, ref, watch, onMounted } from 'vue'
import type { Item, Category } from '~/types'
import { StoreKey } from '~/sharedStore'
import { STRINGS, interpolate } from '~/i18n'
import { floatformat } from '~/utils'
import AvailBox from './AvailBox.vue'
import PriceBox from './PriceBox.vue'
import Variation from './Variation.vue'
const props = defineProps<{
item: Item
category: Category
}>()
const store = inject(StoreKey)!
const expanded = ref(store.showVariationsExpanded)
const variations = ref<HTMLDivElement>()
const classObject = computed(() => ({
'pretix-widget-item': true,
'pretix-widget-item-with-picture': !!props.item.picture,
'pretix-widget-item-with-variations': props.item.has_variations,
}))
const varClasses = computed(() => ({
'pretix-widget-item-variations': true,
'pretix-widget-item-variations-expanded': expanded.value,
}))
const pictureAltText = computed(() => interpolate(STRINGS.image_of, [props.item.name]))
const headingLevel = computed(() => props.category.name ? '4' : '3')
const itemLabelId = computed(() => `${store.htmlId}-item-label-${props.item.id}`)
const itemDescId = computed(() => `${store.htmlId}-item-desc-${props.item.id}`)
const itemPriceId = computed(() => `${store.htmlId}-item-price-${props.item.id}`)
const ariaLabelledby = computed(() => `${itemLabelId.value} ${itemPriceId.value}`)
const minOrderStr = computed(() => interpolate(STRINGS.order_min, [props.item.order_min]))
const quotaLeftStr = computed(() => interpolate(STRINGS.quota_left, [props.item.avail[1]]))
const showToggle = computed(() => props.item.has_variations && !store.showVariationsExpanded)
// TODO dedupe?
const showPrices = computed(() => {
let hasPriced = false
let cntItems = 0
for (const cat of store.categories) {
for (const item of cat.items) {
if (item.has_variations) {
cntItems += item.variations.length
hasPriced = true
} else {
cntItems++
hasPriced = hasPriced || item.price.gross !== '0.00' || item.free_price
}
}
}
return hasPriced || cntItems > 1
})
// TODO XSS
const pricerange = computed(() => {
if (props.item.free_price) {
return interpolate(
STRINGS.price_from,
{
currency: store.currency,
price: floatformat(props.item.min_price || '0', 2),
},
true
).replace(
store.currency,
`<span class="pretix-widget-pricebox-currency">${store.currency}</span>`
)
} else if (props.item.min_price !== props.item.max_price) {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', 2)} ${floatformat(props.item.max_price || '0', 2)}`
} else if (props.item.min_price === '0.00' && props.item.max_price === '0.00') {
if (props.item.mandatory_priced_addons) {
return '\xA0' // nbsp, because an empty string would cause the HTML element to collapse
}
return STRINGS.free
} else {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', 2)}`
}
})
const variationsToggleLabel = computed(() => expanded.value ? STRINGS.hide_variations : STRINGS.variations)
function expand () {
expanded.value = !expanded.value
}
function lightbox () {
if (store.overlay) {
store.overlay.lightbox = {
image: props.item.picture_fullsize || '',
description: props.item.name,
loading: true, // TODO why?
}
}
}
onMounted(() => {
if (variations.value && !expanded.value) {
variations.value.hidden = true
variations.value.addEventListener('transitionend', function (event) {
if (event.target === variations.value) {
if (variations.value) {
variations.value.hidden = !expanded.value
variations.value.style.maxHeight = 'none'
}
}
})
}
})
watch(expanded, (newValue) => {
const v = variations.value
if (!v) return
v.hidden = false
v.style.maxHeight = `${newValue ? 0 : v.scrollHeight}px`
// Vue.nextTick does not work here
setTimeout(() => {
v.style.maxHeight = `${!newValue ? 0 : v.scrollHeight}px`
}, 50)
})
</script>
<template lang="pug">
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") &nbsp;
//- 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"
)
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, inject, nextTick } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
const store = inject(StoreKey)!
const cancelBlocked = ref(false)
const lightboxImage = ref<HTMLImageElement>()
const frameDialog = ref<HTMLDialogElement>()
const alertDialog = ref<HTMLDialogElement>()
const lightboxDialog = ref<HTMLDialogElement>()
const iframe = ref<HTMLIFrameElement>()
const closeButton = ref<HTMLButtonElement>()
const frameClasses = computed(() => ({
'pretix-widget-frame-holder': true,
'pretix-widget-frame-shown': store.overlay.frameShown || store.overlay.frameLoading,
'pretix-widget-frame-isloading': store.overlay.frameLoading,
}))
const alertClasses = computed(() => ({
'pretix-widget-alert-holder': true,
'pretix-widget-alert-shown': store.overlay.errorMessage,
}))
const lightboxClasses = computed(() => ({
'pretix-widget-lightbox-holder': true,
'pretix-widget-lightbox-shown': store.overlay.lightbox,
'pretix-widget-lightbox-isloading': store.overlay.lightbox?.loading,
}))
const cancelBlockedClasses = computed(() => ({
'pretix-widget-visibility-hidden': !cancelBlocked.value,
}))
const errorMessageId = computed(() => `${store.htmlId}-error-message`)
function onMessage (e: MessageEvent) {
if (e.data.type && e.data.type === 'pretix:widget:title') {
if (iframe.value) {
iframe.value.title = e.data.title
}
}
}
function lightboxClose () {
store.overlay.lightbox = null
}
function lightboxLoaded () {
if (store.overlay.lightbox) {
store.overlay.lightbox.loading = false
}
}
function errorClose (e: Event) {
const dialog = e.target as HTMLDialogElement
if (dialog.returnValue === 'continue' && store.overlay.errorUrlAfter) {
if (store.overlay.errorUrlAfterNewTab) {
window.open(store.overlay.errorUrlAfter)
} else {
store.overlay.frameSrc = store.overlay.errorUrlAfter
store.overlay.frameLoading = true
}
}
store.overlay.errorMessage = null
store.overlay.errorUrlAfter = null
store.overlay.errorUrlAfterNewTab = false
}
function close (e) {
if (store.overlay.frameLoading) {
// Chrome does not allow blocking dialog.cancel event more than once
// => wiggle the loading-element and re-open the modal
cancel(e)
frameDialog.value?.showModal()
return
}
store.overlay.frameShown = false
store.frameDismissed = true
store.overlay.frameSrc = ''
store.reload()
triggerCloseCallback()
}
function cancel (e: Event) {
// do not allow to cancel while frame is loading as we cannot abort the operation
if (store.overlay.frameLoading) {
e.preventDefault()
const target = e.target as HTMLElement
target.addEventListener('animationend', function () {
target.classList.remove('pretix-widget-shake-once')
}, { once: true })
target.classList.add('pretix-widget-shake-once')
cancelBlocked.value = true
}
}
function iframeLoaded () {
if (store.overlay.frameLoading) {
store.overlay.frameLoading = false
cancelBlocked.value = false
if (store.overlay.frameSrc) {
store.overlay.frameShown = true
}
}
}
function triggerCloseCallback () {
nextTick(() => {
for (const callback of (window as any).PretixWidget._closed || []) {
callback()
}
})
}
// TODO check if watchEffect is better for the following watchers
watch(() => store.overlay.lightbox, (newValue, oldValue) => {
if (!newValue) return
if (newValue.image !== oldValue?.image) {
newValue.loading = true
}
if (!oldValue) {
lightboxDialog.value?.showModal()
}
})
watch(() => store.overlay.errorMessage, (newValue, oldValue) => {
if (newValue && !oldValue) {
alertDialog.value?.showModal()
}
})
watch(() => store.overlay.frameShown, async (newValue) => {
if (!newValue) return
await nextTick()
closeButton.value?.focus()
})
watch(() => store.overlay.frameSrc, (newValue, oldValue) => {
// show loading spinner only when previously no frame_src was set
if (newValue && !oldValue) {
store.overlay.frameLoading = true
}
if (iframe.value) {
// to close and unload the iframe, frame_src can be empty -> make it valid HTML with about:blank
iframe.value.src = newValue || 'about:blank'
}
})
watch(() => store.overlay.frameLoading, (newValue) => {
if (newValue) {
if (frameDialog.value && !frameDialog.value.open) {
frameDialog.value.showModal()
}
} else {
if (!store.overlay.frameSrc && frameDialog.value?.open) { // finished loading, but no iframe to display => close
frameDialog.value.close()
}
}
})
onMounted(() => {
window.addEventListener('message', onMessage, false)
})
onUnmounted(() => {
window.removeEventListener('message', onMessage, false)
})
</script>
<template lang="pug">
Teleport(to="body")
.pretix-widget-overlay
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!
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")
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 }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { Price } from '~/types'
import { StoreKey } from '~/sharedStore'
import { STRINGS, interpolate } from '~/i18n'
import { floatformat, autofloatformat, stripHTML } from '~/utils'
const props = defineProps<{
price: Price
freePrice: boolean
fieldName: string
suggestedPrice?: Price | null
originalPrice?: string | null
mandatoryPricedAddons?: boolean
itemId: number
}>()
const store = inject(StoreKey)!
const priceBoxId = computed(() => `${store.htmlId}-item-pricebox-${props.itemId}`)
const priceDescId = computed(() => `${store.htmlId}-item-pricedesc-${props.itemId}`)
const ariaLabelledby = computed(() => `${store.htmlId}-item-label-${props.itemId} ${priceBoxId.value}`)
const displayPrice = computed(() => {
if (store.displayNetPrices) {
return floatformat(parseFloat(props.price.net), 2)
}
return floatformat(parseFloat(props.price.gross), 2)
})
const displayPriceNonlocalized = computed(() => {
if (store.displayNetPrices) {
return parseFloat(props.price.net).toFixed(2)
}
return parseFloat(props.price.gross).toFixed(2)
})
const suggestedPriceNonlocalized = computed(() => {
const price = props.suggestedPrice ?? props.price
if (store.displayNetPrices) {
return parseFloat(price.net).toFixed(2)
}
return parseFloat(price.gross).toFixed(2)
})
// TODO BAD
const originalLine = computed(() => {
if (!props.originalPrice) return ''
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(parseFloat(props.originalPrice), 2)}`
})
// TODO BAD
const priceline = computed(() => {
if (props.price.gross === '0.00') {
if (props.mandatoryPricedAddons && !props.originalPrice) {
return '\u00A0' // nbsp
}
return STRINGS.free
}
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${displayPrice.value}`
})
const originalPriceAriaLabel = computed(() => interpolate(STRINGS.original_price, [stripHTML(originalLine.value)]))
const newPriceAriaLabel = computed(() => interpolate(STRINGS.new_price, [stripHTML(priceline.value)]))
const taxline = computed(() => {
if (store.displayNetPrices) {
if (props.price.includes_mixed_tax_rate) {
return STRINGS.tax_plus_mixed
}
return interpolate(STRINGS.tax_plus, {
rate: autofloatformat(props.price.rate, 2),
taxname: props.price.name,
}, true)
} else {
if (props.price.includes_mixed_tax_rate) {
return STRINGS.tax_incl_mixed
}
return interpolate(STRINGS.tax_incl, {
rate: autofloatformat(props.price.rate, 2),
taxname: props.price.name,
}, true)
}
})
const showTaxline = computed(() => props.price.rate !== '0.00' && props.price.gross !== '0.00')
</script>
<template lang="pug">
.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 }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { Variation, Item, Category } from '~/types'
import { StoreKey } from '~/sharedStore'
import { STRINGS, interpolate } from '~/i18n'
import AvailBox from './AvailBox.vue'
import PriceBox from './PriceBox.vue'
const props = defineProps<{
variation: Variation
item: Item
category: Category
}>()
const store = inject(StoreKey)!
const origPrice = computed(() => props.variation.original_price || props.item.original_price)
const quotaLeftStr = computed(() => interpolate(STRINGS.quota_left, [props.variation.avail[1]]))
const variationLabelId = computed(() => `${store.htmlId}-variation-label-${props.item.id}-${props.variation.id}`)
const variationDescId = computed(() => `${store.htmlId}-variation-desc-${props.item.id}-${props.variation.id}`)
const variationPriceId = computed(() => `${store.htmlId}-variation-price-${props.item.id}-${props.variation.id}`)
const ariaLabelledby = computed(() => `${variationLabelId.value} ${variationPriceId.value}`)
const headingLevel = computed(() => props.category.name ? '5' : '4')
const showQuotaLeft = computed(() => props.variation.avail[1] !== null && props.variation.avail[0] === 100)
// TODO dedupe?
const showPrices = computed(() => {
// Determine if prices should be shown
let hasPriced = false
let cntItems = 0
for (const cat of store.categories) {
for (const item of cat.items) {
if (item.has_variations) {
cntItems += item.variations.length
hasPriced = true
} else {
cntItems++
hasPriced = hasPriced || item.price.gross !== '0.00' || item.free_price
}
}
}
return hasPriced || cntItems > 1
})
</script>
<template lang="pug">
.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") &nbsp;
//- Availability
.pretix-widget-item-availability-col
AvailBox(:item="item", :variation="variation")
.pretix-widget-clear
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, inject, ref, onMounted, watch } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import EventForm from './EventForm.vue'
import EventList from './EventList.vue'
import EventCalendar from './EventCalendar.vue'
import EventWeekCalendar from './EventWeekCalendar.vue'
import Overlay from './Overlay.vue'
const emit = defineEmits<{
mounted: []
}>()
const store = inject(StoreKey)!
const wrapper = ref<HTMLDivElement>()
const formcomp = ref<InstanceType<typeof EventForm>>()
const mobile = ref(false)
const classObject = computed(() => ({
'pretix-widget': true,
'pretix-widget-mobile': mobile.value,
'pretix-widget-use-custom-spinners': true,
}))
watch(mobile, (newValue) => {
store.mobile = newValue
})
onMounted(() => {
if (wrapper.value) {
const resizeObserver = new ResizeObserver((entries) => {
mobile.value = entries[0].contentRect.width <= 800
})
resizeObserver.observe(wrapper.value)
}
store.reload() // TODO call earlier?
emit('mounted') // TODO where does this go?
})
watch(() => store.view, (_newValue, oldValue) => {
if (oldValue && wrapper.value) {
// always make sure the widget is scrolled to the top
// as we only check top, we do not need to wait for a redraw
const rect = wrapper.value.getBoundingClientRect()
if (rect.top < 0) {
wrapper.value.scrollIntoView()
}
}
})
</script>
<template lang="pug">
.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
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,3 @@
interface NamedNodeMap {
[key: string]: Attr | undefined;
}

View File

@@ -0,0 +1,126 @@
// Internationalization strings for the pretix widget
// In production, widget.py injects the `django` global before this script loads.
// In dev mode, Django's i18n file expects `this` to be the global object, but
// ES modules have `this` as undefined — so we import as raw text and execute
// with a local context.
interface Django {
pgettext: (context: string, text: string) => string
gettext: (text: string) => string
interpolate: (fmt: string, obj: Record<string, unknown> | unknown[], named?: boolean) => string
get_format: (formatType: string) => string | number
}
let django: Django
if (import.meta.env.DEV) {
// TODO this does not actually grab the correct language strings
const modules = import.meta.glob('../../../jsi18n/*/djangojs.js', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
const key = `../../../jsi18n/${LANG}/djangojs.js`
const raw = modules[key]
if (!raw) throw new Error(`Missing i18n module for lang "${LANG}": ${key}`)
const context: { django?: Django } = {}
new Function(raw).call(context)
django = context.django!
} else {
django = (globalThis as any).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<string, string>,
} as const
export function interpolate (fmt: string, obj: Record<string, unknown> | unknown[], named = false): string {
return django.interpolate(fmt, obj, named)
}
export function getFormat (formatType: string): string | number {
return django.get_format(formatType)
}

View File

@@ -0,0 +1,80 @@
import { reactive, computed, watch } from 'vue'
import type { WatchCallback, WatchOptions, UnwrapNestedRefs } from 'vue'
interface StoreMethods {
$reset: () => void
$watch: <T>(source: () => T, callback: WatchCallback<T>, options?: WatchOptions) => void
}
type GetterReturnTypes<G> = {
readonly [K in keyof G]: G[K] extends () => infer R ? R : never
}
type Store<S, G, A> = UnwrapNestedRefs<S> & GetterReturnTypes<G> & A & StoreMethods
type GettersTree<S> = Record<string, (this: S, state: S) => any> | Record<string, () => any>
type ActionsTree = Record<string, (...args: any[]) => any>
export function createStore<
S extends object,
G extends GettersTree<S>,
A extends ActionsTree
> (
// name: string,
config: {
state: () => S
getters?: G & ThisType<UnwrapNestedRefs<S> & GetterReturnTypes<G> & A & StoreMethods>
actions?: A & ThisType<UnwrapNestedRefs<S> & GetterReturnTypes<G> & A & StoreMethods>
}
): Store<S, G, A> {
type StoreType = Store<S, G, A>
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<string, unknown>)[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<string, unknown>)[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 <T>(source: () => T, callback: WatchCallback<T>, options?: WatchOptions) {
watch(source.bind(store), callback.bind(store), options)
}
return store
}

View File

@@ -0,0 +1,127 @@
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<string, string>,
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 () {
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<string, string>,
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: '',
keepCart: true
})
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

View File

@@ -0,0 +1,561 @@
import { nextTick, type InjectionKey } from 'vue'
import { createStore } from '~/lib/store'
import { fetchProductList, submitCart, checkAsyncTask, ApiError, createCart } from '~/api'
import type { CartResponse } from '~/api'
import { STRINGS } from '~/i18n'
import { setCookie, getCookie, makeid, siteIsSecure } from '~/utils'
import type { Category, DayEntry, EventEntry, LightboxState, MetaFilterField, WidgetData } from '~/types'
export const globalWidgetId = makeid(16)
export type WidgetStore = ReturnType<typeof createWidgetStore>
export const StoreKey: InjectionKey<WidgetStore> = 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
keepCart: boolean
// 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,
keepCart: config.keepCart,
// 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<typeof setTimeout> | null,
asyncTaskInterval: 100,
voucher: null as string | null,
}),
getters: {
useIframe (): boolean {
if ((window as any).crossOriginIsolated === true) return false
return !this.disableIframe && (this.skipSsl || siteIsSecure())
},
cookieName (): string {
return `pretix_widget_${this.targetUrl.replace(/[^a-zA-Z0-9]+/g, '_')}`
},
cartId (): string | null {
if (this._cartId) {
return this._cartId
}
if (this.keepCart) {
return getCookie(this.cookieName) ?? null
}
},
widgetDataJson (): string {
const cloned = { ...this.widgetData }
delete cloned.consent
return JSON.stringify(cloned)
},
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
},
formTarget (): string {
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
const isAndroid = navigator.userAgent.toLowerCase().includes('android')
if (isAndroid && isFirefox) {
return '_top'
}
return '_blank'
},
consentParameterValue (): string {
if (this.widgetData.consent) {
return encodeURIComponent(this.widgetData.consent)
}
return ''
},
formAction (): 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.cartId) {
formTarget += `&take_cart_id=${this.cartId}`
}
formTarget += this.consentParameter
return formTarget
}
},
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)}`
if (this.cartId) url += `&cart_id=${encodeURIComponent(this.cartId)}`
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.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<HTMLElement>(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()
}
throw e
}
},
getVoucherFormTarget (): string {
let formTarget = `${this.targetUrl}w/${globalWidgetId}/redeem?iframe=1&locale=${LANG}`
if (this.cartId) {
formTarget += `&take_cart_id=${this.cartId}`
}
if (this.subevent) {
formTarget += `&subevent=${this.subevent}`
}
if (this.widgetData) {
formTarget += `&widget_data=${encodeURIComponent(this.widgetDataJson)}`
}
formTarget += this.consentParameter
if (this.additionalURLParams) {
formTarget += `&${this.additionalURLParams}`
}
return formTarget
},
handleCartResponse (data: CartResponse) {
if (data.redirect) {
if (data.cart_id) {
this.setCartId(data.cart_id)
}
let url = data.redirect
if (url.substring(0, 1) === '/') {
url = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, '$1')}${url}`
}
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 {
this.asyncTaskId = data.async_id
if (data.check_url) {
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) return
if (event) event.preventDefault()
if (this.isButton && this.items.length === 0) {
if (this.voucherCode) {
this.voucherOpen(this.voucherCode)
} else {
this.resume()
}
return
}
const url = `${this.formAction}&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) {
// Likely a redirect!
this.targetUrl = e.responseUrl.substring(0, e.responseUrl.indexOf('/cart/add') - 18)
this.overlay.frameLoading = false
this.buy(formData)
return
}
}
this.overlay.errorMessage = STRINGS.cart_error
this.overlay.frameLoading = false
}
},
async createCart () {
const url = `${this.targetUrl}w/${globalWidgetId}/cart/create?ajax=1`
try {
this.overlay.frameLoading = true
const data = await createCart(url)
this.setCartId(data.cart_id)
} 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
}
}
},
redeem (voucherCode: string, event?: Event) {
if (!this.useIframe) return
if (event) event.preventDefault()
this.voucherOpen(voucherCode)
},
voucherOpen (voucherCode: string) {
// TODO just use https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
const redirectUrl = `${this.getVoucherFormTarget()}&voucher=${encodeURIComponent(voucherCode)}`
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
} else {
window.open(redirectUrl)
}
},
async resume () {
if (!this.cartId && this.keepCart) {
// create an empty cart whose id we can persist
await this.createCart()
}
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/`
if (this.subevent && this.isButton && this.items.length === 0) {
// button with subevent but no items
redirectUrl += `${this.subevent}/`
}
if (this.subevent && !this.cartId) {
// button with subevent but no items
redirectUrl += `${this.subevent}/`
}
redirectUrl += `?iframe=1&locale=${LANG}`
if (this.cartId) {
// ajax to make sure the cart-id is used, even if the cart is currently empty
redirectUrl += `&take_cart_id=${this.cartId}&ajax=1`
}
if (this.widgetData) {
redirectUrl += `&widget_data=${encodeURIComponent(this.widgetDataJson)}`
}
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(this.widgetDataJson)}`
}
redirectUrl += this.consentParameter
if (this.additionalURLParams) {
redirectUrl += `&${this.additionalURLParams}`
}
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
} else {
window.open(redirectUrl)
}
},
setCartId (cartId: string) {
this._cartId = cartId
setCookie(this.cookieName, cartId, 30)
}
}
})
}

View File

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

View File

@@ -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};${siteIsSecure() ? 'SameSite=None;Secure;' : ''}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<void> {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
return Promise.resolve()
}
return new Promise(resolve => {
document.addEventListener('DOMContentLoaded', () => resolve(), { once: true })
})
}

View File

@@ -0,0 +1 @@
declare const LANG: string

View File

@@ -0,0 +1,63 @@
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),
keepCart: true
})
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
}
}
})
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
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../../../tsconfig.json",
"include": ["src/**/*"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
},
"types": ["node", "vite/client"]
}
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => ({
server: {
port: 5180
},
plugins: [
vue()
],
resolve: {
alias: {
'~': import.meta.dirname + '/src',
},
},
build: {
minify: false, // django will do minification
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: {
...(mode === 'development' && {
LANG: JSON.stringify(process.env.PRETIX_WIDGET_LANG || 'en'),
}),
},
}))