diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000000..be3fd7a2bc
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,5 @@
+[*.{js,jsx,ts,tsx,vue}]
+indent_style = tab
+indent_size = 2
+trim_trailing_whitespace = true
+insert_final_newline = true
diff --git a/package-lock.json b/package-lock.json
index 4f272927a6..29cb2dd643 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,8 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@stylistic/eslint-plugin": "^5.7.1",
+ "@types/jquery": "^3.5.33",
+ "@types/moment": "^2.11.29",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/eslint-config-typescript": "^14.6.0",
"eslint": "^9.39.2",
@@ -1223,6 +1225,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/jquery": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz",
+ "integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/sizzle": "*"
+ }
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1230,6 +1242,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/moment": {
+ "version": "2.11.29",
+ "resolved": "https://registry.npmjs.org/@types/moment/-/moment-2.11.29.tgz",
+ "integrity": "sha512-D5WIgbLYQzvgfsDnBhZFSTnt/BjGPOE+Jsh3k1BYYijJAkrn7ceeLvU4jtjKKXXuXN42O3ARlU4D/P9ezbQYFA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/sizzle": {
+ "version": "2.3.10",
+ "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz",
+ "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
diff --git a/package.json b/package.json
index 6514ca7450..c4db318a45 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,8 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@stylistic/eslint-plugin": "^5.7.1",
+ "@types/jquery": "^3.5.33",
+ "@types/moment": "^2.11.29",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/eslint-config-typescript": "^14.6.0",
"eslint": "^9.39.2",
diff --git a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html
index a56b90b83f..be3ba623da 100644
--- a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html
+++ b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html
@@ -3,6 +3,7 @@
{% load bootstrap3 %}
{% load static %}
{% load compress %}
+{% load vite %}
{% block title %}
{% if checkinlist %}
{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
@@ -74,45 +75,8 @@
{% bootstrap_field form.ignore_in_statistics layout="control" %}
{% trans "Custom check-in rule" %}
-
@@ -152,10 +151,6 @@
{% endcompress %}
- {% compress js %}
-
-
-
-
- {% endcompress %}
+ {% vite_hmr %}
+ {% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/" %}
{% endblock %}
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js
deleted file mode 100644
index 308331a003..0000000000
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js
+++ /dev/null
@@ -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));
- }
- },
- }
- })
-});
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/App.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
new file mode 100644
index 0000000000..df2f7471c9
--- /dev/null
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
@@ -0,0 +1,98 @@
+
+
+#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.") }}
+
+
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue
index 707138edd0..219bf4a77b 100644
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue
@@ -1,355 +1,365 @@
-
-
-
-
-
-
- OR
-
- AND
-
-
-
-
-
- {{texts.and}}
- {{texts.or}}
- {{ v.label }}
-
-
-
- {{ v.label }}
-
-
- {{texts.date_from}}
- {{texts.date_to}}
- {{texts.date_admission}}
- {{texts.date_custom}}
- {{texts.date_customtime}}
-
-
-
-
-
-
- {{ v.label }}
-
-
-
-
-
-
- {{ texts.status_absent }}
- {{ texts.status_present }}
-
-
-
-
-
-
{{ texts.condition_add }}
-
-
-
-
-
+
+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 }}
+
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue
index 1efb1f27c7..f07c8cc8e6 100644
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue
@@ -1,25 +1,23 @@
-
-
-
- {{ this.$root.texts.condition_add }}
-
-
-
-
+
+.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 }}
+
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue
index b7fedc720a..ce8fb0dd80 100644
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue
@@ -1,255 +1,276 @@
-
-
-
-
+
+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"
+ )
+
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts b/src/pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
new file mode 100644
index 0000000000..e802ec260f
--- /dev/null
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
@@ -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'
+ }
+}
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/datetimefield.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/datetimefield.vue
index 3676f458ab..d1a5b90ec5 100644
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/datetimefield.vue
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/datetimefield.vue
@@ -1,55 +1,45 @@
-
-
-
-
+
+input.form-control(ref="input")
+
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/django-interop.ts b/src/pretix/static/pretixcontrol/js/ui/checkinrules/django-interop.ts
new file mode 100644
index 0000000000..9ebaaf2bc2
--- /dev/null
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/django-interop.ts
@@ -0,0 +1,68 @@
+import { ref, watch } from 'vue'
+
+export const allProducts = ref(false)
+export const limitProducts = ref([])
+
+function updateProducts () {
+ allProducts.value = document.querySelector('#id_all_products')?.checked ?? false
+ limitProducts.value = Array.from(document.querySelectorAll('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({})
+
+// grab rules from hidden input
+const rulesInput = document.querySelector('#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([])
+
+const itemsEl = document.querySelector('#items')
+if (itemsEl?.textContent) {
+ items.value = JSON.parse(itemsEl.textContent || '[]')
+
+ function checkForInvalidIds (validProducts: Record, validVariations: Record, 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)
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts b/src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts
new file mode 100644
index 0000000000..9932201c8f
--- /dev/null
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts
@@ -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)
+}
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js
index 885586df23..b3a11df14c 100644
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js
@@ -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
-}
\ No newline at end of file
+ // 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
+}
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/lookup-select2.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/lookup-select2.vue
index 2f59014ef1..2378b06a0d 100644
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/lookup-select2.vue
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/lookup-select2.vue
@@ -1,97 +1,116 @@
-
-
-
-
-
-
+
+select(ref="select")
+ slot
+
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/timefield.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/timefield.vue
index 93641dc70f..f728a4a5ff 100644
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/timefield.vue
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/timefield.vue
@@ -1,55 +1,45 @@
-
-
-
-
+
+input.form-control(ref="input")
+
diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue
index 27911f3c89..f9030aca61 100644
--- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue
+++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue
@@ -1,255 +1,242 @@
-
-
-
-
-
-
-
+
+
+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")
+