mirror of
https://github.com/pretix/pretix.git
synced 2026-04-09 21:02:27 +00:00
Compare commits
48 Commits
stable
...
questions-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdc720f5a2 | ||
|
|
583184af38 | ||
|
|
63d9f7cea8 | ||
|
|
c3d6fb1bd6 | ||
|
|
bb43acd257 | ||
|
|
e0744951e8 | ||
|
|
fef1e356f7 | ||
|
|
81775f5d2d | ||
|
|
4bbcc398cc | ||
|
|
a924d0a266 | ||
|
|
0754e9d049 | ||
|
|
0ebdab5389 | ||
|
|
35c5f02b59 | ||
|
|
11c332dfd6 | ||
|
|
b95847c395 | ||
|
|
8fc2e78a58 | ||
|
|
21bd00e7e6 | ||
|
|
3df9d86216 | ||
|
|
c850a35235 | ||
|
|
d794738b8f | ||
|
|
c62a6359e1 | ||
|
|
e62bed7092 | ||
|
|
477601e9f9 | ||
|
|
6024fad056 | ||
|
|
8945e55900 | ||
|
|
7251ae953d | ||
|
|
3c1b806c8e | ||
|
|
2dade31f23 | ||
|
|
504191c005 | ||
|
|
00cb77de8f | ||
|
|
99bd78f2c6 | ||
|
|
d9b691690e | ||
|
|
b1b2a688a8 | ||
|
|
3f92868dba | ||
|
|
961253bac4 | ||
|
|
f3eb2fba6d | ||
|
|
4d8c22a839 | ||
|
|
d544df098c | ||
|
|
6cfdfc2cd6 | ||
|
|
55299b1eae | ||
|
|
97857e7a67 | ||
|
|
469b777dcf | ||
|
|
333dc56ef7 | ||
|
|
2898f06f56 | ||
|
|
0d522c0eed | ||
|
|
ee44d4c968 | ||
|
|
3d37f62c51 | ||
|
|
94dd5b0350 |
@@ -1,5 +1,6 @@
|
||||
doc/
|
||||
env/
|
||||
node_modules/
|
||||
res/
|
||||
local/
|
||||
.git/
|
||||
|
||||
5
.editorconfig
Normal file
5
.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -46,4 +46,7 @@ jobs:
|
||||
- name: Run build
|
||||
run: python -m build
|
||||
- name: Check files
|
||||
run: unzip -l dist/pretix*whl | grep node_modules || exit 1
|
||||
run: |
|
||||
for pat in 'static.dist/vite/widget/widget.js' 'static.dist/vite/control/assets/checkinrules/main-' 'static.dist/vite/control/assets/webcheckin/main-'; do
|
||||
unzip -l dist/pretix*whl | grep -q "$pat" || { echo "Missing: $pat"; exit 1; }
|
||||
done
|
||||
|
||||
45
.github/workflows/tests.yml
vendored
45
.github/workflows/tests.yml
vendored
@@ -70,7 +70,7 @@ jobs:
|
||||
run: make all compress
|
||||
- name: Run tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --maxfail=100
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --ignore=tests/e2e --maxfail=100
|
||||
- name: Run concurrency tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db
|
||||
@@ -82,3 +82,46 @@ jobs:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.11'
|
||||
e2e:
|
||||
runs-on: ubuntu-22.04
|
||||
name: E2E Tests
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: pretix
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d pretix"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system dependencies
|
||||
run: sudo apt update && sudo apt install -y gettext
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary
|
||||
- name: Install JS dependencies
|
||||
working-directory: ./src
|
||||
run: make npminstall
|
||||
- name: Compile
|
||||
working-directory: ./src
|
||||
run: make all compress
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install
|
||||
- name: Run E2E tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,5 +24,6 @@ local/
|
||||
.project
|
||||
.pydevproject
|
||||
.DS_Store
|
||||
node_modules/
|
||||
|
||||
|
||||
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/*
|
||||
10
Dockerfile
10
Dockerfile
@@ -20,11 +20,11 @@ RUN apt-get update && \
|
||||
supervisor \
|
||||
libmaxminddb0 \
|
||||
libmaxminddb-dev \
|
||||
zlib1g-dev \
|
||||
nodejs \
|
||||
npm && \
|
||||
zlib1g-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
dpkg-reconfigure locales && \
|
||||
locale-gen C.UTF-8 && \
|
||||
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||
@@ -49,6 +49,10 @@ COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY pyproject.toml /pretix/pyproject.toml
|
||||
COPY _build /pretix/_build
|
||||
COPY src /pretix/src
|
||||
COPY package.json /pretix/package.json
|
||||
COPY package-lock.json /pretix/package-lock.json
|
||||
COPY tsconfig.json /pretix/tsconfig.json
|
||||
COPY vite.config.ts /pretix/vite.config.ts
|
||||
|
||||
RUN pip3 install -U \
|
||||
pip \
|
||||
|
||||
@@ -48,3 +48,8 @@ recursive-include src Makefile
|
||||
recursive-exclude doc *
|
||||
recursive-exclude deployment *
|
||||
recursive-exclude res *
|
||||
|
||||
include package.json
|
||||
include package-lock.json
|
||||
include tsconfig.json
|
||||
include vite.config.ts
|
||||
|
||||
108
eslint.config.mjs
Normal file
108
eslint.config.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import ts from 'typescript-eslint'
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import vuePug from 'eslint-plugin-vue-pug'
|
||||
|
||||
const ignores = globalIgnores([
|
||||
'**/node_modules',
|
||||
'**/dist'
|
||||
])
|
||||
|
||||
export default defineConfig([
|
||||
ignores,
|
||||
...ts.config(
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended
|
||||
),
|
||||
stylistic.configs.customize({
|
||||
indent: 'tab',
|
||||
braceStyle: '1tbs',
|
||||
quoteProps: 'as-needed'
|
||||
}),
|
||||
...vue.configs['flat/recommended'],
|
||||
...vuePug.configs['flat/recommended'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
localStorage: false,
|
||||
$: 'readonly',
|
||||
$$: 'readonly',
|
||||
$ref: 'readonly',
|
||||
$computed: 'readonly',
|
||||
},
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
},
|
||||
|
||||
rules: {
|
||||
'no-debugger': 'off',
|
||||
curly: 0,
|
||||
'no-return-assign': 0,
|
||||
'no-console': 'off',
|
||||
'vue/require-default-prop': 0,
|
||||
'vue/require-v-for-key': 0,
|
||||
'vue/valid-v-for': 'warn',
|
||||
'vue/no-reserved-keys': 0,
|
||||
'vue/no-setup-props-destructure': 0,
|
||||
'vue/multi-word-component-names': 0,
|
||||
'vue/max-attributes-per-line': 0,
|
||||
'vue/attribute-hyphenation': ['warn', 'never'],
|
||||
'vue/v-on-event-hyphenation': ['warn', 'never'],
|
||||
'import/first': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'no-use-before-define': 'off',
|
||||
'no-var': 'error',
|
||||
|
||||
'@typescript-eslint/no-use-before-define': ['error', {
|
||||
typedefs: false,
|
||||
functions: false,
|
||||
}],
|
||||
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true
|
||||
}],
|
||||
|
||||
'@stylistic/comma-dangle': 0,
|
||||
'@stylistic/space-before-function-paren': ['error', 'always'],
|
||||
'@stylistic/max-statements-per-line': ['error', { max: 1, ignoredNodes: ['BreakStatement'] }],
|
||||
'@stylistic/member-delimiter-style': 0,
|
||||
'@stylistic/arrow-parens': 0,
|
||||
'@stylistic/generator-star-spacing': 0,
|
||||
'@stylistic/yield-star-spacing': ['error', 'after'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/pretix/static/pretixcontrol/js/ui/checkinrules/**/*.vue',
|
||||
'src/pretix/plugins/webcheckin/**/*.vue',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
moment: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/pretix/static/pretixpresale/widget/**/*.{ts,vue}',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
LANG: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
4784
package-lock.json
generated
Normal file
4784
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "pretix",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"homepage": "https://github.com/pretix/pretix#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pretix/pretix/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/pretix/pretix.git"
|
||||
},
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"author": "",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:control": "vite",
|
||||
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
|
||||
"build": "npm run build:control -s && npm run build:widget -s",
|
||||
"build:control": "vite build",
|
||||
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/moment": "^2.11.29",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/language-plugin-pug": "^3.2.5",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"eslint-plugin-vue-pug": "^1.0.0-alpha.5",
|
||||
"globals": "^17.4.0",
|
||||
"pug": "^3.0.3",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"stylus": "^0.64.0",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ dev = [
|
||||
"pytest-mock==3.15.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest-playwright",
|
||||
"pytest==9.0.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
@@ -37,4 +37,9 @@ ignore =
|
||||
CONTRIBUTING.md
|
||||
Dockerfile
|
||||
SECURITY.md
|
||||
eslint.config.mjs
|
||||
package-lock.json
|
||||
package.json
|
||||
tsconfig.json
|
||||
vite.config.js
|
||||
|
||||
|
||||
12
src/Makefile
12
src/Makefile
@@ -9,10 +9,10 @@ localegen:
|
||||
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||
|
||||
staticfiles: jsi18n
|
||||
staticfiles: npminstall npmbuild jsi18n
|
||||
./manage.py collectstatic --noinput
|
||||
|
||||
compress: npminstall
|
||||
compress:
|
||||
./manage.py compress
|
||||
|
||||
jsi18n: localecompile
|
||||
@@ -25,8 +25,8 @@ coverage:
|
||||
coverage run -m py.test
|
||||
|
||||
npminstall:
|
||||
# keep this in sync with pretix/_build.py!
|
||||
mkdir -p pretix/static.dist/node_prefix/
|
||||
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
|
||||
npm ci --prefix=pretix/static.dist/node_prefix
|
||||
npm ci
|
||||
|
||||
npmbuild:
|
||||
npm run build
|
||||
|
||||
|
||||
@@ -37,9 +37,11 @@ INSTALLED_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
# pretix needs to go before staticfiles
|
||||
# so we can override the runserver command
|
||||
'pretix.base',
|
||||
'django.contrib.staticfiles',
|
||||
'pretix.control',
|
||||
'pretix.presale',
|
||||
'pretix.multidomain',
|
||||
@@ -243,7 +245,6 @@ STORAGES = {
|
||||
|
||||
COMPRESS_PRECOMPILERS = (
|
||||
('text/x-scss', 'django_libsass.SassCompiler'),
|
||||
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
|
||||
)
|
||||
|
||||
COMPRESS_OFFLINE_CONTEXT = {
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
#
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from setuptools.command.build import build
|
||||
from setuptools.command.build_ext import build_ext
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
project_root = os.path.abspath(os.path.join(here, '..', '..'))
|
||||
npm_installed = False
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ def npm_install():
|
||||
global npm_installed
|
||||
|
||||
if not npm_installed:
|
||||
# keep this in sync with Makefile!
|
||||
node_prefix = os.path.join(here, 'static.dist', 'node_prefix')
|
||||
os.makedirs(node_prefix, exist_ok=True)
|
||||
shutil.copytree(os.path.join(here, 'static', 'npm_dir'), node_prefix, dirs_exist_ok=True)
|
||||
subprocess.check_call('npm ci', shell=True, cwd=node_prefix)
|
||||
subprocess.check_call('npm ci', shell=True, cwd=project_root)
|
||||
npm_installed = True
|
||||
|
||||
|
||||
def npm_build():
|
||||
subprocess.check_call('npm run build', shell=True, cwd=project_root)
|
||||
|
||||
|
||||
class CustomBuild(build):
|
||||
def run(self):
|
||||
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
|
||||
@@ -62,6 +62,7 @@ class CustomBuild(build):
|
||||
settings.COMPRESS_OFFLINE = True
|
||||
|
||||
npm_install()
|
||||
npm_build()
|
||||
management.call_command('compilemessages', verbosity=1)
|
||||
management.call_command('compilejsi18n', verbosity=1)
|
||||
management.call_command('collectstatic', verbosity=1, interactive=False)
|
||||
|
||||
@@ -47,3 +47,5 @@ HAS_MEMCACHED = False
|
||||
HAS_CELERY = False
|
||||
HAS_GEOIP = False
|
||||
SENTRY_ENABLED = False
|
||||
VITE_DEV_MODE = False
|
||||
VITE_IGNORE = False
|
||||
|
||||
@@ -51,6 +51,7 @@ from pretix.base.models import (
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SalesChannel,
|
||||
)
|
||||
from pretix.base.models.items import Questionnaire, QuestionnaireChild
|
||||
|
||||
|
||||
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
@@ -540,6 +541,7 @@ class LegacyDependencyValueField(serializers.CharField):
|
||||
class QuestionSerializer(I18nAwareModelSerializer):
|
||||
options = InlineQuestionOptionSerializer(many=True, required=False)
|
||||
identifier = serializers.CharField(allow_null=True)
|
||||
internal_name = serializers.CharField(allow_null=True, source='question', read_only=True)
|
||||
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
@@ -548,7 +550,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_string_length_max', 'valid_file_portrait')
|
||||
'valid_string_length_max', 'valid_file_portrait', 'internal_name',)
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
@@ -624,6 +626,160 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
return question
|
||||
|
||||
|
||||
class QuestionRefField(serializers.PrimaryKeyRelatedField):
|
||||
def to_representation(self, qc):
|
||||
if not qc:
|
||||
return None
|
||||
elif qc.system_question:
|
||||
return qc.system_question
|
||||
elif qc.user_question_id:
|
||||
return qc.user_question_id
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if type(data) == int:
|
||||
return {'user_question': super().to_internal_value(data), 'system_question': None}
|
||||
elif type(data) == str or data is None:
|
||||
return {'user_question': None, 'system_question': data}
|
||||
else:
|
||||
self.fail('incorrect_type', data_type=type(data).__name__)
|
||||
|
||||
def use_pk_only_optimization(self):
|
||||
return self.source == '*'
|
||||
|
||||
|
||||
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
|
||||
question = QuestionRefField(source='*', queryset=Question.objects.none())
|
||||
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
|
||||
|
||||
class Meta:
|
||||
model = QuestionnaireChild
|
||||
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["question"].queryset = self.context["event"].questions.all()
|
||||
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
event = self.context['event']
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
dep = full_data.get('dependency_question')
|
||||
if dep:
|
||||
if dep.ask_during_checkin:
|
||||
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
|
||||
return data
|
||||
|
||||
def validate_dependency_question(self, value):
|
||||
if value:
|
||||
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
|
||||
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||
if value == self.instance:
|
||||
raise ValidationError('A question cannot depend on itself.')
|
||||
return value
|
||||
|
||||
|
||||
class QuestionnaireSerializer(I18nAwareModelSerializer):
|
||||
limit_sales_channels = serializers.SlugRelatedField(
|
||||
slug_field="identifier",
|
||||
queryset=SalesChannel.objects.none(),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
many=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Questionnaire
|
||||
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'children')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
event = self.context['event']
|
||||
|
||||
#full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
#full_data.update(data)
|
||||
|
||||
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
# raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
#if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
# raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
|
||||
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
|
||||
|
||||
#Question.clean_items(event, full_data.get('items') or [])
|
||||
return data
|
||||
|
||||
def validate_children(self, value):
|
||||
prev_questions = {}
|
||||
for child in value:
|
||||
if child.get('dependency_question'):
|
||||
if (child['dependency_question']['user_question'] or child['dependency_question']['system_question']) not in prev_questions:
|
||||
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
|
||||
|
||||
if child['user_question']:
|
||||
prev_questions[child['user_question']] = child
|
||||
if child['system_question']:
|
||||
prev_questions[child['system_question']] = child
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
children_data = validated_data.pop('children') if 'children' in validated_data else []
|
||||
questionnaire = super().create(validated_data)
|
||||
self.set_children(questionnaire, children_data)
|
||||
return questionnaire
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
children_data = validated_data.pop('children', None)
|
||||
questionnaire = super().update(instance, validated_data)
|
||||
if children_data is not None:
|
||||
self.set_children(questionnaire, children_data)
|
||||
return questionnaire
|
||||
|
||||
def set_children(self, questionnaire, new_data):
|
||||
result = []
|
||||
child_serializer = self.fields['children'].child
|
||||
existing = questionnaire.children.all()
|
||||
for i, d in enumerate(new_data):
|
||||
d['questionnaire'] = questionnaire
|
||||
d['position'] = i + 1
|
||||
d.setdefault('required', False)
|
||||
d.setdefault('help_text', None)
|
||||
d.setdefault('dependency_question', None)
|
||||
d.setdefault('dependency_values', None)
|
||||
updatable = min(len(existing), len(new_data))
|
||||
for i in range(0, updatable):
|
||||
result.append(child_serializer.update(existing[i], new_data[i]))
|
||||
for i in range(updatable, len(new_data)):
|
||||
result.append(child_serializer.create(new_data[i]))
|
||||
for i in range(updatable, len(existing)):
|
||||
existing[i].delete()
|
||||
return result
|
||||
|
||||
|
||||
class QuotaSerializer(I18nAwareModelSerializer):
|
||||
available = serializers.BooleanField(read_only=True)
|
||||
available_number = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -79,7 +79,8 @@ event_router.register(r'subevents', event.SubEventViewSet)
|
||||
event_router.register(r'clone', event.CloneEventViewSet)
|
||||
event_router.register(r'items', item.ItemViewSet)
|
||||
event_router.register(r'categories', item.ItemCategoryViewSet)
|
||||
event_router.register(r'questions', item.QuestionViewSet)
|
||||
event_router.register(r'datafields', item.QuestionViewSet)
|
||||
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
|
||||
event_router.register(r'discounts', discount.DiscountViewSet)
|
||||
event_router.register(r'quotas', item.QuotaViewSet)
|
||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
|
||||
@@ -47,13 +47,14 @@ from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuestionnaireSerializer, QuotaSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
|
||||
ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.models.items import Questionnaire
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.helpers.i18n import i18ncomp
|
||||
@@ -538,6 +539,51 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuestionnaireSerializer
|
||||
queryset = Questionnaire.objects.none()
|
||||
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
#filterset_class = QuestionFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.questionnaires.prefetch_related('children').all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.questionnaire.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.questionnaire.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action(
|
||||
'pretix.event.questionnaire.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
|
||||
pass
|
||||
|
||||
|
||||
59
src/pretix/base/management/commands/runserver.py
Normal file
59
src/pretix/base/management/commands/runserver.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""This command supersedes the Django-inbuilt runserver command.
|
||||
|
||||
It runs the local frontend server, if node is installed and the setting
|
||||
is set.
|
||||
"""
|
||||
import atexit
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.management.commands.runserver import (
|
||||
Command as Parent,
|
||||
)
|
||||
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
def handle(self, *args, **options):
|
||||
# Only start Vite in the non-main process of the autoreloader
|
||||
if settings.VITE_DEV_MODE and os.environ.get(DJANGO_AUTORELOAD_ENV) != "true":
|
||||
# Start the vite server in the background
|
||||
vite_server = subprocess.Popen(
|
||||
["npm", "run", "dev:control"],
|
||||
cwd=Path(__file__).parent.parent.parent.parent.parent
|
||||
)
|
||||
|
||||
def cleanup():
|
||||
vite_server.terminate()
|
||||
try:
|
||||
vite_server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
vite_server.kill()
|
||||
|
||||
atexit.register(cleanup)
|
||||
|
||||
super().handle(*args, **options)
|
||||
@@ -280,11 +280,11 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}'],
|
||||
'script-src': ["{static}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []),
|
||||
'object-src': ["'none'"],
|
||||
'frame-src': ['{static}'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'style-src': ["{static}", "{media}"] + (["'unsafe-inline'"] if settings.VITE_DEV_MODE else []),
|
||||
'connect-src': ["{dynamic}", "{media}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []),
|
||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||
'font-src': ["{static}"] + list(font_src),
|
||||
'media-src': ["{static}", "data:"],
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-19 14:24
|
||||
import json
|
||||
from collections import namedtuple
|
||||
from itertools import chain, groupby
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
FakeQuestion = namedtuple(
|
||||
'FakeQuestion', 'id question position required'
|
||||
)
|
||||
|
||||
|
||||
def get_fake_questions(settings):
|
||||
def b(s):
|
||||
return s == 'True'
|
||||
fq = []
|
||||
sqo = json.loads(settings.get('system_question_order', '{}'))
|
||||
_ = LazyI18nString.from_gettext
|
||||
|
||||
if b(settings.get('attendee_names_asked', 'True')):
|
||||
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required'))))
|
||||
|
||||
if b(settings.get('attendee_emails_asked')):
|
||||
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required'))))
|
||||
|
||||
if b(settings.get('attendee_company_asked')):
|
||||
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required'))))
|
||||
|
||||
if b(settings.get('attendee_addresses_asked')):
|
||||
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
|
||||
return fq
|
||||
|
||||
|
||||
def migrate_questions_forward(apps, schema_editor):
|
||||
Event = apps.get_model("pretixbase", "Event")
|
||||
Item = apps.get_model("pretixbase", "Item")
|
||||
Question = apps.get_model("pretixbase", "Question")
|
||||
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
|
||||
QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild")
|
||||
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||
|
||||
for event in Event.objects.iterator():
|
||||
# get relevant settings
|
||||
settings = {
|
||||
setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=(
|
||||
'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required',
|
||||
'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required',
|
||||
))
|
||||
}
|
||||
|
||||
# get all questions (user-defined and system provided), along with the products for which they're asked
|
||||
questions = event.questions.all()
|
||||
children = sorted(chain((
|
||||
(item, q.position, q.id, q)
|
||||
for q in questions
|
||||
for item in q.items.values_list('id', 'internal_name', 'name')
|
||||
), (
|
||||
(item, q.position, None, q)
|
||||
for q in get_fake_questions(settings)
|
||||
for item in event.items.filter(personalized=True).values_list('id', 'internal_name', 'name')
|
||||
)), key=lambda t: (t[0], t[1], t[2]))
|
||||
|
||||
# group by item, creating a unique questionnaire per item
|
||||
item_questionnaires = (([t[3] for t in children], item_id) for item_id, children in groupby(children, key=lambda t: t[0]))
|
||||
|
||||
# group again, merging all questionnaires with identical children
|
||||
merged_questionnaires = groupby(sorted(item_questionnaires, key=lambda t: [q.id for q in t[0]]), key=lambda t: t[0])
|
||||
for children, iterator in merged_questionnaires:
|
||||
items = [item for _c, item in iterator]
|
||||
|
||||
# create questionnaires and children
|
||||
questionnaire = Questionnaire.objects.create(
|
||||
event=event, type='PS', position=0, all_sales_channels=True,
|
||||
internal_name=', '.join(str(iname or name) for (id, iname, name) in items)
|
||||
)
|
||||
questionnaire.items.set([id for (id, iname, name) in items])
|
||||
deps = {}
|
||||
for position, child in enumerate(children):
|
||||
if isinstance(child, FakeQuestion):
|
||||
QuestionnaireChild.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
system_question=child.id,
|
||||
required=child.required,
|
||||
label=child.question,
|
||||
)
|
||||
else:
|
||||
deps[child.id] = QuestionnaireChild.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
user_question=child,
|
||||
required=child.required,
|
||||
label=child.question,
|
||||
help_text=child.help_text,
|
||||
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
|
||||
dependency_values=child.dependency_values,
|
||||
)
|
||||
|
||||
|
||||
def migrate_questions_backward(apps, schema_editor):
|
||||
pass # as long as we don't delete the old columns, this is a no op. after that, it gets complicated...
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0298_pluggable_permissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Questionnaire',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('internal_name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=5)),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('all_sales_channels', models.BooleanField(default=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')),
|
||||
('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')),
|
||||
('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QuestionnaireChild',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('system_question', models.CharField(max_length=25, null=True)),
|
||||
('required', models.BooleanField(default=False)),
|
||||
('label', i18nfield.fields.I18nTextField()),
|
||||
('help_text', i18nfield.fields.I18nTextField(null=True)),
|
||||
('dependency_values', pretix.base.models.fields.MultiStringField(default=[])),
|
||||
('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairechild')),
|
||||
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='pretixbase.questionnaire')),
|
||||
('user_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='references', to='pretixbase.question')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_questions_forward,
|
||||
migrate_questions_backward,
|
||||
),
|
||||
# TODO remove old columns from Question model
|
||||
]
|
||||
@@ -1595,10 +1595,12 @@ class ItemBundle(models.Model):
|
||||
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
A question is an input field that can be used to extend a ticket by custom information,
|
||||
e.g. "Attendee age". The answers are found next to the position. The answers may be found
|
||||
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
|
||||
several input types, currently:
|
||||
A question is a data field that can be used to extend an order or a ticket by custom
|
||||
information, e.g. "Attendee age". To be actually useful, questions need to be added to
|
||||
one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached
|
||||
to Orders, OrderPositions or CartPositions.
|
||||
|
||||
A question can allow one of several input types, currently:
|
||||
|
||||
* a number (``TYPE_NUMBER``)
|
||||
* a one-line string (``TYPE_STRING``)
|
||||
@@ -1667,7 +1669,7 @@ class Question(LoggedModel):
|
||||
related_name="questions",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
question = I18nTextField(
|
||||
question = I18nTextField( # to be renamed to 'internal_name'
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
identifier = models.CharField(
|
||||
@@ -1682,7 +1684,7 @@ class Question(LoggedModel):
|
||||
),
|
||||
],
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
help_text = I18nTextField( # to be removed
|
||||
verbose_name=_("Help text"),
|
||||
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||
null=True, blank=True,
|
||||
@@ -1692,22 +1694,22 @@ class Question(LoggedModel):
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name=_("Question type")
|
||||
)
|
||||
required = models.BooleanField(
|
||||
required = models.BooleanField( # to be removed, -> QuestionnaireChild
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
items = models.ManyToManyField( # to be removed, -> Questionnaire
|
||||
Item,
|
||||
related_name='questions',
|
||||
verbose_name=_("Products"),
|
||||
blank=True,
|
||||
help_text=_('This question will be asked to buyers of the selected products')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
position = models.PositiveIntegerField( # to be removed, -> Questionnaire + QuestionnaireChild
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
ask_during_checkin = models.BooleanField( # to be removed
|
||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False
|
||||
@@ -1717,7 +1719,7 @@ class Question(LoggedModel):
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
hidden = models.BooleanField( # to be removed
|
||||
verbose_name=_('Hidden question'),
|
||||
help_text=_('This question will only show up in the backend.'),
|
||||
default=False
|
||||
@@ -1726,10 +1728,10 @@ class Question(LoggedModel):
|
||||
verbose_name=_('Print answer on invoices'),
|
||||
default=False
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
dependency_question = models.ForeignKey( # to be removed, -> QuestionnaireChild
|
||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_values = MultiStringField(default=[])
|
||||
dependency_values = MultiStringField(default=[]) # to be removed, -> QuestionnaireChild
|
||||
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
|
||||
verbose_name=_('Minimum value'),
|
||||
help_text=_('Currently not supported in our apps and during check-in'))
|
||||
@@ -1763,9 +1765,9 @@ class Question(LoggedModel):
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Question")
|
||||
verbose_name_plural = _("Questions")
|
||||
ordering = ('position', 'id')
|
||||
verbose_name = _("Data field")
|
||||
verbose_name_plural = _("Data fields")
|
||||
ordering = ('question', 'id')
|
||||
unique_together = (('event', 'identifier'),)
|
||||
|
||||
def __str__(self):
|
||||
@@ -1990,6 +1992,103 @@ class QuestionOption(models.Model):
|
||||
ordering = ('position', 'id')
|
||||
|
||||
|
||||
class Questionnaire(LoggedModel):
|
||||
TYPE_ORDER_SALE = "OS"
|
||||
TYPE_ORDER_POSITION_SALE = "PS"
|
||||
TYPE_ORDER_POSITION_ATTENDEE_ONLY = "PA"
|
||||
TYPE_ORDER_POSITION_CHECKIN = "PC"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_ORDER_SALE, _("Order-wide, before purchase")),
|
||||
(TYPE_ORDER_POSITION_SALE, _("Per product, before purchase")),
|
||||
(TYPE_ORDER_POSITION_ATTENDEE_ONLY, _("Per product, via attendee link")),
|
||||
(TYPE_ORDER_POSITION_CHECKIN, _("Per product, at check-in")),
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
related_name="questionnaires",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
internal_name = models.CharField(
|
||||
verbose_name=_("Internal name"),
|
||||
max_length=255,
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=5,
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name=_("Questionnaire type")
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
Item,
|
||||
related_name='questionnaires',
|
||||
verbose_name=_("Products"),
|
||||
blank=True,
|
||||
help_text=_('This questionnaire will be asked to buyers of the selected products')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
all_sales_channels = models.BooleanField(
|
||||
verbose_name=_("Sell on all sales channels the product is sold on"),
|
||||
default=True,
|
||||
)
|
||||
limit_sales_channels = models.ManyToManyField(
|
||||
"SalesChannel",
|
||||
verbose_name=_("Restrict to specific sales channels"),
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
|
||||
class QuestionnaireChild(LoggedModel):
|
||||
SYSTEM_QUESTION_CHOICES = (
|
||||
('attendee_name_parts', _('Attendee name')),
|
||||
('attendee_email', _('Attendee email')),
|
||||
('company', _('Company')),
|
||||
('street', _('Street')),
|
||||
('zipcode', _('ZIP code')),
|
||||
('city', _('City')),
|
||||
('country', _('Country')),
|
||||
)
|
||||
questionnaire = models.ForeignKey(
|
||||
Questionnaire,
|
||||
related_name="children",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
user_question = models.ForeignKey(
|
||||
Question,
|
||||
related_name="references",
|
||||
on_delete=models.CASCADE,
|
||||
null=True, blank=True,
|
||||
)
|
||||
system_question = models.CharField(
|
||||
max_length=25,
|
||||
choices=SYSTEM_QUESTION_CHOICES,
|
||||
null=True, blank=True,
|
||||
)
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
label = I18nTextField(
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
verbose_name=_("Help text"),
|
||||
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||
null=True, blank=True,
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_values = MultiStringField(default=[])
|
||||
|
||||
|
||||
class Quota(LoggedModel):
|
||||
"""
|
||||
A quota is a "pool of tickets". It is there to limit the number of items
|
||||
|
||||
File diff suppressed because one or more lines are too long
113
src/pretix/base/templatetags/vite.py
Normal file
113
src/pretix/base/templatetags/vite.py
Normal file
@@ -0,0 +1,113 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
_MANIFEST = {}
|
||||
# TODO more os.path.join ?
|
||||
MANIFEST_PATH = settings.STATIC_ROOT + "/vite/control/.vite/manifest.json"
|
||||
MANIFEST_BASE = "vite/control/"
|
||||
|
||||
# We're building the manifest if we don't have a dev server running AND if we're
|
||||
# not currently running `rebuild` (which creates the manifest in the first place).
|
||||
if not settings.VITE_DEV_MODE and not settings.VITE_IGNORE:
|
||||
try:
|
||||
with open(MANIFEST_PATH) as fp:
|
||||
_MANIFEST = json.load(fp)
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"Error reading vite manifest at {MANIFEST_PATH}: {str(e)}")
|
||||
|
||||
|
||||
def generate_script_tag(path, attrs):
|
||||
all_attrs = " ".join(f'{key}="{value}"' for key, value in attrs.items())
|
||||
if settings.VITE_DEV_MODE:
|
||||
src = urljoin(settings.VITE_DEV_SERVER, path)
|
||||
else:
|
||||
src = urljoin(settings.STATIC_URL, path)
|
||||
return f'<script {all_attrs} src="{src}"></script>'
|
||||
|
||||
|
||||
def generate_css_tags(asset, already_processed=None):
|
||||
"""Recursively builds all CSS tags used in a given asset.
|
||||
|
||||
Ignore the side effects."""
|
||||
tags = []
|
||||
manifest_entry = _MANIFEST[asset]
|
||||
if already_processed is None:
|
||||
already_processed = []
|
||||
|
||||
# Put our own CSS file first for specificity
|
||||
if "css" in manifest_entry:
|
||||
for css_path in manifest_entry["css"]:
|
||||
if css_path not in already_processed:
|
||||
full_path = urljoin(settings.STATIC_URL, MANIFEST_BASE + css_path)
|
||||
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
|
||||
already_processed.append(css_path)
|
||||
|
||||
# Import each file only one by way of side effects in already_processed
|
||||
if "imports" in manifest_entry:
|
||||
for import_path in manifest_entry["imports"]:
|
||||
tags += generate_css_tags(import_path, already_processed)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
@mark_safe
|
||||
def vite_asset(path):
|
||||
"""
|
||||
Generates one <script> tag and <link> tags for each of the CSS dependencies.
|
||||
"""
|
||||
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
if settings.VITE_DEV_MODE:
|
||||
return generate_script_tag(path, {"type": "module"})
|
||||
|
||||
manifest_entry = _MANIFEST.get(path)
|
||||
if not manifest_entry:
|
||||
raise RuntimeError(f"Cannot find {path} in Vite manifest at {MANIFEST_PATH}")
|
||||
|
||||
tags = generate_css_tags(path)
|
||||
tags.append(
|
||||
generate_script_tag(
|
||||
MANIFEST_BASE + manifest_entry["file"], {"type": "module", "crossorigin": ""}
|
||||
)
|
||||
)
|
||||
return "".join(tags)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
@mark_safe
|
||||
def vite_hmr():
|
||||
if not settings.VITE_DEV_MODE:
|
||||
return ""
|
||||
return generate_script_tag("@vite/client", {"type": "module"})
|
||||
@@ -104,6 +104,12 @@ class GlobalSettingsForm(SettingsForm):
|
||||
help_text=_("Will be served at {domain}/.well-known/apple-developer-merchantid-domain-association").format(
|
||||
domain=settings.SITE_URL
|
||||
)
|
||||
)),
|
||||
('widget_vite_origins', forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': '3'}),
|
||||
required=False,
|
||||
label=_("Vite widget origins"),
|
||||
help_text=_("One origin per line (e.g. https://example.com). Requests from these origins will be served the new vite-based widget."),
|
||||
))
|
||||
])
|
||||
responses = register_global_settings.send(self)
|
||||
|
||||
@@ -850,6 +850,9 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
|
||||
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
|
||||
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
|
||||
'pretix.event.question.option.changed': _('An answer option has been changed.'),
|
||||
'pretix.event.questionnaire.added': _('A questionnaire has been created.'),
|
||||
'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'),
|
||||
'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'),
|
||||
'pretix.event.permissions.added': _('A user has been added to the event team.'),
|
||||
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
||||
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
||||
|
||||
@@ -182,12 +182,12 @@ def get_event_navigation(request: HttpRequest):
|
||||
'active': 'event.items.categories' in url.url_name,
|
||||
},
|
||||
{
|
||||
'label': _('Questions'),
|
||||
'url': reverse('control:event.items.questions', kwargs={
|
||||
'label': _('Questionnaires'),
|
||||
'url': reverse('control:event.items.questionnaires', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': 'event.items.questions' in url.url_name,
|
||||
'active': 'event.items.questionnaires' in url.url_name or 'event.items.questions' in url.url_name,
|
||||
},
|
||||
{
|
||||
'label': _('Discounts'),
|
||||
@@ -363,7 +363,7 @@ def get_global_navigation(request):
|
||||
'icon': 'dashboard',
|
||||
},
|
||||
]
|
||||
if request.user.is_in_any_teams:
|
||||
if request.user.is_in_any_teams or request.user.is_staff:
|
||||
nav += [
|
||||
{
|
||||
'label': _('Events'),
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
<h3>{% trans "Custom check-in rule" %}</h3>
|
||||
<div id="rules-editor" class="form-inline">
|
||||
<div>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="active">
|
||||
<a href="#rules-edit" role="tab" data-toggle="tab">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#rules-viz" role="tab" data-toggle="tab">
|
||||
<span class="fa fa-eye"></span>
|
||||
{% trans "Visualize" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="rules-edit">
|
||||
<checkin-rules-editor></checkin-rules-editor>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="rules-viz">
|
||||
<checkin-rules-visualization></checkin-rules-visualization>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="alert alert-info" v-if="missingItems.length">
|
||||
<p>
|
||||
{% trans "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:" %}
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="h in missingItems">{{ "{" }}{h}{{ "}" }}</li>
|
||||
</ul>
|
||||
<p>
|
||||
{% trans "Please double-check if this was intentional." %}
|
||||
</p>
|
||||
</div>
|
||||
<div id="rules-editor">
|
||||
<!-- Vue app mount point -->
|
||||
</div>
|
||||
<div class="disabled-withoutjs sr-only">
|
||||
{{ form.rules }}
|
||||
@@ -127,11 +91,6 @@
|
||||
</form>
|
||||
{{ items|json_script:"items" }}
|
||||
|
||||
{% if DEBUG %}
|
||||
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
|
||||
{% else %}
|
||||
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
|
||||
{% endif %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script>
|
||||
@@ -144,15 +103,6 @@
|
||||
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/lookup-select2.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rule.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% load getitem %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
{% load vite %}
|
||||
{% block title %}{% trans "Check-in simulator" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>
|
||||
@@ -124,11 +125,9 @@
|
||||
{% endif %}
|
||||
{% if result.rule_graph %}
|
||||
<div id="rules-editor" class="form-inline">
|
||||
<div role="tabpanel" class="tab-pane" id="rules-viz">
|
||||
<checkin-rules-visualization></checkin-rules-visualization>
|
||||
</div>
|
||||
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
|
||||
<!-- Vue app mount point -->
|
||||
</div>
|
||||
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,10 +151,6 @@
|
||||
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,142 +12,113 @@
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
{% if question %}
|
||||
<h1>{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}</h1>
|
||||
<h1>{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}</h1>
|
||||
{% else %}
|
||||
<h1>{% trans "Question" %}</h1>
|
||||
<h1>{% trans "Data field" %}</h1>
|
||||
{% endif %}
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<div class="tabbed-form">
|
||||
<fieldset>
|
||||
<legend>{% trans "General" %}</legend>
|
||||
{% bootstrap_field form.question layout="control" %}
|
||||
{% bootstrap_field form.type layout="control" %}
|
||||
{% bootstrap_field form.items layout="control" %}
|
||||
{% bootstrap_field form.required layout="control" %}
|
||||
<div class="alert alert-info alert-required-boolean">
|
||||
{% blocktrans trimmed %}
|
||||
If you mark a Yes/No question as required, it means that the user has to select Yes and No is not
|
||||
accepted. If you want to allow both options, do not make this field required.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div id="valid-number">
|
||||
{% bootstrap_field form.valid_number_min layout="control" %}
|
||||
{% bootstrap_field form.valid_number_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-date">
|
||||
{% bootstrap_field form.valid_date_min layout="control" %}
|
||||
{% bootstrap_field form.valid_date_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-datetime">
|
||||
{% bootstrap_field form.valid_datetime_min layout="control" %}
|
||||
{% bootstrap_field form.valid_datetime_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-string">
|
||||
{% bootstrap_field form.valid_string_length_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-file">
|
||||
{% bootstrap_field form.valid_file_portrait layout="control" %}
|
||||
</div>
|
||||
<div id="answer-options">
|
||||
<h3>{% trans "Answer options" %}</h3>
|
||||
<noscript>
|
||||
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
|
||||
</noscript>
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for form in formset %}
|
||||
<div data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ form.id }}
|
||||
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="row question-option-row">
|
||||
<div class="col-xs-10">
|
||||
<span class="text-muted">
|
||||
{% blocktrans trimmed with id=form.instance.identifier %}
|
||||
Answer option {{ id }}
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right flip">
|
||||
<span> </span><br>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ formset.empty_form.id }}
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="row question-option-row">
|
||||
<div class="col-xs-10">
|
||||
<span class="text-muted">
|
||||
{% trans "New answer option" %}
|
||||
</span>
|
||||
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right flip">
|
||||
<span> </span><br>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Advanced" %}</legend>
|
||||
{% bootstrap_field form.help_text layout="control" %}
|
||||
{% bootstrap_field form.identifier layout="control" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_dependency_question">
|
||||
{% trans "Question dependency" %}
|
||||
<br><span class="optional">{% trans "Optional" context "form" %}</span>
|
||||
</label>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field form.dependency_question layout="inline" form_group_class="inner" %}
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<script type="text/plain" id="dependency_value_val">{{ form.instance.dependency_values|escapejson_dumps }}</script>
|
||||
{% bootstrap_field form.dependency_values layout="inline" form_group_class="inner" %}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{% bootstrap_field form.question layout="control" %}
|
||||
{% bootstrap_field form.type layout="control" %}
|
||||
<div id="valid-number">
|
||||
{% bootstrap_field form.valid_number_min layout="control" %}
|
||||
{% bootstrap_field form.valid_number_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-date">
|
||||
{% bootstrap_field form.valid_date_min layout="control" %}
|
||||
{% bootstrap_field form.valid_date_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-datetime">
|
||||
{% bootstrap_field form.valid_datetime_min layout="control" %}
|
||||
{% bootstrap_field form.valid_datetime_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-string">
|
||||
{% bootstrap_field form.valid_string_length_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-file">
|
||||
{% bootstrap_field form.valid_file_portrait layout="control" %}
|
||||
</div>
|
||||
<div id="answer-options">
|
||||
<h3>{% trans "Answer options" %}</h3>
|
||||
<noscript>
|
||||
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
|
||||
</noscript>
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for form in formset %}
|
||||
<div data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ form.id }}
|
||||
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="row question-option-row">
|
||||
<div class="col-xs-10">
|
||||
<span class="text-muted">
|
||||
{% blocktrans trimmed with id=form.instance.identifier %}
|
||||
Answer option {{ id }}
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right flip">
|
||||
<span> </span><br>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ formset.empty_form.id }}
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="row question-option-row">
|
||||
<div class="col-xs-10">
|
||||
<span class="text-muted">
|
||||
{% trans "New answer option" %}
|
||||
</span>
|
||||
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right flip">
|
||||
<span> </span><br>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_field form.identifier layout="control" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load static %}
|
||||
{% load icon %}
|
||||
{% load compress %}
|
||||
{% load vite %}
|
||||
|
||||
{% block title %}
|
||||
{% trans "Questionnaires" %}
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>
|
||||
{% trans "Questionnaires" %}
|
||||
<a href="{% url "control:event.items.questions" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default pull-right">
|
||||
{% icon "wrench" %} {% trans "Manage data fields" %}
|
||||
</a>
|
||||
</h1>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
|
||||
example might be to ask your users about dietary requirements.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<div id="questionnaires-editor">
|
||||
<!-- Vue app mount point -->
|
||||
</div>
|
||||
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts" %}
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Questions" %}{% endblock %}
|
||||
{% block title %}{% trans "Data fields" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Questions" %}</h1>
|
||||
<h1>{% trans "Data fields" %}</h1>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
|
||||
@@ -12,7 +12,7 @@
|
||||
{% csrf_token %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new data field" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -20,39 +20,27 @@
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Question" %}</th>
|
||||
<th>{% trans "Internal name" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th>{% trans "Products" %}</th>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<th class="action-col-2"></th>
|
||||
{% endif %}
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
<tbody>
|
||||
{% for q in questions %}
|
||||
<tr data-dnd-id="{{ q.id }}">
|
||||
<tr>
|
||||
<td>
|
||||
<strong>
|
||||
{% if q.pk %}
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
|
||||
{% endif %}
|
||||
{{ q.question }}
|
||||
{% if q.pk %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
|
||||
{{ q.question }}
|
||||
</a>
|
||||
</strong><br>
|
||||
<small class="text-muted">{{ q.identifier }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk %}
|
||||
{{ q.get_type_display }}
|
||||
{% else %}
|
||||
{% trans "System question" %}
|
||||
{% endif %}
|
||||
{{ q.get_type_display }}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.required %}
|
||||
@@ -63,42 +51,17 @@
|
||||
{% if q.pk and q.ask_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.hidden %}
|
||||
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk %}
|
||||
<ul>
|
||||
{% for item in q.items.all %}
|
||||
<li>
|
||||
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<small>{% trans "All personalized products" %}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<td class="dnd-container">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right flip">
|
||||
{% if q.pk %}
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if 'event.settings.general:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -348,6 +348,7 @@ urlpatterns = [
|
||||
re_path(r'^questions/(?P<question>\d+)/change$', item.QuestionUpdate.as_view(),
|
||||
name='event.items.questions.edit'),
|
||||
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
|
||||
re_path(r'^questionnaires/$', item.QuestionnairesEditor.as_view(), name='event.items.questionnaires'),
|
||||
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
|
||||
re_path(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'),
|
||||
re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'),
|
||||
|
||||
@@ -641,7 +641,7 @@ def user_index(request):
|
||||
|
||||
ctx = {
|
||||
'widgets': rearrange(widgets),
|
||||
'can_create_event': request.user.teams.with_organizer_permission("organizer.events:create").exists(),
|
||||
'can_create_event': request.user.teams.with_organizer_permission("organizer.events:create").exists() or request.user.is_staff,
|
||||
'upcoming': widgets_for_event_qs(
|
||||
request,
|
||||
annotated_event_query(request, lazy=True).filter(
|
||||
|
||||
@@ -55,7 +55,7 @@ from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic import ListView, TemplateView
|
||||
from django.views.generic.detail import DetailView, SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
|
||||
@@ -435,87 +435,7 @@ class QuestionList(ListView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
questions = []
|
||||
|
||||
if self.request.event.settings.attendee_names_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='attendee_name_parts',
|
||||
question=_('Attendee name'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'attendee_name_parts', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_names_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_emails_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='attendee_email',
|
||||
question=_('Attendee email'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'attendee_email', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_emails_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_company_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='company',
|
||||
question=_('Company'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'company', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_company_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_addresses_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='street',
|
||||
question=_('Street'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'street', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='zipcode',
|
||||
question=_('ZIP code'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'zipcode', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='city',
|
||||
question=_('City'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'city', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='country',
|
||||
question=_('Country'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'country', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
|
||||
questions += list(ctx['questions'])
|
||||
questions = list(ctx['questions'])
|
||||
questions.sort(key=lambda q: q.position)
|
||||
ctx['questions'] = questions
|
||||
return ctx
|
||||
@@ -831,6 +751,11 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
|
||||
return ret
|
||||
|
||||
|
||||
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
|
||||
permission = 'can_change_items'
|
||||
template_name = 'pretixcontrol/items/questionnaires.html'
|
||||
|
||||
|
||||
class QuotaList(PaginationMixin, ListView):
|
||||
model = Quota
|
||||
context_object_name = 'quotas'
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
||||
from compressor.exceptions import FilterError
|
||||
from compressor.filters import CompilerFilter
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class VueCompiler(CompilerFilter):
|
||||
# Based on work (c) Laura Klünder in https://github.com/codingcatgirl/django-vue-rollup
|
||||
# Released under Apache License 2.0
|
||||
|
||||
def __init__(self, content, attrs, **kwargs):
|
||||
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'static', 'npm_dir')
|
||||
node_path = os.path.join(settings.STATIC_ROOT, 'node_prefix', 'node_modules')
|
||||
self.rollup_bin = os.path.join(node_path, 'rollup', 'dist', 'bin', 'rollup')
|
||||
rollup_config = os.path.join(config_dir, 'rollup.config.js')
|
||||
if not os.path.exists(self.rollup_bin) and not settings.DEBUG:
|
||||
raise FilterError("Rollup not installed or pretix not built properly, please run 'make npminstall' in source root.")
|
||||
command = (
|
||||
' '.join((
|
||||
'NODE_PATH=' + shlex.quote(node_path),
|
||||
shlex.quote(self.rollup_bin),
|
||||
'-c',
|
||||
shlex.quote(rollup_config))
|
||||
) +
|
||||
' --input {infile} -n {export_name} --file {outfile}'
|
||||
)
|
||||
super().__init__(content, command=command, **kwargs)
|
||||
|
||||
def input(self, **kwargs):
|
||||
if self.filename is None:
|
||||
raise FilterError('VueCompiler can only compile files, not inline code.')
|
||||
if not os.path.exists(self.rollup_bin):
|
||||
raise FilterError("Rollup not installed, please run 'make npminstall' in source root.")
|
||||
self.options += (('export_name', re.sub(
|
||||
r'^([a-z])|[^a-z0-9A-Z]+([a-zA-Z0-9])?',
|
||||
lambda s: s.group(0)[-1].upper(),
|
||||
os.path.basename(self.filename).split('.')[0]
|
||||
)),)
|
||||
return super().input(**kwargs)
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { I18nString, SubEvent } from './i18n'
|
||||
|
||||
const settingsEl = document.getElementById('api-settings')
|
||||
const { urls } = JSON.parse(settingsEl.textContent || '{}') as { urls: {
|
||||
lists: string
|
||||
questions: string
|
||||
} }
|
||||
|
||||
// interfaces generated from api docs
|
||||
export interface PaginatedResponse<T> {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: T[]
|
||||
}
|
||||
|
||||
export interface CheckinList {
|
||||
id: number
|
||||
name: string
|
||||
all_products: boolean
|
||||
limit_products: number[]
|
||||
subevent: SubEvent | null
|
||||
position_count?: number
|
||||
checkin_count?: number
|
||||
include_pending: boolean
|
||||
allow_multiple_entries: boolean
|
||||
allow_entry_after_exit: boolean
|
||||
rules: Record<string, unknown>
|
||||
exit_all_at: string | null
|
||||
addon_match: boolean
|
||||
ignore_in_statistics?: boolean
|
||||
consider_tickets_used?: boolean
|
||||
}
|
||||
|
||||
export interface Checkin {
|
||||
id: number
|
||||
list: number
|
||||
datetime: string
|
||||
type: 'entry' | 'exit'
|
||||
gate: number | null
|
||||
device: number | null
|
||||
device_id: number | null
|
||||
auto_checked_in: boolean
|
||||
}
|
||||
|
||||
export interface Seat {
|
||||
id: number
|
||||
name: string
|
||||
zone_name: string
|
||||
row_name: string
|
||||
row_label: string | null
|
||||
seat_number: string
|
||||
seat_label: string | null
|
||||
seat_guid: string
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
id: number
|
||||
order: string
|
||||
positionid: number
|
||||
canceled?: boolean
|
||||
item: { id?: number; name: I18nString; internal_name?: string; admission?: boolean }
|
||||
variation: { id?: number; value: I18nString } | null
|
||||
price: string
|
||||
attendee_name: string
|
||||
attendee_name_parts: Record<string, string>
|
||||
attendee_email: string | null
|
||||
company?: string | null
|
||||
street?: string | null
|
||||
zipcode?: string | null
|
||||
city?: string | null
|
||||
country?: string | null
|
||||
state?: string | null
|
||||
voucher?: number | null
|
||||
voucher_budget_use?: string | null
|
||||
tax_rate: string
|
||||
tax_value: string
|
||||
tax_code?: string | null
|
||||
tax_rule: number | null
|
||||
secret: string
|
||||
addon_to: number | null
|
||||
subevent: SubEvent | null
|
||||
discount?: number | null
|
||||
blocked: string[] | null
|
||||
valid_from: string | null
|
||||
valid_until: string | null
|
||||
pseudonymization_id: string
|
||||
seat: Seat | null
|
||||
checkins: Checkin[]
|
||||
downloads?: { output: string; url: string }[]
|
||||
answers: Answer[]
|
||||
pdf_data?: Record<string, unknown>
|
||||
plugin_data?: Record<string, unknown>
|
||||
// Additional fields from checkin list positions endpoint
|
||||
order__status?: string
|
||||
order__valid_if_pending?: boolean
|
||||
order__require_approval?: boolean
|
||||
order__locale?: string
|
||||
require_attention?: boolean
|
||||
addons?: Addon[]
|
||||
}
|
||||
|
||||
export interface Answer {
|
||||
question: number | AnswerQuestion
|
||||
answer: string
|
||||
question_identifier: string
|
||||
options: number[]
|
||||
option_identifiers: string[]
|
||||
}
|
||||
|
||||
export interface AnswerQuestion {
|
||||
id: number
|
||||
question: I18nString
|
||||
help_text?: I18nString
|
||||
type: string
|
||||
required: boolean
|
||||
position: number
|
||||
items: number[]
|
||||
identifier: string
|
||||
ask_during_checkin: boolean
|
||||
show_during_checkin: boolean
|
||||
hidden?: boolean
|
||||
print_on_invoice?: boolean
|
||||
options: QuestionOption[]
|
||||
valid_number_min?: string | null
|
||||
valid_number_max?: string | null
|
||||
valid_date_min?: string | null
|
||||
valid_date_max?: string | null
|
||||
valid_datetime_min?: string | null
|
||||
valid_datetime_max?: string | null
|
||||
valid_file_portrait?: boolean
|
||||
valid_string_length_max?: number | null
|
||||
dependency_question?: number | null
|
||||
dependency_values?: string[]
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
id: number
|
||||
identifier: string
|
||||
position: number
|
||||
answer: I18nString
|
||||
}
|
||||
|
||||
export interface Addon {
|
||||
item: { name: I18nString; internal_name?: string }
|
||||
variation: { value: I18nString } | null
|
||||
}
|
||||
|
||||
export interface CheckinStatusVariation {
|
||||
id: number
|
||||
value: string
|
||||
checkin_count: number
|
||||
position_count: number
|
||||
}
|
||||
|
||||
export interface CheckinStatusItem {
|
||||
id: number
|
||||
name: string
|
||||
checkin_count: number
|
||||
admission: boolean
|
||||
position_count: number
|
||||
variations: CheckinStatusVariation[]
|
||||
}
|
||||
|
||||
export interface CheckinStatus {
|
||||
checkin_count: number
|
||||
position_count: number
|
||||
inside_count: number
|
||||
event?: { name: string }
|
||||
items?: CheckinStatusItem[]
|
||||
}
|
||||
|
||||
export interface RedeemRequest {
|
||||
questions_supported: boolean
|
||||
canceled_supported: boolean
|
||||
ignore_unpaid: boolean
|
||||
type: 'entry' | 'exit'
|
||||
answers: Record<string, string>
|
||||
datetime?: string | null
|
||||
force?: boolean
|
||||
nonce?: string
|
||||
}
|
||||
|
||||
export interface RedeemResponseList {
|
||||
id: number
|
||||
name: string
|
||||
event: string
|
||||
subevent: number | null
|
||||
include_pending: boolean
|
||||
}
|
||||
|
||||
export interface RedeemResponse {
|
||||
status: 'ok' | 'error' | 'incomplete'
|
||||
reason?: 'invalid' | 'unpaid' | 'blocked' | 'invalid_time' | 'canceled' | 'already_redeemed' | 'product' | 'rules' | 'ambiguous' | 'revoked' | 'unapproved' | 'error'
|
||||
reason_explanation?: string | null
|
||||
position?: Position
|
||||
questions?: AnswerQuestion[]
|
||||
checkin_texts?: string[]
|
||||
require_attention?: boolean
|
||||
list?: RedeemResponseList
|
||||
}
|
||||
|
||||
const CSRF_TOKEN = document.querySelector<HTMLInputElement>('input[name=csrfmiddlewaretoken]')?.value ?? ''
|
||||
|
||||
function handleAuthError (response: Response): void {
|
||||
if ([401, 403].includes(response.status)) {
|
||||
window.location.href = '/control/login?next=' + encodeURIComponent(
|
||||
window.location.pathname + window.location.search + window.location.hash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// generic fetch wrapper, not sure if this should be exposed
|
||||
async fetch <T> (url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, options)
|
||||
handleAuthError(response)
|
||||
if (!response.ok && response.status !== 400 && response.status !== 404) {
|
||||
throw new Error('HTTP status ' + response.status)
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
async fetchCheckinLists (endsAfter?: string): Promise<PaginatedResponse<CheckinList>> {
|
||||
const cutoff = endsAfter ?? moment().subtract(8, 'hours').toISOString()
|
||||
const url = `${urls.lists}?exclude=checkin_count&exclude=position_count&expand=subevent&ends_after=${cutoff}`
|
||||
return api.fetch(url)
|
||||
},
|
||||
async fetchCheckinList (listId: string): Promise<CheckinList> {
|
||||
return api.fetch(`${urls.lists}${listId}/?expand=subevent`)
|
||||
},
|
||||
async fetchNextPage<T> (nextUrl: string): Promise<PaginatedResponse<T>> {
|
||||
return api.fetch(nextUrl)
|
||||
},
|
||||
async fetchStatus (listId: number): Promise<CheckinStatus> {
|
||||
return api.fetch(`${urls.lists}${listId}/status/`)
|
||||
},
|
||||
async searchPositions (listId: number, query: string): Promise<PaginatedResponse<Position>> {
|
||||
const url = `${urls.lists}${listId}/positions/?ignore_status=true&expand=subevent&expand=item&expand=variation&check_rules=true&search=${encodeURIComponent(query)}`
|
||||
return api.fetch(url)
|
||||
},
|
||||
async redeemPosition (
|
||||
listId: number,
|
||||
positionId: string,
|
||||
data: RedeemRequest,
|
||||
untrusted: boolean = false
|
||||
): Promise<RedeemResponse> {
|
||||
let url = `${urls.lists}${listId}/positions/${encodeURIComponent(positionId)}/redeem/?expand=item&expand=subevent&expand=variation&expand=answers.question&expand=addons`
|
||||
if (untrusted) url += '&untrusted_input=true'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': CSRF_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
handleAuthError(response)
|
||||
|
||||
if (response.status === 404) {
|
||||
return { status: 'error', reason: 'invalid' }
|
||||
}
|
||||
|
||||
if (!response.ok && response.status !== 400) {
|
||||
throw new Error('HTTP status ' + response.status)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,21 @@
|
||||
<template>
|
||||
<a class="list-group-item" href="#" @click.prevent="$emit('selected', list)">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ list.name }}
|
||||
</div>
|
||||
<div class="col-md-6 text-muted">
|
||||
{{ subevent }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
list: Object
|
||||
},
|
||||
computed: {
|
||||
subevent () {
|
||||
if (!this.list.subevent) return '';
|
||||
const name = i18nstring_localize(this.list.subevent.name)
|
||||
const date = moment.utc(this.list.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
|
||||
return `${name} · ${date}`
|
||||
}
|
||||
},
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { CheckinList } from '../api'
|
||||
import { formatSubevent } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
list: CheckinList
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
selected: [list: CheckinList]
|
||||
}>()
|
||||
|
||||
const subevent = computed(() => formatSubevent(props.list.subevent))
|
||||
</script>
|
||||
<template lang="pug">
|
||||
a.list-group-item(href="#", @click.prevent="$emit('selected', list)")
|
||||
.row
|
||||
.col-md-6 {{ list.name }}
|
||||
.col-md-6.text-muted {{ subevent }}
|
||||
</template>
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
<template>
|
||||
<div class="panel panel-primary checkinlist-select">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{{ $root.strings['checkinlist.select'] }}
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<checkinlist-item v-if="lists" v-for="l in lists" :list="l" :key="l.id" @selected="$emit('selected', l)"></checkinlist-item>
|
||||
<li v-if="loading" class="list-group-item text-center">
|
||||
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
|
||||
</li>
|
||||
<li v-else-if="error" class="list-group-item text-center">
|
||||
{{ error }}
|
||||
</li>
|
||||
<a v-else-if="next_url" class="list-group-item text-center" href="#" @click.prevent="loadNext">
|
||||
{{ $root.strings['pagination.next'] }}
|
||||
</a>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
components: {
|
||||
CheckinlistItem: CheckinlistItem.default,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
lists: null,
|
||||
next_url: null,
|
||||
}
|
||||
},
|
||||
// TODO: pagination
|
||||
mounted() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
const cutoff = moment().subtract(8, 'hours').toISOString()
|
||||
if (location.hash) {
|
||||
fetch(this.$root.api.lists + location.hash.substr(1) + '/' + '?expand=subevent')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
this.loading = false
|
||||
if (data.id) {
|
||||
this.$emit('selected', data)
|
||||
} else {
|
||||
location.hash = ''
|
||||
this.load()
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
location.hash = ''
|
||||
this.load()
|
||||
})
|
||||
return
|
||||
}
|
||||
fetch(this.$root.api.lists + '?exclude=checkin_count&exclude=position_count&expand=subevent&ends_after=' + cutoff)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
this.loading = false
|
||||
if (data.results) {
|
||||
this.lists = data.results
|
||||
this.next_url = data.next
|
||||
} else if (data.results === 0) {
|
||||
this.error = this.$root.strings['checkinlist.none']
|
||||
} else {
|
||||
this.error = data
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
this.loading = false
|
||||
this.error = reason
|
||||
})
|
||||
},
|
||||
loadNext() {
|
||||
this.loading = true
|
||||
fetch(this.next_url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
this.loading = false
|
||||
if (data.results) {
|
||||
this.lists.push(...data.results)
|
||||
this.next_url = data.next
|
||||
} else if (data.results === 0) {
|
||||
this.error = this.$root.strings['checkinlist.none']
|
||||
} else {
|
||||
this.error = data
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
this.loading = false
|
||||
this.error = reason
|
||||
})
|
||||
},
|
||||
},
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import type { CheckinList } from '../api'
|
||||
import { STRINGS } from '../i18n'
|
||||
import CheckinlistItem from './checkinlist-item.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
selected: [list: CheckinList]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<unknown>(null)
|
||||
const lists = ref<CheckinList[] | null>(null)
|
||||
const nextUrl = ref<string | null>(null)
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (location.hash) {
|
||||
const listId = location.hash.substring(1)
|
||||
try {
|
||||
const data = await api.fetchCheckinList(listId)
|
||||
loading.value = false
|
||||
if (data.id) {
|
||||
emit('selected', data)
|
||||
} else {
|
||||
location.hash = ''
|
||||
load()
|
||||
}
|
||||
} catch {
|
||||
location.hash = ''
|
||||
load()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = await api.fetchCheckinLists()
|
||||
loading.value = false
|
||||
|
||||
if (data.results) {
|
||||
lists.value = data.results
|
||||
nextUrl.value = data.next
|
||||
} else if (data.results === 0) {
|
||||
error.value = STRINGS['checkinlist.none']
|
||||
} else {
|
||||
error.value = data
|
||||
}
|
||||
} catch (e) {
|
||||
loading.value = false
|
||||
error.value = e
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNext () {
|
||||
if (!nextUrl.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await api.fetchNextPage<CheckinList>(nextUrl.value)
|
||||
loading.value = false
|
||||
|
||||
if (data.results) {
|
||||
lists.value.push(...data.results)
|
||||
nextUrl.value = data.next
|
||||
} else if (data.results === 0) {
|
||||
error.value = STRINGS['checkinlist.none']
|
||||
} else {
|
||||
error.value = data
|
||||
}
|
||||
} catch (e) {
|
||||
loading.value = false
|
||||
error.value = e
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
<template lang="pug">
|
||||
.panel.panel-primary.checkinlist-select
|
||||
.panel-heading
|
||||
h3.panel-title {{ STRINGS['checkinlist.select'] }}
|
||||
ul.list-group
|
||||
CheckinlistItem(
|
||||
v-for="l in lists",
|
||||
:key="l.id",
|
||||
:list="l",
|
||||
@selected="emit('selected', $event)"
|
||||
)
|
||||
li.list-group-item.text-center(v-if="loading")
|
||||
span.fa.fa-4x.fa-cog.fa-spin.loading-icon
|
||||
li.list-group-item.text-center(v-else-if="error") {{ error }}
|
||||
a.list-group-item.text-center(v-else-if="nextUrl", href="#", @click.prevent="loadNext")
|
||||
| {{ STRINGS['pagination.next'] }}
|
||||
</template>
|
||||
|
||||
@@ -1,54 +1,64 @@
|
||||
<template>
|
||||
<input class="form-control" :required="required">
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["required", "value"],
|
||||
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("YYYY-MM-DD"));
|
||||
});
|
||||
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-dateformat"),
|
||||
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 { dateFormat, datetimeLocale } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
required?: boolean
|
||||
modelValue?: string
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
|
||||
const opts = {
|
||||
format: dateFormat,
|
||||
locale: datetimeLocale,
|
||||
useCurrent: false,
|
||||
showClear: props.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',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
$(input.value!).data('DateTimePicker').date(moment(val))
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
$(input.value!)
|
||||
.datetimepicker(opts)
|
||||
.trigger('change')
|
||||
.on('dp.change', function (this: HTMLElement) {
|
||||
emit('update:modelValue', $(this).data('DateTimePicker').date().format('YYYY-MM-DD'))
|
||||
})
|
||||
|
||||
if (!props.modelValue) {
|
||||
$(input.value!).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
|
||||
} else {
|
||||
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
$(input.value!)
|
||||
.off()
|
||||
.datetimepicker('destroy')
|
||||
})
|
||||
</script>
|
||||
<template lang="pug">
|
||||
input.form-control(:id="id", ref="input", :required="required")
|
||||
</template>
|
||||
|
||||
@@ -1,55 +1,65 @@
|
||||
<template>
|
||||
<input class="form-control" :required="required">
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["required", "value"],
|
||||
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"),
|
||||
timeZone: $("body").attr("data-timezone"),
|
||||
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 { datetimeFormat, datetimeLocale, timezone } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
required?: boolean
|
||||
modelValue?: string
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
|
||||
const opts = {
|
||||
format: datetimeFormat,
|
||||
locale: datetimeLocale,
|
||||
timeZone: timezone,
|
||||
useCurrent: false,
|
||||
showClear: props.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',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
$(input.value!).data('DateTimePicker').date(moment(val))
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
$(input.value!)
|
||||
.datetimepicker(opts)
|
||||
.trigger('change')
|
||||
.on('dp.change', function (this: HTMLElement) {
|
||||
emit('update:modelValue', $(this).data('DateTimePicker').date().toISOString())
|
||||
})
|
||||
|
||||
if (!props.modelValue) {
|
||||
$(input.value!).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
|
||||
} else {
|
||||
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
$(input.value!)
|
||||
.off()
|
||||
.datetimepicker('destroy')
|
||||
})
|
||||
</script>
|
||||
<template lang="pug">
|
||||
input.form-control(:id="id", ref="input", :required="required")
|
||||
</template>
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<template>
|
||||
<a class="list-group-item searchresult" href="#" @click.prevent="$emit('selected', position)" ref="a">
|
||||
<div class="details">
|
||||
<h4>{{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}</h4>
|
||||
<span>{{ itemvar }}<br></span>
|
||||
<span v-if="subevent">{{ subevent }}<br></span>
|
||||
<div class="secret">{{ position.secret }}</div>
|
||||
</div>
|
||||
<div :class="`status status-${status}`">
|
||||
<span v-if="position.require_attention"><span class="fa fa-warning"></span><br></span>
|
||||
{{ $root.strings[`status.${status}`] }}
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
position: Object
|
||||
},
|
||||
computed: {
|
||||
status() {
|
||||
if (this.position.checkins.length) return 'redeemed';
|
||||
if (this.position.order__status === 'n' && this.position.order__valid_if_pending) return 'pending_valid';
|
||||
if (this.position.order__status === 'n' && this.position.order__require_approval) return 'require_approval';
|
||||
return this.position.order__status
|
||||
},
|
||||
itemvar() {
|
||||
if (this.position.variation) {
|
||||
return `${i18nstring_localize(this.position.item.name)} – ${i18nstring_localize(this.position.variation.value)}`
|
||||
}
|
||||
return i18nstring_localize(this.position.item.name)
|
||||
},
|
||||
subevent() {
|
||||
if (!this.position.subevent) return ''
|
||||
const name = i18nstring_localize(this.position.subevent.name)
|
||||
const date = moment.utc(this.position.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
|
||||
return `${name} · ${date}`
|
||||
},
|
||||
},
|
||||
}
|
||||
// secret
|
||||
// status
|
||||
// order code
|
||||
// name
|
||||
// seat
|
||||
// require attention
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Position } from '../api'
|
||||
import { STRINGS, i18nstringLocalize, formatSubevent } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
position: Position
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
selected: [position: Position]
|
||||
}>()
|
||||
|
||||
const rootEl = ref<HTMLAnchorElement>()
|
||||
|
||||
const status = computed(() => {
|
||||
if (props.position.checkins.length) return 'redeemed'
|
||||
if (props.position.order__status === 'n' && props.position.order__valid_if_pending) return 'pending_valid'
|
||||
if (props.position.order__status === 'n' && props.position.order__require_approval) return 'require_approval'
|
||||
return props.position.order__status
|
||||
})
|
||||
|
||||
const itemvar = computed(() => {
|
||||
if (props.position.variation) {
|
||||
return `${i18nstringLocalize(props.position.item.name)} – ${i18nstringLocalize(props.position.variation.value)}`
|
||||
}
|
||||
return i18nstringLocalize(props.position.item.name)
|
||||
})
|
||||
|
||||
const subevent = computed(() => formatSubevent(props.position.subevent))
|
||||
|
||||
defineExpose({ el: rootEl })
|
||||
</script>
|
||||
<template lang="pug">
|
||||
a.list-group-item.searchresult(ref="rootEl", href="#", @click.prevent="$emit('selected', position)")
|
||||
.details
|
||||
h4 {{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}
|
||||
span {{ itemvar }}
|
||||
br
|
||||
span(v-if="subevent") {{ subevent }}
|
||||
br
|
||||
.secret {{ position.secret }}
|
||||
.status(:class="`status-${status}`")
|
||||
span(v-if="position.require_attention")
|
||||
span.fa.fa-warning
|
||||
br
|
||||
| {{ STRINGS[`status.${status}`] }}
|
||||
</template>
|
||||
|
||||
@@ -1,54 +1,64 @@
|
||||
<template>
|
||||
<input class="form-control" :required="required">
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["required", "value"],
|
||||
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(moment(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(moment(val));
|
||||
},
|
||||
},
|
||||
destroyed: function () {
|
||||
$(this.$el)
|
||||
.off()
|
||||
.datetimepicker("destroy");
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { timeFormat, datetimeLocale } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
required?: boolean
|
||||
modelValue?: string
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
|
||||
const opts = {
|
||||
format: timeFormat,
|
||||
locale: datetimeLocale,
|
||||
useCurrent: false,
|
||||
showClear: props.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',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
$(input.value!).data('DateTimePicker').date(moment(val))
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
$(input.value!)
|
||||
.datetimepicker(opts)
|
||||
.trigger('change')
|
||||
.on('dp.change', function (this: HTMLElement) {
|
||||
emit('update:modelValue', $(this).data('DateTimePicker').date().format('HH:mm:ss'))
|
||||
})
|
||||
|
||||
if (!props.modelValue) {
|
||||
$(input.value!).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
|
||||
} else {
|
||||
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
$(input.value!)
|
||||
.off()
|
||||
.datetimepicker('destroy')
|
||||
})
|
||||
</script>
|
||||
<template lang="pug">
|
||||
input.form-control(:id="id", ref="input", :required="required")
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
const body = document.body
|
||||
|
||||
export const timezone = body.dataset.timezone ?? 'UTC'
|
||||
export const datetimeFormat = body.dataset.datetimeformat ?? 'L LT'
|
||||
export const dateFormat = body.dataset.dateformat ?? 'L'
|
||||
export const timeFormat = body.dataset.timeformat ?? 'LT'
|
||||
export const datetimeLocale = body.dataset.datetimelocale ?? 'en'
|
||||
export const pretixLocale = body.dataset.pretixlocale ?? 'en'
|
||||
|
||||
moment.locale(datetimeLocale)
|
||||
|
||||
export function gettext (msgid: string): string {
|
||||
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
|
||||
return django.gettext(msgid)
|
||||
}
|
||||
return msgid
|
||||
}
|
||||
|
||||
export function ngettext (singular: string, plural: string, count: number): string {
|
||||
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
|
||||
return django.ngettext(singular, plural, count)
|
||||
}
|
||||
return plural
|
||||
}
|
||||
|
||||
export type I18nString = string | Record<string, string> | null | undefined
|
||||
|
||||
export function i18nstringLocalize (obj: I18nString): string {
|
||||
// external
|
||||
return i18nstring_localize(obj)
|
||||
}
|
||||
|
||||
export const STRINGS: Record<string, string> = {
|
||||
'checkinlist.select': gettext('Select a check-in list'),
|
||||
'checkinlist.none': gettext('No active check-in lists found.'),
|
||||
'checkinlist.switch': gettext('Switch check-in list'),
|
||||
'results.headline': gettext('Search results'),
|
||||
'results.none': gettext('No tickets found'),
|
||||
'check.headline': gettext('Result'),
|
||||
'check.attention': gettext('This ticket requires special attention'),
|
||||
'scantype.switch': gettext('Switch direction'),
|
||||
'scantype.entry': gettext('Entry'),
|
||||
'scantype.exit': gettext('Exit'),
|
||||
'input.placeholder': gettext('Scan a ticket or search and press return…'),
|
||||
'pagination.next': gettext('Load more'),
|
||||
'status.p': gettext('Valid'),
|
||||
'status.n': gettext('Unpaid'),
|
||||
'status.c': gettext('Canceled'),
|
||||
'status.e': gettext('Canceled'),
|
||||
'status.pending_valid': gettext('Confirmed'),
|
||||
'status.require_approval': gettext('Approval pending'),
|
||||
'status.redeemed': gettext('Redeemed'),
|
||||
'modal.cancel': gettext('Cancel'),
|
||||
'modal.continue': gettext('Continue'),
|
||||
'modal.unpaid.head': gettext('Ticket not paid'),
|
||||
'modal.unpaid.text': gettext('This ticket is not yet paid. Do you want to continue anyways?'),
|
||||
'modal.questions': gettext('Additional information required'),
|
||||
'result.ok': gettext('Valid ticket'),
|
||||
'result.exit': gettext('Exit recorded'),
|
||||
'result.already_redeemed': gettext('Ticket already used'),
|
||||
'result.questions': gettext('Information required'),
|
||||
'result.invalid': gettext('Unknown ticket'),
|
||||
'result.product': gettext('Ticket type not allowed here'),
|
||||
'result.unpaid': gettext('Ticket not paid'),
|
||||
'result.rules': gettext('Entry not allowed'),
|
||||
'result.revoked': gettext('Ticket code revoked/changed'),
|
||||
'result.blocked': gettext('Ticket blocked'),
|
||||
'result.invalid_time': gettext('Ticket not valid at this time'),
|
||||
'result.canceled': gettext('Order canceled'),
|
||||
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
|
||||
'result.unapproved': gettext('Order not approved'),
|
||||
'status.checkin': gettext('Checked-in Tickets'),
|
||||
'status.position': gettext('Valid Tickets'),
|
||||
'status.inside': gettext('Currently inside'),
|
||||
yes: gettext('Yes'),
|
||||
no: gettext('No'),
|
||||
}
|
||||
|
||||
export interface SubEvent {
|
||||
name: Record<string, string>
|
||||
date_from: string
|
||||
}
|
||||
|
||||
export function formatSubevent (subevent: SubEvent | null | undefined): string {
|
||||
if (!subevent) return ''
|
||||
const name = i18nstringLocalize(subevent.name)
|
||||
const date = moment.utc(subevent.date_from).tz(timezone).format(datetimeFormat)
|
||||
return `${name} · ${date}`
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
type: string
|
||||
}
|
||||
|
||||
export function formatAnswer (value: string, question: Question): string {
|
||||
if (question.type === 'B' && value === 'True') {
|
||||
return STRINGS['yes']
|
||||
} else if (question.type === 'B' && value === 'False') {
|
||||
return STRINGS['no']
|
||||
} else if (question.type === 'W' && value) {
|
||||
return moment(value).tz(timezone).format('L LT')
|
||||
} else if (question.type === 'D' && value) {
|
||||
return moment(value).format('L')
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*global gettext, Vue, App*/
|
||||
function gettext(msgid) {
|
||||
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
|
||||
return django.gettext(msgid);
|
||||
}
|
||||
return msgid;
|
||||
}
|
||||
|
||||
function ngettext(singular, plural, count) {
|
||||
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
|
||||
return django.ngettext(singular, plural, count);
|
||||
}
|
||||
return plural;
|
||||
}
|
||||
|
||||
|
||||
moment.locale(document.body.attributes['data-datetimelocale'].value)
|
||||
window.vapp = new Vue({
|
||||
components: {
|
||||
App: App.default
|
||||
},
|
||||
render: function (h) {
|
||||
return h('App')
|
||||
},
|
||||
data: {
|
||||
api: {
|
||||
lists: document.querySelector('#app').attributes['data-api-lists'].value,
|
||||
},
|
||||
strings: {
|
||||
'checkinlist.select': gettext('Select a check-in list'),
|
||||
'checkinlist.none': gettext('No active check-in lists found.'),
|
||||
'checkinlist.switch': gettext('Switch check-in list'),
|
||||
'results.headline': gettext('Search results'),
|
||||
'results.none': gettext('No tickets found'),
|
||||
'check.headline': gettext('Result'),
|
||||
'check.attention': gettext('This ticket requires special attention'),
|
||||
'scantype.switch': gettext('Switch direction'),
|
||||
'scantype.entry': gettext('Entry'),
|
||||
'scantype.exit': gettext('Exit'),
|
||||
'input.placeholder': gettext('Scan a ticket or search and press return…'),
|
||||
'pagination.next': gettext('Load more'),
|
||||
'status.p': gettext('Valid'),
|
||||
'status.n': gettext('Unpaid'),
|
||||
'status.c': gettext('Canceled'),
|
||||
'status.e': gettext('Canceled'),
|
||||
'status.pending_valid': gettext('Confirmed'),
|
||||
'status.require_approval': gettext('Approval pending'),
|
||||
'status.redeemed': gettext('Redeemed'),
|
||||
'modal.cancel': gettext('Cancel'),
|
||||
'modal.continue': gettext('Continue'),
|
||||
'modal.unpaid.head': gettext('Ticket not paid'),
|
||||
'modal.unpaid.text': gettext('This ticket is not yet paid. Do you want to continue anyways?'),
|
||||
'modal.questions': gettext('Additional information required'),
|
||||
'result.ok': gettext('Valid ticket'),
|
||||
'result.exit': gettext('Exit recorded'),
|
||||
'result.already_redeemed': gettext('Ticket already used'),
|
||||
'result.questions': gettext('Information required'),
|
||||
'result.invalid': gettext('Unknown ticket'),
|
||||
'result.product': gettext('Ticket type not allowed here'),
|
||||
'result.unpaid': gettext('Ticket not paid'),
|
||||
'result.rules': gettext('Entry not allowed'),
|
||||
'result.revoked': gettext('Ticket code revoked/changed'),
|
||||
'result.blocked': gettext('Ticket blocked'),
|
||||
'result.invalid_time': gettext('Ticket not valid at this time'),
|
||||
'result.canceled': gettext('Order canceled'),
|
||||
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
|
||||
'result.unapproved': gettext('Order not approved'),
|
||||
'status.checkin': gettext('Checked-in Tickets'),
|
||||
'status.position': gettext('Valid Tickets'),
|
||||
'status.inside': gettext('Currently inside'),
|
||||
'yes': gettext('Yes'),
|
||||
'no': gettext('No'),
|
||||
},
|
||||
event_name: document.querySelector('#app').attributes['data-event-name'].value,
|
||||
timezone: document.body.attributes['data-timezone'].value,
|
||||
datetime_format: document.body.attributes['data-datetimeformat'].value,
|
||||
},
|
||||
el: '#app'
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
// import './scss/main.scss'
|
||||
|
||||
import App from './components/app.vue'
|
||||
|
||||
const mountEl = document.querySelector<HTMLElement>('#app')!
|
||||
|
||||
const app = createApp(App, mountEl.dataset)
|
||||
app.mount('#app')
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
{% load statici18n %}
|
||||
{% load eventurl %}
|
||||
{% load escapejson %}
|
||||
{% load vite %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -23,11 +24,7 @@
|
||||
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
|
||||
data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}"
|
||||
data-pretixlocale="{{ request.LANGUAGE_CODE }}" data-timezone="{{ request.event.settings.timezone }}">
|
||||
<div
|
||||
data-api-lists="{% url "api-v1:checkinlist-list" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
data-api-questions="{% url "api-v1:question-list" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
data-event-name="{{ request.event.name }}"
|
||||
id="app"></div>
|
||||
<div id="app" data-event-name="{{ request.event.name }}"></div>
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/i18nstring.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
|
||||
@@ -35,22 +32,17 @@
|
||||
<script type="text/javascript" src="{% static "jquery/js/jquery-3.6.4.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% if DEBUG %}
|
||||
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
|
||||
{% else %}
|
||||
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
|
||||
{% endif %}
|
||||
{% compress js %}
|
||||
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-item.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-select.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/searchresult-item.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datetimefield.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datefield.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/timefield.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/app.vue' %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixplugins/webcheckin/main.js" %}"></script>
|
||||
{% endcompress %}
|
||||
<script type="application/json" id="countries">{{ countries|escapejson_dumps }}</script>
|
||||
<script type="application/json" id="api-settings">
|
||||
{
|
||||
"urls": {
|
||||
"lists": "{% url "api-v1:checkinlist-list" event=request.event.slug organizer=request.organizer.slug %}",
|
||||
"questions": "{% url "api-v1:question-list" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.ts" %}
|
||||
{% csrf_token %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -121,9 +121,22 @@ def widget_css_etag(request, version, **kwargs):
|
||||
return f'{_get_source_cache_key(version)}-{request.organizer.cache.get_or_set("css_version", default=lambda: int(time.time()))}'
|
||||
|
||||
|
||||
def _use_vite(request):
|
||||
if getattr(settings, 'PRETIX_WIDGET_VITE', False):
|
||||
return True
|
||||
origin = request.META.get('HTTP_ORIGIN', '')
|
||||
gs = GlobalSettingsObject()
|
||||
vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='')
|
||||
if origin and vite_origins:
|
||||
origins_list = [o.strip() for o in vite_origins.strip().splitlines() if o.strip()]
|
||||
return origin in origins_list
|
||||
return False
|
||||
|
||||
|
||||
def widget_js_etag(request, version, lang, **kwargs):
|
||||
gs = GlobalSettingsObject()
|
||||
return gs.settings.get('widget_checksum_{}_{}'.format(version, lang))
|
||||
variant = 'vite' if _use_vite(request) else 'legacy'
|
||||
return gs.settings.get('widget_checksum_{}_{}_{}'.format(version, lang, variant))
|
||||
|
||||
|
||||
@gzip_page
|
||||
@@ -152,13 +165,16 @@ def widget_css(request, version, **kwargs):
|
||||
return resp
|
||||
|
||||
|
||||
def generate_widget_js(version, lang):
|
||||
def generate_widget_js(version, lang, use_vite=False):
|
||||
code = []
|
||||
with language(lang):
|
||||
# Provide isolation
|
||||
code.append('(function (siteglobals) {\n')
|
||||
code.append('var module = {}, exports = {};\n')
|
||||
code.append('var lang = "%s";\n' % lang)
|
||||
if use_vite:
|
||||
code.append('const LANG = "%s";\n' % lang)
|
||||
else:
|
||||
code.append('var lang = "%s";\n' % lang)
|
||||
|
||||
c = JavaScriptCatalog()
|
||||
c.translation = DjangoTranslation(lang, domain='djangojs')
|
||||
@@ -179,20 +195,25 @@ def generate_widget_js(version, lang):
|
||||
'plural': plural,
|
||||
})
|
||||
i18n_js = template.render(context)
|
||||
i18n_js = i18n_js.replace('for (const ', 'for (var ') # remove if we really want to break IE11 for good
|
||||
i18n_js = i18n_js.replace(r"value.includes(", r"-1 != value.indexOf(") # remove if we really want to break IE11 for good
|
||||
code.append(i18n_js)
|
||||
|
||||
files = [
|
||||
'vuejs/vue.js' if settings.DEBUG else 'vuejs/vue.min.js',
|
||||
'pretixpresale/js/widget/docready.js',
|
||||
'pretixpresale/js/widget/floatformat.js',
|
||||
'pretixpresale/js/widget/widget.js' if version == version_max else 'pretixpresale/js/widget/widget.v{}.js'.format(version),
|
||||
]
|
||||
for fname in files:
|
||||
f = finders.find(fname)
|
||||
with open(f, 'r', encoding='utf-8') as fp:
|
||||
if use_vite:
|
||||
vite_js = finders.find('vite/widget/widget.js')
|
||||
if not vite_js:
|
||||
raise FileNotFoundError('Vite widget build not found. Run: npm run build:widget')
|
||||
with open(vite_js, 'r', encoding='utf-8') as fp:
|
||||
code.append(fp.read())
|
||||
else:
|
||||
files = [
|
||||
'vuejs/vue.js' if settings.DEBUG else 'vuejs/vue.min.js',
|
||||
'pretixpresale/js/widget/docready.js',
|
||||
'pretixpresale/js/widget/floatformat.js',
|
||||
'pretixpresale/js/widget/widget.js' if version == version_max else 'pretixpresale/js/widget/widget.v{}.js'.format(version),
|
||||
]
|
||||
for fname in files:
|
||||
f = finders.find(fname)
|
||||
with open(f, 'r', encoding='utf-8') as fp:
|
||||
code.append(fp.read())
|
||||
|
||||
if settings.DEBUG:
|
||||
code.append('})(this);\n')
|
||||
@@ -213,15 +234,22 @@ def widget_js(request, version, lang, **kwargs):
|
||||
if version < version_min:
|
||||
version = version_min
|
||||
|
||||
cached_js = cache.get('widget_js_data_v{}_{}'.format(version, lang))
|
||||
use_vite = _use_vite(request)
|
||||
variant = 'vite' if use_vite else 'legacy'
|
||||
cache_prefix = 'widget_js_data_v{}_{}_{}'.format(version, lang, variant)
|
||||
|
||||
cached_js = cache.get(cache_prefix)
|
||||
if cached_js and not settings.DEBUG:
|
||||
resp = HttpResponse(cached_js, content_type='text/javascript')
|
||||
resp._csp_ignore = True
|
||||
resp['Access-Control-Allow-Origin'] = '*'
|
||||
return resp
|
||||
|
||||
settings_key = 'widget_file_v{}_{}_{}'.format(version, lang, variant)
|
||||
checksum_key = 'widget_checksum_v{}_{}_{}'.format(version, lang, variant)
|
||||
|
||||
gs = GlobalSettingsObject()
|
||||
fname = gs.settings.get('widget_file_v{}_{}'.format(version, lang))
|
||||
fname = gs.settings.get(settings_key)
|
||||
resp = None
|
||||
if fname and not settings.DEBUG:
|
||||
if isinstance(fname, File):
|
||||
@@ -229,21 +257,21 @@ def widget_js(request, version, lang, **kwargs):
|
||||
try:
|
||||
data = default_storage.open(fname).read()
|
||||
resp = HttpResponse(data, content_type='text/javascript')
|
||||
cache.set('widget_js_data_v{}_{}'.format(version, lang), data, 3600 * 4)
|
||||
cache.set(cache_prefix, data, 3600 * 4)
|
||||
except:
|
||||
logger.exception('Failed to open widget.js')
|
||||
|
||||
if not resp:
|
||||
data = generate_widget_js(version, lang).encode()
|
||||
data = generate_widget_js(version, lang, use_vite=use_vite).encode()
|
||||
checksum = hashlib.sha1(data).hexdigest()
|
||||
if not settings.DEBUG:
|
||||
newname = default_storage.save(
|
||||
'widget/widget.{}.{}.{}.js'.format(version, lang, checksum),
|
||||
'widget/widget.{}.{}.{}.{}.js'.format(version, lang, variant, checksum),
|
||||
ContentFile(data)
|
||||
)
|
||||
gs.settings.set('widget_file_v{}_{}'.format(version, lang), 'file://' + newname)
|
||||
gs.settings.set('widget_checksum_v{}_{}'.format(version, lang), checksum)
|
||||
cache.set('widget_js_data_v{}_{}'.format(version, lang), data, 3600 * 4)
|
||||
gs.settings.set(settings_key, 'file://' + newname)
|
||||
gs.settings.set(checksum_key, checksum)
|
||||
cache.set(cache_prefix, data, 3600 * 4)
|
||||
resp = HttpResponse(data, content_type='text/javascript')
|
||||
resp._csp_ignore = True
|
||||
resp['Access-Control-Allow-Origin'] = '*'
|
||||
|
||||
@@ -869,3 +869,10 @@ FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT = 1024 * 1024 * config.getint("pretix
|
||||
FILE_UPLOAD_MAX_SIZE_OTHER = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_other", fallback=10)
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
|
||||
VITE_DEV_SERVER_PORT = 5173
|
||||
VITE_DEV_SERVER = f"http://localhost:{VITE_DEV_SERVER_PORT}"
|
||||
VITE_DEV_MODE = DEBUG
|
||||
VITE_IGNORE = False # Used to ignore `collectstatic`/`rebuild`
|
||||
PRETIX_WIDGET_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0')
|
||||
|
||||
6405
src/pretix/static/npm_dir/package-lock.json
generated
6405
src/pretix/static/npm_dir/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "pretix",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
});
|
||||
98
src/pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
Normal file
98
src/pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
193
src/pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
Normal file
193
src/pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
12
src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts
Normal file
12
src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
70
src/pretix/static/pretixcontrol/js/ui/questionnaires/App.vue
Normal file
70
src/pretix/static/pretixcontrol/js/ui/questionnaires/App.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script>
|
||||
import Questionnaire from './Questionnaire.vue';
|
||||
import {get_datafields, get_items, get_questionnaires} from './api';
|
||||
import { i18n_any, QUESTION_TYPE } from './helper';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const datafields_response = await get_datafields();
|
||||
const questionnaires_response = await get_questionnaires();
|
||||
const items_response = await get_items();
|
||||
|
||||
const questionnaires = ref(questionnaires_response.results);
|
||||
const datafields = ref(datafields_response.results);
|
||||
export default {
|
||||
components: {
|
||||
Questionnaire
|
||||
},
|
||||
methods: {
|
||||
i18n_any,
|
||||
addQuestionnaire: function() {
|
||||
questionnaires.value.push({
|
||||
items: [], internal_name: "Unnamed questionnaire",
|
||||
type: 'PS',
|
||||
});
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
questionnaires,
|
||||
datafields,
|
||||
items: items_response.results,
|
||||
selected_product: ref(""),
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.hidden-question { opacity: 0.3; }
|
||||
.hidden-question label { text-decoration: line-through; }
|
||||
|
||||
.question-editor { margin-right: 140px; }
|
||||
.question-edit-buttons { float:right }
|
||||
.question-edit-buttons div { position: absolute; margin-left: 10px; }
|
||||
.question-edit-buttons button { }
|
||||
.form-group { margin-bottom: 30px }
|
||||
</style>
|
||||
<template>
|
||||
|
||||
<p>
|
||||
Questionnaires for product:
|
||||
<select v-model="selected_product">
|
||||
<option value="">(all)</option>
|
||||
<option v-for="item in items" :value="item.id">
|
||||
{{ i18n_any(item.name) }}
|
||||
</option>
|
||||
</select>
|
||||
</p>
|
||||
<div class="question-editor">
|
||||
<Questionnaire
|
||||
v-for="questionnaire in questionnaires"
|
||||
:questionnaire="questionnaire"
|
||||
:datafields="datafields"
|
||||
:items="items"
|
||||
:selected_product="selected_product" />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<button class="btn btn-default" @click="addQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useId, defineProps } from 'vue';
|
||||
|
||||
const props = defineProps(['value', 'id']);
|
||||
|
||||
if (!props.value) props.value = {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="i18n-form-group" :id="id">
|
||||
<textarea cols="40" rows="2" lang="en" dir="ltr" class="form-control" title="Englisch" :id="`${id}_0`" placeholder="Englisch" v-model="value.en"></textarea>
|
||||
<textarea cols="40" rows="2" lang="de" dir="ltr" class="form-control" title="Deutsch" :id="`${id}_1`" placeholder="Deutsch" v-model="value.de"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue';
|
||||
|
||||
const dialog = ref<HTMLDialogElement>();
|
||||
|
||||
const props = defineProps({
|
||||
classes: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
title: '',
|
||||
});
|
||||
|
||||
const visible = ref(false);
|
||||
|
||||
const showModal = () => {
|
||||
dialog.value?.showModal();
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: showModal,
|
||||
close: (returnVal?: string): void => dialog.value?.close(returnVal),
|
||||
visible,
|
||||
});
|
||||
const id = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog" class="modal-card"
|
||||
@close="visible = false"
|
||||
closedby="any"
|
||||
:aria-labelledby="`${id}-title`"
|
||||
>
|
||||
<form
|
||||
v-if="visible"
|
||||
method="dialog" class="modal-card-inner form-horizontal"
|
||||
:class="{
|
||||
[props.classes]: props.classes,
|
||||
}"
|
||||
>
|
||||
<div class="modal-card-content">
|
||||
<h2 :id="`${id}-title`" class="modal-card-title h3">{{ title }}</h2>
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
import { i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL, SYSTEM_DATAFIELDS } from './helper';
|
||||
import NativeDialog from './NativeDialog.vue';
|
||||
import I18nTextField from './I18nTextField.vue';
|
||||
import { useId, ref } from 'vue'
|
||||
const id = useId();
|
||||
const props = defineProps(['question', 'datafields', 'editable'])
|
||||
const emit = defineEmits(['removeSelf']);
|
||||
|
||||
const df = typeof props.question.question === 'number' ?
|
||||
props.datafields.find(el => el.id === props.question.question) :
|
||||
typeof props.question.question === 'string' ?
|
||||
SYSTEM_DATAFIELDS[props.question.question] :
|
||||
{};
|
||||
|
||||
if (!props.question.label) props.question.label = {};
|
||||
if (!props.question.help_text) props.question.help_text = {};
|
||||
|
||||
const editor = ref();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<div class="question-edit-buttons" v-if="editable"><div>
|
||||
<button class="btn btn-default"><i class="fa fa-arrows"></i></button>
|
||||
<button class="btn btn-default" @click="editor.show()"><i class="fa fa-edit"></i></button>
|
||||
</div></div>
|
||||
<label class="col-md-3 control-label" :for="id" v-if="df.type !== QUESTION_TYPE.BOOLEAN">
|
||||
{{ i18n_any(question.label) }}
|
||||
</label>
|
||||
<div v-else class="col-md-3 control-label label-empty"></div>
|
||||
<div class="col-md-9">
|
||||
<input :id="id" type="text" v-if="df.type === QUESTION_TYPE.STRING" class="form-control">
|
||||
<textarea :id="id" v-if="df.type === QUESTION_TYPE.TEXT" class="form-control"></textarea>
|
||||
<div class="checkbox" v-if="df.type === QUESTION_TYPE.BOOLEAN">
|
||||
<label :for="id">
|
||||
<input :id="id" type="checkbox"> {{ i18n_any(question.label) }}
|
||||
</label>
|
||||
</div>
|
||||
<input :id="id" type="number" v-if="df.type === QUESTION_TYPE.NUMBER" class="form-control">
|
||||
<input :id="id" type="file" v-if="df.type === QUESTION_TYPE.FILE" class="form-control">
|
||||
<select :id="id"
|
||||
v-if="df.type === QUESTION_TYPE.CHOICE || df.type === QUESTION_TYPE.CHOICE_MULTIPLE"
|
||||
:multiple="df.type === QUESTION_TYPE.CHOICE_MULTIPLE" class="form-control">
|
||||
<option></option>
|
||||
<option v-for="opt in question.options">{{ i18n_any(opt.answer) }}</option>
|
||||
</select>
|
||||
<div class="help-block">{{ i18n_any(question.help_text) }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<NativeDialog ref="editor" class="modal-card"
|
||||
title="Edit question">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
Question
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<I18nTextField :value="question.label"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
Help text
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<I18nTextField :value="question.help_text"/>
|
||||
<div class="help-block">Wenn diese Frage noch weitere Erklärung braucht, können Sie sie hier eintragen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
Data field
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
{{ question.question }}
|
||||
<a href="" target="_blank">Manage data field details</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
Data field type
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<select v-model="df.type" class="form-control" disabled>
|
||||
<option v-for="(label, type) in QUESTION_TYPE_LABEL" :value="QUESTION_TYPE[type]">{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="editor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> Save and close</button>
|
||||
<button @click="emit('removeSelf')" class="btn btn-default">Remove from questionnaire</button>
|
||||
</NativeDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import {useId, ref, computed} from 'vue'
|
||||
import Question from "./Question.vue";
|
||||
import {i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL} from "./helper";
|
||||
import I18nTextField from "./I18nTextField.vue";
|
||||
import NativeDialog from "./NativeDialog.vue";
|
||||
const id = useId();
|
||||
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'items'])
|
||||
const gettext = (window as any).gettext
|
||||
|
||||
function toggleItem() {
|
||||
const i = props.questionnaire.items.indexOf(props.selected_product);
|
||||
if (i === -1) {
|
||||
props.questionnaire.items.push(props.selected_product);
|
||||
} else {
|
||||
props.questionnaire.items.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function addExistingDatafield(field) {
|
||||
props.questionnaire.children.push({
|
||||
question: field.id,
|
||||
required: false,
|
||||
label: {},
|
||||
help_text: {},
|
||||
dependency_question: null,
|
||||
dependency_values: [],
|
||||
});
|
||||
dlgAddExisting.value.close();
|
||||
}
|
||||
|
||||
const isHidden = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) === -1);
|
||||
const isEditable = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) !== -1);
|
||||
|
||||
const dlgEditor = ref();
|
||||
const dlgAddExisting = ref();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="question-edit-buttons"><div>
|
||||
<button class="btn btn-default"><i class="fa fa-arrows"></i></button>
|
||||
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-edit"></i></button>
|
||||
<button class="btn btn-default" @click="toggleItem()" v-if="selected_product"><i :class="`fa fa-eye${isHidden ? '-slash':''}`"></i></button>
|
||||
</div></div>
|
||||
|
||||
<details class="panel panel-default " :open="!!isEditable"
|
||||
:class="{ 'hidden-question': isHidden }">
|
||||
<summary class="panel-heading">
|
||||
{{ props.questionnaire.internal_name }}
|
||||
</summary>
|
||||
<div class="panel-body" v-if="!isHidden">
|
||||
<div class="form-horizontal">
|
||||
|
||||
<Question
|
||||
v-for="(child, index) in props.questionnaire.children" :key="index"
|
||||
:datafields="props.datafields"
|
||||
:question="child"
|
||||
:editable="true"
|
||||
@remove-self="questionnaire.children.splice(index, 1)" />
|
||||
|
||||
</div>
|
||||
<p v-if="true">
|
||||
<button class="btn btn-default" @click="dlgAddExisting.show()"><i class="fa fa-plus"></i> Bestehendes Datenfeld hinzufügen</button>
|
||||
<button class="btn btn-default" @click="newDatafield()"><i class="fa fa-plus"></i> Neues Datenfeld</button>
|
||||
<button class="btn btn-default" @click="addSubtitle()"><i class="fa fa-plus"></i> Zwischenüberschrift</button>
|
||||
<button class="btn btn-default" @click="addTextBlock()"><i class="fa fa-plus"></i> Text</button>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Teleport to="body">
|
||||
<NativeDialog ref="dlgEditor" class="modal-card"
|
||||
:title="gettext('Edit questionnaire')">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Internal name') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Visible on products') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<div class="checkbox" v-for="item in items">
|
||||
<label :for="id + '_' + item.id">
|
||||
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1"> {{ item.internal_name || i18n_any(item.name) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="dlgEditor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
|
||||
<button class="btn btn-default">Delete</button>
|
||||
</NativeDialog>
|
||||
|
||||
<NativeDialog ref="dlgAddExisting" class="modal-card"
|
||||
:title="gettext('Add existing data field')">
|
||||
|
||||
<div class="list-group">
|
||||
<a href="javascript:" @click="addExistingDatafield(field)" v-for="field in datafields" class="list-group-item">{{ field.internal_name }}</a>
|
||||
</div>
|
||||
|
||||
<button @click="dlgAddExisting.close()" class="btn btn-default pull-right">Cancel</button>
|
||||
</NativeDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
14
src/pretix/static/pretixcontrol/js/ui/questionnaires/api.ts
Normal file
14
src/pretix/static/pretixcontrol/js/ui/questionnaires/api.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const organizer_slug = document.body.getAttribute('data-organizer'),
|
||||
event_slug = document.body.getAttribute('data-event');
|
||||
|
||||
export async function get_datafields() {
|
||||
return await (await fetch(`/api/v1/organizers/${organizer_slug}/events/${event_slug}/datafields/`)).json();
|
||||
}
|
||||
|
||||
export async function get_questionnaires() {
|
||||
return await (await fetch(`/api/v1/organizers/${organizer_slug}/events/${event_slug}/questionnaires/`)).json();
|
||||
}
|
||||
|
||||
export async function get_items() {
|
||||
return await (await fetch(`/api/v1/organizers/${organizer_slug}/events/${event_slug}/items/`)).json();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
export function i18n_any(data) {
|
||||
if (!data) return null;
|
||||
const preferred = document.body.getAttribute("data-pretixlocale");
|
||||
if (data[preferred]) return data[preferred];
|
||||
return Object.values(data)[0];
|
||||
}
|
||||
|
||||
function freezeRec(o) {
|
||||
return Object.freeze(Object.fromEntries(Object.entries(o).map(([k, v]) => [k, v && Object.getPrototypeOf(v) === Object.prototype ? freezeRec(v) : v])))
|
||||
}
|
||||
|
||||
export const QUESTION_TYPE = {
|
||||
NUMBER: "N",
|
||||
STRING: "S",
|
||||
TEXT: "T",
|
||||
BOOLEAN: "B",
|
||||
CHOICE: "C",
|
||||
CHOICE_MULTIPLE: "M",
|
||||
FILE: "F",
|
||||
DATE: "D",
|
||||
TIME: "H",
|
||||
DATETIME: "W",
|
||||
COUNTRYCODE: "CC",
|
||||
PHONENUMBER: "TEL",
|
||||
};
|
||||
|
||||
const _ = x => x;
|
||||
|
||||
export const QUESTION_TYPE_LABEL = {
|
||||
NUMBER: _("Number"),
|
||||
STRING: _("Text (one line)"),
|
||||
TEXT: _("Multiline text"),
|
||||
BOOLEAN: _("Yes/No"),
|
||||
CHOICE: _("Choose one from a list"),
|
||||
CHOICE_MULTIPLE: _("Choose multiple from a list"),
|
||||
FILE: _("File upload"),
|
||||
DATE: _("Date"),
|
||||
TIME: _("Time"),
|
||||
DATETIME: _("Date and time"),
|
||||
COUNTRYCODE: _("Country code (ISO 3166-1 alpha-2)"),
|
||||
PHONENUMBER: _("Phone number"),
|
||||
};
|
||||
|
||||
export const SYSTEM_DATAFIELDS = freezeRec({
|
||||
'attendee_name_parts': { label: _('Attendee name'), type: QUESTION_TYPE.STRING },
|
||||
'attendee_email': { label: _('Attendee email'), type: QUESTION_TYPE.STRING },
|
||||
'company': { label: _('Company'), type: QUESTION_TYPE.STRING },
|
||||
'street': { label: _('Street'), type: QUESTION_TYPE.STRING },
|
||||
'zipcode': { label: _('ZIP code'), type: QUESTION_TYPE.STRING },
|
||||
'city': { label: _('City'), type: QUESTION_TYPE.STRING },
|
||||
'country': { label: _('Country'), type: QUESTION_TYPE.COUNTRYCODE },
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#questionnaires-editor')
|
||||
2
src/pretix/static/pretixpresale/widget/TODOS.md
Normal file
2
src/pretix/static/pretixpresale/widget/TODOS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- modernize the sometimes native form submitting?
|
||||
- destructure props?
|
||||
15
src/pretix/static/pretixpresale/widget/index.html
Normal file
15
src/pretix/static/pretixpresale/widget/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!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 rel="stylesheet" type="text/css" href="http://localhost:8000/testorg/testevent/widget/v2.css" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<pretix-widget event="http://localhost:8000/testorg/testevent/"></pretix-widget>
|
||||
<!-- <script type="text/javascript" src="http://localhost:8000/widget/v2.en.js" async crossorigin></script> -->
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
85
src/pretix/static/pretixpresale/widget/src/api.ts
Normal file
85
src/pretix/static/pretixpresale/widget/src/api.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Category, DayEntry, EventEntry, MetaFilterField } from '~/types'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
responseUrl: string
|
||||
|
||||
constructor (status: number, responseUrl: string) {
|
||||
super(`HTTP ${status}`)
|
||||
this.status = status
|
||||
this.responseUrl = responseUrl
|
||||
}
|
||||
}
|
||||
|
||||
// --- Product list ---
|
||||
|
||||
export interface ProductListResponse {
|
||||
target_url?: string
|
||||
subevent?: string | number
|
||||
name?: string
|
||||
frontpage_text?: string
|
||||
date_range?: string
|
||||
location?: string
|
||||
items_by_category?: Category[]
|
||||
currency?: string
|
||||
display_net_prices?: boolean
|
||||
voucher_explanation_text?: string
|
||||
error?: string
|
||||
display_add_to_cart?: boolean
|
||||
waiting_list_enabled?: boolean
|
||||
show_variations_expanded?: boolean
|
||||
cart_exists?: boolean
|
||||
vouchers_exist?: boolean
|
||||
has_seating_plan?: boolean
|
||||
has_seating_plan_waitinglist?: boolean
|
||||
itemnum?: number
|
||||
poweredby?: string
|
||||
events?: EventEntry[]
|
||||
has_more_events?: boolean
|
||||
meta_filter_fields?: MetaFilterField[]
|
||||
weeks?: DayEntry[][]
|
||||
date?: string
|
||||
days?: DayEntry[]
|
||||
week?: [number, number]
|
||||
}
|
||||
|
||||
export async function fetchProductList (url: string) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.url)
|
||||
}
|
||||
return {
|
||||
data: await response.json() as ProductListResponse,
|
||||
responseUrl: response.url,
|
||||
}
|
||||
}
|
||||
|
||||
export interface CartResponse {
|
||||
redirect?: string
|
||||
cart_id?: string
|
||||
success?: boolean
|
||||
message?: string
|
||||
has_cart?: boolean
|
||||
async_id?: string
|
||||
check_url?: string
|
||||
}
|
||||
|
||||
export async function submitCart (endpoint: string, formData: FormData) {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(formData as any).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.url)
|
||||
}
|
||||
return await response.json() as CartResponse
|
||||
}
|
||||
|
||||
export async function checkAsyncTask (url: string) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.url)
|
||||
}
|
||||
return await response.json() as CartResponse
|
||||
}
|
||||
73
src/pretix/static/pretixpresale/widget/src/button.ts
Normal file
73
src/pretix/static/pretixpresale/widget/src/button.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createApp, type App } from 'vue'
|
||||
import ButtonComponent from '~/components/Button.vue'
|
||||
import { createWidgetStore, StoreKey } from '~/sharedStore'
|
||||
import { makeid } from '~/utils'
|
||||
import type { WidgetData } from '~/types'
|
||||
|
||||
export function createButtonInstance (element: Element, htmlId?: string): App {
|
||||
let targetUrl = element.attributes.event.value
|
||||
if (!targetUrl.match(/\/$/)) {
|
||||
targetUrl += '/'
|
||||
}
|
||||
|
||||
const widgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data))
|
||||
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
if (attr.name.match(/^data-.*$/)) {
|
||||
widgetData[attr.name.replace(/^data-/, '')] = attr.value
|
||||
}
|
||||
}
|
||||
|
||||
const rawItems = element.attributes.items?.value || ''
|
||||
|
||||
// Parse items string (format: "item_1=2,item_3=1")
|
||||
const buttonItems: { item: string; count: string }[] = []
|
||||
for (const itemStr of rawItems.split(',')) {
|
||||
if (itemStr.includes('=')) {
|
||||
const [item, count] = itemStr.split('=')
|
||||
buttonItems.push({ item, count })
|
||||
}
|
||||
}
|
||||
|
||||
const store = createWidgetStore({
|
||||
targetUrl,
|
||||
voucher: element.attributes.voucher?.value || null,
|
||||
subevent: element.attributes.subevent?.value || null,
|
||||
skipSsl: 'skip-ssl-check' in element.attributes,
|
||||
disableIframe: 'disable-iframe' in element.attributes,
|
||||
widgetData,
|
||||
htmlId: htmlId || element.id || makeid(16),
|
||||
isButton: true,
|
||||
buttonItems,
|
||||
buttonText: element.innerHTML
|
||||
})
|
||||
|
||||
const observer = new MutationObserver((mutationList) => {
|
||||
for (const mutation of mutationList) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName?.startsWith('data-')) {
|
||||
const attrName = mutation.attributeName.substring(5)
|
||||
const attrValue = (mutation.target as Element).getAttribute(mutation.attributeName)
|
||||
if (attrValue !== null) {
|
||||
store.widgetData[attrName] = attrValue
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// TODO I don't think we need this anymore in vue3
|
||||
// if (element.tagName !== 'pretix-button') {
|
||||
// element.innerHTML = '<pretix-button>' + element.innerHTML + '</pretix-button>'
|
||||
// // Vue does not replace the container, so watch container as well
|
||||
// observer.observe(element, observerOptions)
|
||||
// }
|
||||
|
||||
const app = createApp(ButtonComponent)
|
||||
app.provide(StoreKey, store)
|
||||
app.config.errorHandler = (error, _vm, info) => {
|
||||
console.error('[pretix-button]', info, error)
|
||||
}
|
||||
app.mount(element)
|
||||
observer.observe(element, { attributes: true })
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
| ‹ {{ 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")
|
||||
| « {{ STRINGS.previous_month }}
|
||||
|
|
||||
strong(:id="ariaLabelledby") {{ monthname }}
|
||||
|
|
||||
a.pretix-widget-event-calendar-next-month(href="#", @click.prevent.stop="nextmonth")
|
||||
| {{ STRINGS.next_month }} »
|
||||
|
||||
//- 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
| ‹ {{ STRINGS.back_to_list }}
|
||||
a(v-if="store.subevent", href="#", rel="back", @click.prevent.stop="backToList")
|
||||
| ‹ {{ STRINGS.back_to_dates }}
|
||||
|
||||
//- Event name
|
||||
.pretix-widget-event-header(v-if="displayEventInfo")
|
||||
strong(role="heading", aria-level="2") {{ store.name }}
|
||||
|
||||
//- Date range
|
||||
.pretix-widget-event-details(v-if="displayEventInfo && store.dateRange") {{ store.dateRange }}
|
||||
|
||||
//- Location
|
||||
.pretix-widget-event-location(
|
||||
v-if="displayEventInfo && store.location",
|
||||
v-html="store.location"
|
||||
)
|
||||
|
||||
//- Description
|
||||
.pretix-widget-event-description(
|
||||
v-if="displayEventInfo && store.frontpageText",
|
||||
v-html="store.frontpageText"
|
||||
)
|
||||
|
||||
//- Form start
|
||||
form(
|
||||
ref="form",
|
||||
method="post",
|
||||
:action="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>
|
||||
@@ -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")
|
||||
| ‹ {{ 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
| ‹ {{ 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")
|
||||
| « {{ STRINGS.previous_week }}
|
||||
|
|
||||
strong {{ weekname }}
|
||||
|
|
||||
a.pretix-widget-event-calendar-next-month(href="#", role="button", @click.prevent.stop="nextweek")
|
||||
| {{ STRINGS.next_week }} »
|
||||
|
||||
//- Actual calendar
|
||||
.pretix-widget-event-week-table(:id="id", tabindex="0", :aria-label="weekname")
|
||||
.pretix-widget-event-week-col(v-for="d in store.days", :key="d?.date || ''")
|
||||
EventWeekCell(:day="d", :mobile="mobile")
|
||||
</template>
|
||||
<style lang="sass">
|
||||
</style>
|
||||
@@ -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>
|
||||
218
src/pretix/static/pretixpresale/widget/src/components/Item.vue
Normal file
218
src/pretix/static/pretixpresale/widget/src/components/Item.vue
Normal 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")
|
||||
|
||||
//- 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
|
||||
//- Availability
|
||||
.pretix-widget-item-availability-col
|
||||
AvailBox(:item="item", :variation="variation")
|
||||
|
||||
.pretix-widget-clear
|
||||
</template>
|
||||
<style lang="sass">
|
||||
</style>
|
||||
@@ -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>
|
||||
3
src/pretix/static/pretixpresale/widget/src/globals.d.ts
vendored
Normal file
3
src/pretix/static/pretixpresale/widget/src/globals.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
interface NamedNodeMap {
|
||||
[key: string]: Attr | undefined;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user