Compare commits

...

34 Commits

Author SHA1 Message Date
Richard Schreiber
3da492d6f2 fix isort after flake8 2026-05-18 13:15:15 +02:00
Richard Schreiber
58eb3c2b2e Improve error messages for name-parts inputs (Z#23234440) 2026-05-18 13:02:25 +02:00
Martin Gross
857791445f Docs: Add Exhibitor API docs (Z#23225216) (#6184)
* Docs: Add Exhibitor API docs

* Docs: Add Resource-table for Exhibitor vouchers
2026-05-18 12:02:27 +02:00
dependabot[bot]
52b28997a2 Update fakeredis requirement from ==2.34.* to ==2.35.* (#6072)
* Update fakeredis requirement from ==2.34.* to ==2.35.*

Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.34.0...v2.35.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.35.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update class name

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-05-18 09:48:51 +02:00
dependabot[bot]
f65a6aa11f Update cryptography requirement from >=47.0.0 to >=48.0.0 (#6177)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/47.0.0...48.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 16:39:18 +02:00
dependabot[bot]
9faca5ea24 Update sentry-sdk requirement from ==2.58.* to ==2.59.*
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.58.0a1...2.59.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.59.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:36:35 +02:00
dependabot[bot]
867512eee5 Bump picomatch
Bumps  and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:34:46 +02:00
Raphael Michel
1436b65347 Add webhooks for quota changes (Z#23232443) 2026-05-17 16:33:20 +02:00
sweenu
cc06588991 Rephrase refund info paragraph 2026-05-17 16:33:15 +02:00
dependabot[bot]
32bd9fa265 Bump vite from 8.0.0 to 8.0.12
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.0 to 8.0.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.12
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:56 +02:00
dependabot[bot]
bdc9b155f9 Bump flatted from 3.3.3 to 3.4.2
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:49 +02:00
dependabot[bot]
1af2941594 Bump postcss from 8.5.8 to 8.5.14
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.14.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.14)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:45 +02:00
dependabot[bot]
11dc1e6f70 Bump arabic-reshaper from 3.0.0 to 3.0.1
Bumps [arabic-reshaper](https://github.com/mpcabd/python-arabic-reshaper) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/mpcabd/python-arabic-reshaper/releases)
- [Commits](https://github.com/mpcabd/python-arabic-reshaper/compare/v3.0.0...v3.0.1)

---
updated-dependencies:
- dependency-name: arabic-reshaper
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:17:45 +02:00
Nikolai
e08243e3b2 Translations: Update Danish
Currently translated at 59.1% (3725 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2026-05-17 15:33:11 +02:00
Yasunobu YesNo Kawaguchi
3a4e30f2ec Translations: Update Japanese
Currently translated at 100.0% (256 of 256 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/ja/

powered by weblate
2026-05-17 15:33:11 +02:00
Yasunobu YesNo Kawaguchi
ea2fa741f5 Translations: Update Japanese
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ja/

powered by weblate
2026-05-17 15:33:11 +02:00
Stefano Campus
20d1bb9d32 Translations: Update Italian
Currently translated at 40.0% (2521 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/it/

powered by weblate
2026-05-17 15:33:11 +02:00
Hijiri Umemoto
ad48d592e7 Translations: Update Japanese
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ja/

powered by weblate
2026-05-17 15:33:11 +02:00
pajowu
4861aca640 Fix timepicker in checkinrules (#6182)
The timepickers format was changed by accident to the datetimeformat in the vue3 migration
2026-05-12 16:17:24 +02:00
pajowu
82450c8250 Handle related fields in export_form_data (Z#23233538) (#6157) 2026-05-12 14:55:25 +02:00
Richard Schreiber
b21b69b2b8 Fix playwright install on CI (#6180) 2026-05-12 13:14:05 +02:00
luelista
80ed6e76cd Fix failing orderlist export if orders with invalid payment provider identifiers exist (Z#23233440) (#6159)
* Fix orderlist export if orders with invalid payment provider identifiers exist (Z#23233440)
* Performance: Move _get_all_payment_methods out of loop
2026-05-12 11:57:18 +02:00
Lukas Bockstaller
bb211be436 use datetime.fromisoformat instead of dateutil.parser (Z#23234093) (#6164)
* use datetime.fromisoformat instead of dateutil.parser

* convert remaining parser usages as well
2026-05-12 10:41:24 +02:00
Richard Schreiber
3b70ef8c84 Allow event being optional in LoggingMixin (#6166) 2026-05-12 09:45:42 +02:00
Richard Schreiber
9d57380c9a Widget: fix missing whitespace in PriceBox 2026-05-12 09:34:17 +02:00
Richard Schreiber
8b468c31a5 Fix translation for order import (#6165) 2026-05-12 09:03:34 +02:00
Richard Schreiber
9aec608601 Fix checkinrules js errors 2026-05-12 08:34:22 +02:00
Raphael Michel
e542bb606d Vue3: Minor fixes in checkinlist editor 2026-05-11 18:51:21 +02:00
Raphael Michel
fe1b4ec9d0 Order bulk action: Remove nonsensical <form action> attribute (#6154) 2026-05-11 17:39:46 +02:00
rash
f04df7a6ee Migrate vue2 control components and widget to vue3 and vite (#5989)
* setup vite and integrate fully with django

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

* better syntax for cors header setting

* migrate checkin rules editor to vue3

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

* migrate webcheckin plugin to vite+vue3

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

* fix migration error

* first draft migrating widget to vue3/vite

* first couple widget e2e tests

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

* test file is not actually used

* drop widget_ prefix from e2e test fixtures

* add test for complete widget journey for simple event

* switch timezone in e2e tests to Europe/Berlin

* make dates in e2e tests relative

* migrate widget bugfix #5886

* start testing event series widget

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

* simplify e2e test iframe check

* less flaky e2e tests

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

* fix inconsistencies from automatic migration

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

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

* remove janky claude testts again

* resolve migration TODOs: properly refocus parent on navigations

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

* upgrade npm dependencies

* fix js linter errors

* fix python linter errors

* build all control vue components

* add new js config files to check-manifest ignore

* working prod build

acutal serving of built assets not tested yet

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

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

* remove now unused old vue2 tooling

* try fixing e2e test ci

* fix flake8 error

* check if vite build artefacts are in the wheel

* add license headers

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

* remove superfluous `createElement` calls

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

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

* fix rst syntax

* remove migration todos file

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

* rearrange dockerfile commands for smaller image, thanks @luelista

* Update .gitignore, adding .vite

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

* add eslint CI

* make vue dev work in plugins

* fix docker build

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

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

* Add widget changes from #6047, #6149

* Allow buttons to reuse cart (Z#23226853)

* Always keep cart of buttons with items set

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

---------

Co-authored-by: luelista <mira@teamwiki.de>
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-05-11 15:05:06 +02:00
pajowu
1640ddd497 Widget: handle cart if not same-site (Z#23233393)
Sets SameSite for cookie if page is secure, so cookie can be read even if not same-site. Also stores cart-id in vue state, so correct cart is used even if cookies to not work
2026-05-11 15:02:57 +02:00
pajowu
27148324a6 sendmail: Add missing cleanup migration (#6158) 2026-05-11 14:53:47 +02:00
corentin-spec
71edfa8e1a Translations: Update French
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2026-05-08 09:48:04 +02:00
Daniel Musketa
8303ba7808 Translations: Update German
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2026-05-08 09:48:04 +02:00
125 changed files with 13961 additions and 9341 deletions

View File

@@ -1,5 +1,6 @@
doc/
env/
node_modules/
res/
local/
.git/

5
.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

View File

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

43
.github/workflows/style-js.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: JS Code Style
on:
push:
branches: [ master ]
paths:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
pull_request:
branches: [ master ]
paths:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
permissions:
contents: read
env:
FORCE_COLOR: 1
jobs:
eslint:
name: eslint
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install Dependencies
run: npm ci
- name: Run ESLint
run: npm run lint:eslint

View File

@@ -72,7 +72,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
@@ -84,3 +84,46 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
if: matrix.database == 'postgres' && matrix.python-version == '3.13'
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: playwright install
- name: Run E2E tests
working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10

2
.gitignore vendored
View File

@@ -24,5 +24,7 @@ local/
.project
.pydevproject
.DS_Store
node_modules/
.vite/

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
/*

View File

@@ -1,6 +1,7 @@
FROM python:3.13-trixie
RUN apt-get update && \
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
gettext \
@@ -21,8 +22,7 @@ RUN apt-get update && \
libmaxminddb0 \
libmaxminddb-dev \
zlib1g-dev \
nodejs \
npm && \
nodejs && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
dpkg-reconfigure locales && \
@@ -50,6 +50,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 \

View File

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

View File

@@ -844,3 +844,187 @@ You can also fetch existing leads (if you are authorized to do so):
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
Retrieving Vouchers
"""""""""""""""""""
Vouchers returned by the App API use a different format than described in :ref:`rest-vouchers`.
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the voucher
code string The voucher code that is required to redeem the voucher
max_usages integer The maximum number of times this voucher can be
redeemed (default: 1).
redeemed integer The number of times this voucher already has been
redeemed.
valid_until datetime The voucher expiration date (or ``null``).
subevent string Name of the date inside an event series this voucher belongs to (or ``null``).
tag string A string that is used for grouping vouchers
comment string An internal exhibitor comment on the voucher.
items list of strings A list of items this voucher is restricted to (or ``null``).
price_mode string Determines how this voucher affects product prices.
Possible values:
* ``none`` No effect on price
* ``set`` The product price is set to the given ``value``
* ``subtract`` The product price is determined by the original price *minus* the given ``value``
* ``percent`` The product price is determined by the original price reduced by the percentage given in ``value``
value decimal (string) The value (see ``price_mode``)
redemptions list of objects A list of objects, where each object represents an order position that has been purchased using the voucher.
Each entry will contains the fields ``attendee_fields``, ``redemption_date`` and ``subevent``.
The attendee data in the ``attendee_fields`` that is shown is based on the event's configuration, and each entry
contains the fields ``id``, ``label``, ``value``, and ``details``. ``details`` is usually empty
except in a few cases where it contains an additional list of objects
with ``value`` and ``label`` keys (e.g. splitting of names).
===================================== ========================== =======================================================
.. http:get:: /exhibitors/api/v1/vouchers/
Returns a list of all vouchers connected to the exhibitor.
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
The app should dynamically show these values (read-only) with the labels sent by the server.
**Example request**:
.. sourcecode:: http
GET /exhibitors/api/v1/vouchers/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"subevent": null,
"tag": "testvoucher",
"comment": "",
"items": [
"All"
],
"price_mode": "set",
"value": "12.00",
"redemptions": [
{
"attendee_fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
],
"redemption_date": "2026-05-06",
"subevent": null
},
]
}
]
}
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
.. http:get:: /exhibitors/api/v1/vouchers/(id)/
Returns the details of a single, specific voucher connected to the exhibitor.
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
The app should dynamically show these values (read-only) with the labels sent by the server.
**Example request**:
.. sourcecode:: http
GET /exhibitors/api/v1/vouchers/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"subevent": null,
"tag": "testvoucher",
"comment": "",
"items": [
"All"
],
"price_mode": "set",
"value": "12.00",
"redemptions": [
{
"attendee_fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
],
"redemption_date": "2026-05-06",
"subevent": null
},
]
}
:param id: The ``id`` field of the voucher to fetch
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
:statuscode 404: Voucher not found in system

View File

@@ -70,6 +70,7 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.subevent.changed``
* ``pretix.subevent.deleted``
* ``pretix.event.item.*``
* ``pretix.event.quota.*``
* ``pretix.event.live.activated``
* ``pretix.event.live.deactivated``
* ``pretix.event.testmode.activated``

View File

@@ -110,6 +110,56 @@ process::
However, beware that code changes will not auto-reload within Celery.
Running the local development server will also automatically start a vite dev server for all control vue components.
Run the widget development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To locally develop the presale widget you need to start a separate vite dev server using::
npm run dev:widget
You can control the org, event and much more via query parameters like this::
http://localhost:5180/?org=testorg&event=testevent
The following query parameters are supported:
.. list-table::
:header-rows: 1
:widths: 20 20 60
* - Parameter
- Default
- Description
* - ``org``
- ``testorg``
- Organization slug
* - ``event``
- ``testevent``
- Event slug
* - ``host``
- ``http://localhost:8000``
- Backend host URL
* - ``type``
- ``widget``
- Element type: ``widget`` or ``button``
* - ``mode``
- ``dev``
- ``dev`` loads the Vite dev source, ``prod`` loads the built ``v2.{lang}.js``
* - ``lang``
- ``de``
- Language code for the prod script
* - ``button-text``
- ``Buy tickets!``
- Text content for the button (only used when ``type=button``)
Any other query parameter is passed through as an attribute on the widget/button element.
For example, ``?skip-ssl-check&list-type=calendar&items=123`` adds those attributes directly.
.. _`checksandtests`:
Code checks and unit tests

108
eslint.config.mjs Normal file
View 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',
},
},
},
])

4781
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"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",
"smol-toml": "^1.6.1",
"stylus": "^0.64.0",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.0"
}
}

View File

@@ -27,13 +27,13 @@ classifiers = [
]
dependencies = [
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
"babel",
"BeautifulSoup4==4.14.*",
"bleach==6.3.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=47.0.0",
"cryptography>=48.0.0",
"css-inline==0.20.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
@@ -93,7 +93,7 @@ dependencies = [
"redis==7.4.*",
"reportlab==4.4.*",
"requests==2.32.*",
"sentry-sdk==2.58.*",
"sentry-sdk==2.59.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -111,7 +111,7 @@ dev = [
"aiohttp==3.13.*",
"coverage",
"coveralls",
"fakeredis==2.34.*",
"fakeredis==2.35.*",
"flake8==7.3.*",
"freezegun",
"isort==8.0.*",
@@ -124,7 +124,9 @@ dev = [
"pytest-mock==3.15.*",
"pytest-sugar",
"pytest-xdist==3.8.*",
"pytest-playwright",
"pytest==9.0.*",
"playwright",
"responses",
]

View File

@@ -37,4 +37,9 @@ ignore =
CONTRIBUTING.md
Dockerfile
SECURITY.md
eslint.config.mjs
package-lock.json
package.json
tsconfig.json
vite.config.js

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -47,3 +47,5 @@ HAS_MEMCACHED = False
HAS_CELERY = False
HAS_GEOIP = False
SENTRY_ENABLED = False
VITE_DEV_MODE = False
VITE_IGNORE = False

View File

@@ -133,37 +133,43 @@ class JobRunSerializer(serializers.Serializer):
return not bool(self._errors)
class ExportFormDataField(serializers.Field):
def get_attribute(self, instance):
return (instance.export_identifier, instance.export_form_data)
def to_representation(self, value):
export_identifier, export_form_data = value
exporter = self.context['exporters'].get(export_identifier)
if exporter:
return JobRunSerializer(exporter=exporter).to_representation(export_form_data)
else:
return export_form_data
def get_value(self, dictionary):
return dictionary
def to_internal_value(self, data):
if "export_form_data" in data:
identifier = data.get('export_identifier', self.parent.instance.export_identifier if self.parent.instance else None)
exporter = self.context['exporters'].get(identifier)
if exporter:
return JobRunSerializer(exporter=exporter).to_internal_value(data["export_form_data"])
else:
return data['export_form_data']
class ScheduledExportSerializer(serializers.ModelSerializer):
schedule_next_run = serializers.DateTimeField(read_only=True)
export_identifier = serializers.ChoiceField(choices=[])
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
owner = serializers.SlugRelatedField(slug_field='email', read_only=True)
error_counter = serializers.IntegerField(read_only=True)
export_form_data = ExportFormDataField()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']]
def validate(self, attrs):
if attrs.get("export_form_data"):
identifier = attrs.get('export_identifier', self.instance.export_identifier if self.instance else None)
exporter = self.context['exporters'].get(identifier)
if exporter:
try:
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
except ValidationError as e:
raise ValidationError({"export_form_data": e.detail})
else:
raise ValidationError({"export_identifier": ["Unknown exporter."]})
return attrs
def to_representation(self, instance):
repr = super().to_representation(instance)
exporter = self.context['exporters'].get(instance.export_identifier)
if exporter:
repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"])
return repr
def validate_mail_additional_recipients(self, value):
d = value.replace(' ', '')
if len(d.split(',')) > 25:

View File

@@ -45,6 +45,12 @@ class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
return value
return super().to_representation(value)
def to_internal_value(self, data):
value = super().to_internal_value(data)
if value is not None:
return value.pk
return value
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):

View File

@@ -408,6 +408,12 @@ def register_default_webhook_events(sender, **kwargs):
_('This includes product added or deleted and changes to nested objects like '
'variations or bundles.'),
),
ParametrizedItemWebhookEvent(
'pretix.event.quota.*',
_('Quota changed'),
_('This includes related events like creation, deletion, opening or closing of quotas. '
'No webhook is sent for changes to the resulting availability.'),
),
ParametrizedEventWebhookEvent(
'pretix.event.live.activated',
_('Shop taken live'),

View File

@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
def _get_all_payment_methods(self, qs):
pps = dict(get_all_payment_providers())
return sorted([(pp, pps[pp]) for pp in set(
return sorted([(pp, pps.get(pp, pp)) for pp in set(
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
'provider', flat=True
).distinct()
@@ -330,6 +330,7 @@ class OrderListExporter(MultiSheetListExporter):
taxsum=Sum('tax_value'), grosssum=Sum('value')
)
}
payment_methods = None
if form_data.get('include_payment_amounts'):
payment_sum_cache = {
(o['order__id'], o['provider']): o['grosssum'] for o in
@@ -347,6 +348,7 @@ class OrderListExporter(MultiSheetListExporter):
grosssum=Sum('amount')
)
}
payment_methods = self._get_all_payment_methods(qs)
sum_cache = {
(o['order__id'], o['tax_rate']): o for o in
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
@@ -434,7 +436,6 @@ class OrderListExporter(MultiSheetListExporter):
)
if form_data.get('include_payment_amounts'):
payment_methods = self._get_all_payment_methods(qs)
for id, vn in payment_methods:
row.append(
payment_sum_cache.get((order.id, id), Decimal('0.00')) -

View File

@@ -35,6 +35,7 @@
import copy
import json
import logging
import re
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -47,9 +48,7 @@ from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import (
MaxValueValidator, MinValueValidator, RegexValidator,
)
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
@@ -220,20 +219,6 @@ class NamePartsFormField(forms.MultiValueField):
defaults = {
'widget': self.widget,
'max_length': kwargs.pop('max_length', None),
'validators': [
RegexValidator(
# The following characters should never appear in a name anywhere of
# the world. However, they commonly appear in inputs generated by spam
# bots.
r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.')
),
RegexValidator(
URL_RE,
inverse_match=True,
message=_('Please do not use special characters in names.')
)
]
}
self.max_length = defaults['max_length']
self.scheme_name = kwargs.pop('scheme')
@@ -255,7 +240,6 @@ class NamePartsFormField(forms.MultiValueField):
if fname == 'title' and self.scheme_titles:
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
@@ -264,7 +248,6 @@ class NamePartsFormField(forms.MultiValueField):
elif fname == 'salutation':
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[
@@ -296,6 +279,36 @@ class NamePartsFormField(forms.MultiValueField):
if sum(len(v) for v in value.values() if v) > (self.max_length or 250):
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
for fname, label, size in self.scheme['fields']:
if fname == 'salutation' or (fname == 'title' and self.scheme_titles):
continue
v = value.get(fname)
if not v:
continue
special_chars = re.findall('[$€/%§{}<>~]', v)
if special_chars:
raise forms.ValidationError(
_('The field "%(label)s" may not contain special characters such as "%(chars)s".'),
code='name_special_chars',
params={
"label": label,
"chars": "".join(special_chars),
},
)
# URL_RE checks for valid domain names, including one special TLD med, which can be part of a title
# correct spelling for URL-matching, but keep original spelling in user-provided value
v = v.replace(".med", ". med")
url_matched = URL_RE.match(v)
if url_matched:
raise forms.ValidationError(
_('The field "%(label)s" may not contain an URL (%(url)s).'),
code='url_in_title',
params={
"label": label,
"url": url_matched.group(0),
}
)
if value.get("salutation") == "empty":
value["salutation"] = ""

View 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)

View File

@@ -281,7 +281,7 @@ class SecurityMiddleware(MiddlewareMixin):
h = {
'default-src': ["{static}"],
'script-src': ['{static}'],
'script-src': ["{static}"],
'object-src': ["'none'"],
'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"],
@@ -295,6 +295,18 @@ class SecurityMiddleware(MiddlewareMixin):
# this. However, we'll restrict it to HTTPS.
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
}
if settings.VITE_DEV_MODE:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"]
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
if hasattr(request, 'csp_nonce'):
nonce = f"'nonce-{request.csp_nonce}'"
h['script-src'].append(nonce)
if not settings.VITE_DEV_MODE:
# can't have 'unsafe-inline' and nonce at the same time
h['style-src'].append(nonce)
# Only include pay.google.com for wallet detection purposes on the Payment selection page
if (
url.url_name == "event.order.pay.change" or

View File

@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('State')
return _('Attendee address') + ': ' + pgettext('address', 'State')
def clean(self, value, previous_values):
if value:

View File

@@ -125,7 +125,7 @@ class LoggingMixin:
elif isinstance(self, Event):
event = self
organizer_id = self.organizer_id
elif hasattr(self, 'event'):
elif hasattr(self, 'event') and self.event:
event = self.event
organizer_id = self.event.organizer_id
elif hasattr(self, 'organizer_id'):

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,243 @@
#
# 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
import pathlib
import re
import secrets
from urllib.parse import urljoin
from urllib.request import urlopen
import importlib_metadata as metadata
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/"
# entry_name -> {"manifest_entry": {...}, "url_base": "..."}
_PLUGIN_REGISTRY = {}
def _discover_plugin_manifests():
"""Discover plugin vite manifests at startup.
Scans installed pretix plugins for a .vite/manifest.json inside a static.dist
directory. Only non-editable (wheel) plugins are expected to ship pre-built
assets; editable plugins are served through the Vite dev server.
"""
for ep in metadata.entry_points(group='pretix.plugin'):
dist = ep.dist
if not dist or not dist.files:
continue
try:
url_info = json.loads(dist.read_text('direct_url.json') or '{}')
if url_info.get('dir_info', {}).get('editable', False):
continue # editable plugins are served via vite dev server
except Exception:
pass
# Find .vite/manifest.json inside a /static/ directory
try:
manifest_rel = None
for f in dist.files:
if f.name == 'manifest.json' and '/static/' in str(f) and '/.vite/' in str(f):
manifest_rel = f
break
if not manifest_rel:
continue
manifest_path = pathlib.Path(str(dist.locate_file(manifest_rel)))
if not manifest_path.exists():
continue
plugin_manifest = json.loads(manifest_path.read_text())
url_base = re.search(r'/static/(.+?)/\.vite/', str(manifest_rel)).group(1) + '/'
for _key, entry in plugin_manifest.items():
if entry.get('isEntry') and 'name' in entry:
_PLUGIN_REGISTRY[entry['name']] = {
'manifest_entry': entry,
'url_base': url_base,
}
except Exception:
LOGGER.warning(f"Failed to discover vite manifest for plugin {ep.name}", exc_info=True)
# Load core manifest
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)}")
# Discover plugin manifests
if not settings.VITE_IGNORE:
_discover_plugin_manifests()
def _generate_script_tag(path, attrs, src=None):
all_attrs = " ".join(f'{key}="{value}"' for key, value in attrs.items())
if src is None:
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 from the core manifest."""
tags = []
manifest_entry = _MANIFEST[asset]
if already_processed is None:
already_processed = []
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)
if "imports" in manifest_entry:
for import_path in manifest_entry["imports"]:
tags += _generate_css_tags(import_path, already_processed)
return tags
def _generate_plugin_css_tags(manifest_entry, url_base):
"""Build CSS tags for a plugin manifest entry."""
tags = []
if "css" in manifest_entry:
for css_path in manifest_entry["css"]:
full_path = urljoin(settings.STATIC_URL, url_base + css_path)
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
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 ""
# Check plugin registry (non-editable plugins with pre-built assets)
if path in _PLUGIN_REGISTRY:
info = _PLUGIN_REGISTRY[path]
entry = info['manifest_entry']
url_base = info['url_base']
tags = _generate_plugin_css_tags(entry, url_base)
# Always use STATIC_URL for pre-built plugin assets, even in dev mode
src = urljoin(settings.STATIC_URL, url_base + entry["file"])
tags.append(_generate_script_tag(path, {"type": "module", "crossorigin": ""}, src=src))
return "".join(tags)
# Dev mode: editable plugins and core entries go through the vite dev server
if settings.VITE_DEV_MODE:
return _generate_script_tag(path, {"type": "module"})
# Prod mode
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"})
_dev_importmap_cache = None
def _get_dev_importmap():
"""Fetch the shared-dep import map from the Vite dev server. Cached after first call."""
global _dev_importmap_cache
if _dev_importmap_cache is not None:
return _dev_importmap_cache
try:
url = urljoin(settings.VITE_DEV_SERVER, "/__pretix_importmap")
raw = json.loads(urlopen(url, timeout=2).read())
_dev_importmap_cache = {
dep: urljoin(settings.VITE_DEV_SERVER, dep_path)
for dep, dep_path in raw.items()
}
except Exception:
LOGGER.warning("Failed to fetch import map from Vite dev server")
_dev_importmap_cache = {}
return _dev_importmap_cache
@register.simple_tag(takes_context=True)
@mark_safe
def vite_importmap(context):
"""Emit an import map so pre-built plugin assets can resolve shared dependencies like vue."""
imports = {}
if settings.VITE_DEV_MODE:
# Fetch the import map from the Vite dev server (served by sharedDepsPlugin)
imports.update(_get_dev_importmap())
else:
# Discover all _vendor/* entries from the core manifest
for _key, entry in _MANIFEST.items():
name = entry.get("name", "")
if name.startswith("_vendor/"):
bare_specifier = name[len("_vendor/"):]
imports[bare_specifier] = urljoin(settings.STATIC_URL, MANIFEST_BASE + entry["file"])
if not imports:
return ""
# Generate a nonce and store it on the request so the CSP middleware can allow it
nonce = secrets.token_urlsafe(16)
request = context.get('request')
if request:
request.csp_nonce = nonce
return f'<script type="importmap" nonce="{nonce}">{json.dumps({"imports": imports})}</script>'

View File

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

View File

@@ -34,11 +34,11 @@
# License for the specific language governing permissions and limitations under the License.
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
from typing import Optional
import bleach
import dateutil.parser
from django.dispatch import receiver
from django.urls import reverse
from django.utils.formats import date_format
@@ -248,7 +248,7 @@ class OrderValidFromChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity start date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
'new_value') else ''
)
@@ -260,7 +260,7 @@ class OrderValidUntilChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity end date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
)
@@ -364,7 +364,7 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
data['posid'] = logentry.parsed_data.get('positionid', '?')
if 'datetime' in data:
dt = dateutil.parser.parse(data.get('datetime'))
dt = datetime.fromisoformat(data.get('datetime'))
if abs((logentry.datetime - dt).total_seconds()) > 5 or data.get('forced'):
if event:
data['datetime'] = date_format(dt.astimezone(event.timezone), "SHORT_DATETIME_FORMAT")
@@ -430,7 +430,7 @@ class OrderPrintLogEntryType(OrderLogEntryType):
return _('Position #{posid} has been printed at {datetime} with type "{type}".').format(
posid=data.get('positionid'),
datetime=date_format(
dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone),
datetime.fromisoformat(data["datetime"]).astimezone(logentry.event.timezone),
"SHORT_DATETIME_FORMAT"
) if logentry.event else data["datetime"],
type=dict(PrintLog.PRINT_TYPES)[data["type"]],
@@ -985,7 +985,7 @@ class LegacyCheckinLogEntryType(OrderLogEntryType):
def display(self, logentry, data):
# deprecated
dt = dateutil.parser.parse(data.get('datetime'))
dt = datetime.fromisoformat(data.get('datetime'))
tz = logentry.event.timezone
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in data:

View File

@@ -2,6 +2,7 @@
{% load static %}
{% load i18n %}
{% load statici18n %}
{% load vite %}
{% load eventsignal %}
{% load eventurl %}
{% load dialog %}
@@ -84,6 +85,7 @@
<meta name="theme-color" content="#3b1c4a">
<meta name="referrer" content="origin">
{% vite_importmap %}
{% block custom_header %}{% endblock %}
</head>
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"

View File

@@ -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 }}
@@ -125,13 +89,10 @@
</button>
</div>
</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>
{% if items %}
{{ items|json_script:"items" }}
{% 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 +105,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 %}

View File

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

View File

@@ -23,9 +23,9 @@
<legend>{% trans "How should the refund be sent?" %}</legend>
<p>
{% blocktrans trimmed %}
Any payments that you selected for automatical refunds will be immediately communicate the refund
request to the respective payment provider. Manual refunds will be created as pending refunds, you
can then later mark them as done once you actually transferred the money back to the customer.
Any payments you selected for automatic refunds will have the refund request sent immediately to the
respective payment provider. Manual refunds will be created as pending refunds, which you can later
mark as done once you have actually transferred the money back to the customer.
{% endblocktrans %}
</p>

View File

@@ -108,7 +108,7 @@
</a>
</p>
{% endif %}
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post">
<form action="#will-be-overridden" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}

View File

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

View File

@@ -4,8 +4,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-05-04 14:19+0000\n"
"Last-Translator: Mie Frydensbjerg <mif@aarhus.dk>\n"
"PO-Revision-Date: 2026-05-12 12:55+0000\n"
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/"
"da/>\n"
"Language: da\n"
@@ -13,7 +13,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.17\n"
"X-Generator: Weblate 5.17.1\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -8160,10 +8160,8 @@ msgstr ""
"2x Add-on 2"
#: pretix/base/pdf.py:383
#, fuzzy
#| msgid "List of Add-Ons"
msgid "List of Checked-In Add-Ons"
msgstr "Tilføjelser"
msgstr "Liste over indtjekkede tilvalg"
#: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537
#: pretix/control/forms/filter.py:1539
@@ -9152,10 +9150,8 @@ msgid "Czech National Bank"
msgstr "Den tjekkiske nationalbank"
#: pretix/base/services/currencies.py:41
#, fuzzy
#| msgid "Czech National Bank"
msgid "National Bank of Poland"
msgstr "Den tjekkiske nationalbank"
msgstr "Polens Nationalbank"
#: pretix/base/services/export.py:95 pretix/base/services/export.py:155
msgid ""
@@ -10224,16 +10220,12 @@ msgstr ""
"i CZK."
#: pretix/base/settings.py:577 pretix/base/settings.py:586
#, fuzzy
#| msgid ""
#| "Based on Czech National Bank daily rates, whenever the invoice amount is "
#| "not in CZK."
msgid ""
"Based on National Bank of Poland daily rates, whenever the invoice amount is "
"not in PLN."
msgstr ""
"Baseret på den tjekkiske nationalbanks dagskurs, når fakturabeløbet ikke er "
"i CZK."
"Baseret på Polens Nationalbanks dagskurser, når fakturabeløbet ikke er "
"angivet i PLN."
#: pretix/base/settings.py:597
msgid "Require invoice address"
@@ -16259,9 +16251,8 @@ msgid "Allow to overbook quotas when performing this operation"
msgstr "Tillad overbooking af kvoter, når denne handling udføres"
#: pretix/control/forms/orders.py:335
#, fuzzy
msgid "Number of products to add"
msgstr "Antal dage"
msgstr "Antal produkter, der skal tilføjes"
#: pretix/control/forms/orders.py:344
msgid "Add-on to"
@@ -16293,10 +16284,8 @@ msgstr ""
"standardpris"
#: pretix/control/forms/orders.py:441
#, fuzzy
#| msgid "You can not select the same seat multiple times."
msgid "You can not choose a seat when adding multiple products at once."
msgstr "Du kan ikke vælge den samme plads flere gange."
msgstr "Du kan ikke vælge en plads, når du tilføjer flere produkter på én gang."
#: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482
#: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552
@@ -16662,24 +16651,26 @@ msgid ""
msgstr "Din enhed har ikke adgang til noget. Vælg venligst nogle begivenheder."
#: pretix/control/forms/organizer.py:677 pretix/plugins/stripe/payment.py:330
#, fuzzy
msgid "experimental"
msgstr "Funktioner"
msgstr "eksperimentel"
#: pretix/control/forms/organizer.py:683
msgid ""
"This feature is currently in an experimental stage. It only supports very "
"limited use cases and might change at any point."
msgstr ""
"Denne funktion er i øjeblikket på forsøgsstadiet. Den understøtter kun meget "
"få anvendelsessituationer og kan ændres når som helst."
#: pretix/control/forms/organizer.py:706
msgid "Sensitive emails like password resets will not be sent in Bcc."
msgstr ""
"Følsomme e-mails, såsom dem om nulstilling af adgangskoder, vil ikke blive "
"sendt som Bcc."
#: pretix/control/forms/organizer.py:716
#, fuzzy
msgid "This will be attached to every email."
msgstr "Bliver tilføjet alle e-mails. Tilgængelige pladsholdere: {event}"
msgstr "Dette vil blive vedhæftet til hver eneste e-mail."
#: pretix/control/forms/organizer.py:790 pretix/control/logdisplay.py:671
#: pretix/control/views/user.py:850 pretix/presale/views/customer.py:289
@@ -16688,63 +16679,58 @@ msgid "Your password has been changed."
msgstr "Din adgangskode er blevet ændret."
#: pretix/control/forms/organizer.py:823
#, fuzzy
msgctxt "webhooks"
msgid "Event types"
msgstr "Arrangementsdato"
msgstr "Begivenhedstyper"
#: pretix/control/forms/organizer.py:857
#, fuzzy
msgid "Gift card value"
msgstr "Gavekort"
msgstr "Gavekortets værdi"
#: pretix/control/forms/organizer.py:961
#, fuzzy
msgid "An medium with this type and identifier is already registered."
msgstr "Denne bestilling er allerede blevet tilbagebetalt."
msgstr ""
"Der findes allerede et medie med denne type og dette identifikationsnummer."
#: pretix/control/forms/organizer.py:1059
#, fuzzy
msgid "An account with this customer ID is already registered."
msgstr "Denne bestilling er allerede blevet tilbagebetalt."
msgstr "Der findes allerede en konto med dette kunde-id."
#: pretix/control/forms/organizer.py:1076
#: pretix/control/templates/pretixcontrol/organizers/customer.html:62
#: pretix/presale/forms/customer.py:169 pretix/presale/forms/customer.py:507
msgid "Phone"
msgstr ""
msgstr "Telefon"
#: pretix/control/forms/organizer.py:1190
msgctxt "sso_oidc"
msgid "Base URL"
msgstr ""
msgstr "Grund-URL"
#: pretix/control/forms/organizer.py:1194
#, fuzzy
msgctxt "sso_oidc"
msgid "Client ID"
msgstr "Klient-id"
msgstr "Kunde-ID"
#: pretix/control/forms/organizer.py:1198
#, fuzzy
msgctxt "sso_oidc"
msgid "Client secret"
msgstr "Arrangementsrække"
msgstr "Kundens sikkerhedsnøgle"
#: pretix/control/forms/organizer.py:1202
msgctxt "sso_oidc"
msgid "Scope"
msgstr ""
msgstr "Omfang"
#: pretix/control/forms/organizer.py:1203
msgctxt "sso_oidc"
msgid "Multiple scopes separated with spaces."
msgstr ""
msgstr "Flere omfang adskilt med mellemrum."
#: pretix/control/forms/organizer.py:1207
msgctxt "sso_oidc"
msgid "User ID field"
msgstr ""
msgstr "Feltet Bruger-ID"
#: pretix/control/forms/organizer.py:1208
msgctxt "sso_oidc"
@@ -16752,12 +16738,13 @@ msgid ""
"We will assume that the contents of the user ID fields are unique and can "
"never change for a user."
msgstr ""
"Vi antager, at indholdet i felterne til bruger-id er unikt og aldrig kan "
"ændres for en bruger."
#: pretix/control/forms/organizer.py:1214
#, fuzzy
msgctxt "sso_oidc"
msgid "Email field"
msgstr "Alle fakturaer"
msgstr "E-mail-felt"
#: pretix/control/forms/organizer.py:1215
msgctxt "sso_oidc"
@@ -16766,17 +16753,19 @@ msgid ""
"verified to really belong the the user. If this can't be guaranteed, "
"security issues might arise."
msgstr ""
"Vi går ud fra, at alle e-mailadresser, vi modtager fra SSO-udbyderen, er "
"verificeret, så vi kan være sikre på, at de tilhører brugeren. Hvis dette "
"ikke kan garanteres, kan der opstå sikkerhedsproblemer."
#: pretix/control/forms/organizer.py:1222
#, fuzzy
msgctxt "sso_oidc"
msgid "Phone field"
msgstr "Telefonnummer"
msgstr "Feltet \"Telefon\""
#: pretix/control/forms/organizer.py:1226
msgctxt "sso_oidc"
msgid "Query parameters"
msgstr ""
msgstr "Forespørgselsparametre"
#: pretix/control/forms/organizer.py:1227
#, python-brace-format
@@ -16785,20 +16774,20 @@ msgid ""
"Optional query parameters, that will be added to calls to the authorization "
"endpoint. Enter as: {example}"
msgstr ""
"Valgfrie forespørgselsparametre, der tilføjes til opkald til "
"godkendelsesendepunktet. Indtast som: {example}"
#: pretix/control/forms/organizer.py:1288
msgid "Invalidate old client secret and generate a new one"
msgstr ""
msgstr "Ugyldiggør den gamle klient-sikkerhedsnøgle og generer en ny"
#: pretix/control/forms/organizer.py:1321
#, fuzzy
msgid "Organizer short name"
msgstr "Navn"
msgstr "Arrangørens korte navn"
#: pretix/control/forms/organizer.py:1325
#, fuzzy
msgid "Allow access to reusable media"
msgstr "Deaktiveret"
msgstr "Tillad adgang til genbrugsmedier"
#: pretix/control/forms/organizer.py:1326
msgid ""
@@ -16808,26 +16797,27 @@ msgid ""
"will grant the other organizer access to cryptographic key material required "
"to interact with the media type."
msgstr ""
"Dette er nødvendigt, hvis du ønsker, at den anden arrangør skal deltage i et "
"fælles system med f.eks. NFC-betalingschips. Du bør kun benytte denne "
"mulighed for arrangører, du stoler på, da dette (afhængigt af de aktiverede "
"medietyper) giver den anden arrangør adgang til det kryptografiske "
"nøglemateriale, der er nødvendigt for at kunne interagere med medietypen."
#: pretix/control/forms/organizer.py:1342
#, fuzzy
msgid "The selected organizer does not exist or cannot be invited."
msgstr "Delarrangementet tilhører ikke dette arrangement."
msgstr "Den valgte arrangør findes ikke eller kan ikke inviteres."
#: pretix/control/forms/organizer.py:1344
#, fuzzy
msgid "The selected organizer has already been invited."
msgstr "Den valgt arrangør findes ikke."
msgstr "Den valgte arrangør er allerede blevet inviteret."
#: pretix/control/forms/organizer.py:1379
#, fuzzy
#| msgid "A voucher with this code already exists."
msgid "A sales channel with the same identifier already exists."
msgstr "En rabatkode med denne kode findes allerede."
msgstr "Der findes allerede en salgskanal med samme identifikator."
#: pretix/control/forms/organizer.py:1391
msgid "Events with active plugin"
msgstr ""
msgstr "Begivenheder med aktivt plugin"
#: pretix/control/forms/renderers.py:56
#: pretix/control/templates/pretixcontrol/items/question_edit.html:139
@@ -16840,22 +16830,21 @@ msgstr "Valgfrit"
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html:49
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html:192
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html:286
#, fuzzy
msgctxt "form_bulk"
msgid "change"
msgstr "Gem ændringer"
msgstr "ændring"
#: pretix/control/forms/rrule.py:35
msgid "year(s)"
msgstr ""
msgstr "år"
#: pretix/control/forms/rrule.py:36
msgid "month(s)"
msgstr ""
msgstr "måned(er)"
#: pretix/control/forms/rrule.py:37
msgid "week(s)"
msgstr ""
msgstr "uge(r)"
#: pretix/control/forms/rrule.py:38
msgid "day(s)"
@@ -16863,7 +16852,7 @@ msgstr "dag(e)"
#: pretix/control/forms/rrule.py:43
msgid "Interval"
msgstr ""
msgstr "Interval"
#: pretix/control/forms/rrule.py:69
msgid "Number of repetitions"
@@ -16876,22 +16865,22 @@ msgstr "Seneste dato"
#: pretix/control/forms/rrule.py:87 pretix/control/forms/rrule.py:134
msgctxt "rrule"
msgid "first"
msgstr ""
msgstr "første"
#: pretix/control/forms/rrule.py:88 pretix/control/forms/rrule.py:135
msgctxt "rrule"
msgid "second"
msgstr ""
msgstr "anden"
#: pretix/control/forms/rrule.py:89 pretix/control/forms/rrule.py:136
msgctxt "rrule"
msgid "third"
msgstr ""
msgstr "tredje"
#: pretix/control/forms/rrule.py:90 pretix/control/forms/rrule.py:137
msgctxt "rrule"
msgid "last"
msgstr ""
msgstr "sidste"
#: pretix/control/forms/rrule.py:111 pretix/control/forms/rrule.py:150
#: pretix/presale/templates/pretixpresale/fragment_calendar_nav.html:20
@@ -16905,7 +16894,7 @@ msgstr "Weekend dag"
#: pretix/control/forms/subevents.py:106
msgctxt "subevent"
msgid "Skip dates that overlap with any existing date"
msgstr ""
msgstr "Spring datoer over, der overlapper med eksisterende datoer"
#: pretix/control/forms/subevents.py:109
msgctxt "subevent"
@@ -16915,20 +16904,24 @@ msgid ""
"This respects even inactive dates and works best if all dates have both a "
"start and end time."
msgstr ""
"Dette kan være nyttigt, hvis alle dine datoer finder sted på samme sted, og "
"der ikke må oprettes gentagne datoer, der er i konflikt med eksisterende "
"særlige begivenheder. Dette gælder også for inaktive datoer og fungerer "
"bedst, hvis alle datoer har både en start- og en sluttid."
#: pretix/control/forms/subevents.py:128
#, fuzzy
msgid "Keep the current values"
msgstr "Aktuelle problemer"
msgstr "Bevar de nuværende værdier"
#: pretix/control/forms/subevents.py:145 pretix/control/forms/subevents.py:151
msgid "Selection contains various values"
msgstr ""
msgstr "Udvalget indeholder forskellige værdier"
#: pretix/control/forms/subevents.py:298 pretix/control/forms/subevents.py:327
#, fuzzy
msgid "The end of availability should be after the start of availability."
msgstr "Arrangementets sluttidspunkt skal være efter starttidspunktet."
msgstr ""
"Slutdatoen for tilgængeligheden bør ligge efter startdatoen for "
"tilgængeligheden."
#: pretix/control/forms/subevents.py:360
#, fuzzy

View File

@@ -5,7 +5,7 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-05-02 23:00+0000\n"
"PO-Revision-Date: 2026-05-06 00:00+0000\n"
"Last-Translator: Daniel Musketa <daniel@musketa.de>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/"
"de/>\n"
@@ -14,7 +14,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.17\n"
"X-Generator: Weblate 5.17.1\n"
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
#: pretix/_base_settings.py:87
@@ -3072,7 +3072,7 @@ msgstr "Wertgutscheine"
#: pretix/base/exporters/orderlist.py:1367
msgid "Download a spreadsheet of all gift cards including their current value."
msgstr ""
"Tabelle (Excel oder CSV) mit allen Wertgutscheinen und deren aktuellen Wert."
"Tabelle (Excel oder CSV) mit allen Wertgutscheinen und deren aktuellem Wert."
#: pretix/base/exporters/orderlist.py:1378
msgid "Show value at"
@@ -36026,7 +36026,7 @@ msgid ""
"the order clicked the link in the email they received to confirm the email "
"address is valid."
msgstr ""
"Sie können Ihre Tickets hier herunterladen sobald die Person, die die "
"Sie können Ihre Tickets hier herunterladen, sobald die Person, die die "
"Bestellung aufgegeben hat, einen Link in der an sie geschickten E-Mail "
"geklickt hat."
@@ -37012,10 +37012,10 @@ msgid ""
"need the ticket any more, please be so kind and remove your ticket from the "
"list so we can pass it on to the next person waiting as quickly as possible!"
msgstr ""
"Sie wurden von unserer Warteliste ausgewählt um ein Ticket zu erhalten. Wenn "
"Sie das Ticket nicht mehr brauchen, helfen Sie uns indem Sie sich von der "
"Warteliste entfernen, sodass wir das Ticket schnellstmöglich an die nächste "
"wartende Person weitergeben können."
"Sie wurden von unserer Warteliste ausgewählt, um ein Ticket zu erhalten. "
"Wenn Sie das Ticket nicht mehr brauchen, helfen Sie uns, indem Sie sich von "
"der Warteliste entfernen, sodass wir das Ticket schnellstmöglich an die "
"nächste wartende Person weitergeben können."
#: pretix/presale/templates/pretixpresale/event/waitinglist_remove.html:16
msgctxt "waitinglist"

View File

@@ -4,8 +4,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-04-30 18:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"PO-Revision-Date: 2026-05-08 04:00+0000\n"
"Last-Translator: corentin-spec <corentin@spectentaculaire.fr>\n"
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/"
"fr/>\n"
"Language: fr\n"
@@ -13,7 +13,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.17\n"
"X-Generator: Weblate 5.17.1\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -37884,7 +37884,7 @@ msgstr "Votre panier a été mis à jour."
#: pretix/presale/views/cart.py:525 pretix/presale/views/cart.py:551
msgid "Your cart is now empty."
msgstr "Votre panier à été vidé."
msgstr "Votre panier a été vidé."
#: pretix/presale/views/cart.py:584
msgid ""

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-03-27 09:03+0000\n"
"Last-Translator: Ivano Voghera <ivano.voghera@gmail.com>\n"
"PO-Revision-Date: 2026-05-12 04:00+0000\n"
"Last-Translator: Stefano Campus <stefano.campus@regione.piemonte.it>\n"
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix/"
"it/>\n"
"Language: it\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.16.2\n"
"X-Generator: Weblate 5.17.1\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -8598,6 +8598,8 @@ msgid ""
"Includes the ability to give someone (including oneself) additional "
"permissions."
msgstr ""
"Consente di assegnare a qualcuno (compreso se stessi) autorizzazioni "
"aggiuntive."
#: pretix/base/permissions.py:298 pretix/control/navigation.py:608
#: pretix/control/templates/pretixcontrol/organizers/customers.html:6
@@ -8609,13 +8611,14 @@ msgstr "Indirizzi Email (file di testo)"
#: pretix/base/permissions.py:310 pretix/control/navigation.py:666
#: pretix/control/navigation.py:673
msgid "Devices"
msgstr ""
msgstr "Dispositivi"
#: pretix/base/permissions.py:316
msgid ""
"Includes the ability to give access to events and data oneself does not have "
"access to."
msgstr ""
"Consente di concedere l'accesso a eventi e dati a cui non si ha accesso."
#: pretix/base/permissions.py:321
#, fuzzy
@@ -8747,6 +8750,8 @@ msgid ""
"Some products can no longer be purchased and have been removed from your "
"cart for the following reason: %s"
msgstr ""
"Alcuni prodotti non sono più disponibili e sono stati rimossi dal tuo "
"carrello per il seguente motivo: %s"
#: pretix/base/services/cart.py:117
msgid ""
@@ -10060,6 +10065,8 @@ msgid ""
"For business customers, compute taxes based on net total. For individuals, "
"use line-based rounding"
msgstr ""
"Per i clienti aziendali, calcolare le imposte sul totale al netto. Per i "
"privati, applicare l'arrotondamento per singola voce"
#: pretix/base/settings.py:85
msgid "Compute taxes based on net total with stable gross prices"
@@ -10096,6 +10103,8 @@ msgstr ""
#: pretix/base/settings.py:190
msgid "Require login to access order confirmation pages"
msgstr ""
"È necessario effettuare l'accesso per visualizzare le pagine di conferma "
"dell'ordine"
#: pretix/base/settings.py:191
msgid ""

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-04-20 08:07+0000\n"
"PO-Revision-Date: 2026-05-12 06:34+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.17\n"
"X-Generator: Weblate 5.17.1\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -4441,7 +4441,7 @@ msgstr "全ての製品(新規に作成されたものを含む)"
#: pretix/base/models/checkin.py:56 pretix/plugins/badges/exporters.py:436
#: pretix/plugins/checkinlists/exporters.py:854
msgid "Limit to products"
msgstr "商品の上限"
msgstr "対象製品を限定"
#: pretix/base/models/checkin.py:60
msgid ""
@@ -6896,8 +6896,8 @@ msgstr "免税輸出品目、VAT非課税"
msgctxt "tax_code"
msgid "VAT exempt for EEA intra-community supply of goods and services"
msgstr ""
"EEA欧州経済領域域内事業者間取引における品・サービス供給のVAT(付加価値"
"税)免税"
"EEA(欧州経済領域)域内事業者間取引における品・サービス供給のVAT(付加価値税)"
"免税"
#: pretix/base/models/tax.py:186
msgid "Special cases"
@@ -7144,10 +7144,10 @@ msgid ""
"usages in some cases can be lower than this limit, e.g. in case of "
"cancellations."
msgstr ""
"複数1を超える値に設定した場合、バウチャーは初回使用時にこの数の製品と引き"
"換える必要があります。その後の使用では、より少ない数の製品にも使用できます。"
"ただし、キャンセルなどの場合には、合計使用回数がこの限を下回ることがありま"
"。"
"1より大きい値を設定すると、バウチャーを最初に使用する際に、この数の製品に対し"
"て引き換える必要があります。2回目以降の使用では、これより少ない数の製品に対し"
"ても使用できます。この場合、キャンセルなどにより、合計使用回数がこの限を"
"下回ることがある点にご注意ください。"
#: pretix/base/models/vouchers.py:217
msgid ""
@@ -8059,10 +8059,8 @@ msgstr ""
"2x アドオン2"
#: pretix/base/pdf.py:383
#, fuzzy
#| msgid "List of Add-Ons"
msgid "List of Checked-In Add-Ons"
msgstr "アドオンのリスト"
msgstr "チェックイン済みアドオン一覧"
#: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537
#: pretix/control/forms/filter.py:1539
@@ -9019,10 +9017,8 @@ msgid "Czech National Bank"
msgstr "チェコ国立銀行"
#: pretix/base/services/currencies.py:41
#, fuzzy
#| msgid "Czech National Bank"
msgid "National Bank of Poland"
msgstr "チェコ国立銀行"
msgstr "ポーランド国立銀行"
#: pretix/base/services/export.py:95 pretix/base/services/export.py:155
msgid ""
@@ -10068,14 +10064,10 @@ msgid ""
msgstr "チェコ国立銀行の日次レートに基づいて、請求書の金額がCZK以外の場合。"
#: pretix/base/settings.py:577 pretix/base/settings.py:586
#, fuzzy
#| msgid ""
#| "Based on Czech National Bank daily rates, whenever the invoice amount is "
#| "not in CZK."
msgid ""
"Based on National Bank of Poland daily rates, whenever the invoice amount is "
"not in PLN."
msgstr "チェコ国立銀行の日次レートに基づいて、請求書の金額がCZK以外の場合。"
msgstr "ポーランド国立銀行の日次レートに基づいて、請求書の金額がPLN以外の場合。"
#: pretix/base/settings.py:597
msgid "Require invoice address"
@@ -15956,10 +15948,8 @@ msgid "Allow to overbook quotas when performing this operation"
msgstr "この操作を実行する際にクォータの超過予約を許可する"
#: pretix/control/forms/orders.py:335
#, fuzzy
#| msgid "Number of orders"
msgid "Number of products to add"
msgstr "注文数"
msgstr "追加する製品の数"
#: pretix/control/forms/orders.py:344
msgid "Add-on to"
@@ -15991,10 +15981,8 @@ msgstr ""
"さい"
#: pretix/control/forms/orders.py:441
#, fuzzy
#| msgid "You can not select the same seat multiple times."
msgid "You can not choose a seat when adding multiple products at once."
msgstr "同じ席を複数回選択することはできません。"
msgstr "複数の製品を同時に追加する場合、座席を選択することはできません。"
#: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482
#: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552
@@ -16596,7 +16584,7 @@ msgstr "週末の日"
#: pretix/control/forms/subevents.py:106
msgctxt "subevent"
msgid "Skip dates that overlap with any existing date"
msgstr ""
msgstr "既存の日付と重複する日付をスキップする"
#: pretix/control/forms/subevents.py:109
msgctxt "subevent"
@@ -16606,6 +16594,9 @@ msgid ""
"This respects even inactive dates and works best if all dates have both a "
"start and end time."
msgstr ""
"これは、すべての日付が同じ場所で行われ、既存の特別イベントと競合して重複した"
"日付が作成されない場合に有用です。これは、非アクティブな日付さえも尊重し、す"
"べての日付に開始時刻と終了時刻の両方がある場合に最も効果的です。"
#: pretix/control/forms/subevents.py:128
msgid "Keep the current values"
@@ -22963,12 +22954,11 @@ msgid ""
"total number of tickets sold and the number of a specific ticket type at the "
"same time."
msgstr ""
"製品を実際に利用可能にするには、クォータも必要です。クォータは、pretixが製品"
"のインスタンスをいくつ販売するかを定義します。これにより、イベント無制限"
"参加者を受け入れることができるか、参加者数が制限されるかを設定できます。より"
"複雑な要件を満たすために、製品を複数のクォータに割り当てることができます。例"
"えば、販売されるチケットの総数と特定のチケット種別の数を同時に制限したい場合"
"などです。"
"製品を実際に販売可能にするには、クォータも必要です。クォータは、製品をどれだ"
"けpretixが販売するかを定義します。これにより、イベントの参加者数を無制限にす"
"るか、人数を制限するかを設定できます。1つの製品を複数のクォータに割り当てるこ"
"とで、より複雑な要件にも対応できます。たとえば、販売するチケットの総数と特定"
"のチケット種別の数を同時に制限したい場合などです。"
#: pretix/control/templates/pretixcontrol/items/quotas.html:25
msgid "Your search did not match any quotas."
@@ -23685,7 +23675,7 @@ msgid ""
"this product was part of the discount calculation for a different product in "
"this order."
msgstr ""
"自動割引によりこの品の価格が引き下げられたか、同じ注文内の別の品に対する"
"自動割引によりこの品の価格が引き下げられたか、同じ注文内の別の品に対する"
"割引計算の対象になっています。"
#: pretix/control/templates/pretixcontrol/order/index.html:496
@@ -29425,7 +29415,7 @@ msgstr "一度に10万以上の日付を作成しないでください。"
#: pretix/control/views/subevents.py:966
msgid "All dates would be skipped because they conflict with existing dates."
msgstr ""
msgstr "すべての日付は、既存の日付と衝突するため、スキップされます。"
#: pretix/control/views/subevents.py:1102
#, python-brace-format
@@ -34613,7 +34603,7 @@ msgid ""
"changed because they are not on sale:"
msgstr ""
"このアドオンカテゴリで選択された製品の中には、現在セール対象外のため変更でき"
"ない品があります"
"ない品があります:"
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:392
msgid "There are no add-ons available for this product."

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:04+0000\n"
"PO-Revision-Date: 2026-03-23 21:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"PO-Revision-Date: 2026-05-12 06:34+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-"
"js/ja/>\n"
"Language: ja\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.16.2\n"
"X-Generator: Weblate 5.17.1\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -572,7 +572,7 @@ msgstr "未入場"
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:289
msgid "Error: Product not found!"
msgstr "エラー:品が見つかりません!"
msgstr "エラー:品が見つかりません!"
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:296
msgid "Error: Variation not found!"
@@ -743,7 +743,7 @@ msgstr "カートの有効期限が近づいています。"
#: pretix/static/pretixpresale/js/ui/cart.js:62
msgid "The items in your cart are reserved for you for one minute."
msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] "カート内の品はあと {num} 分間確保されています。"
msgstr[0] "カート内の品はあと {num} 分間確保されています。"
#: pretix/static/pretixpresale/js/ui/cart.js:83
msgid "Your cart has expired."
@@ -754,7 +754,7 @@ msgid ""
"The items in your cart are no longer reserved for you. You can still "
"complete your order as long as they're available."
msgstr ""
"カート内の品の確保期限が切れました。在庫があれば、このまま注文を完了するこ"
"カート内の品の確保期限が切れました。在庫があれば、このまま注文を完了するこ"
"とができます。"
#: pretix/static/pretixpresale/js/ui/cart.js:87
@@ -987,7 +987,7 @@ msgid ""
"You currently have an active cart for this event. If you select more "
"products, they will be added to your existing cart."
msgstr ""
"このイベントのカートに品が入っています。品を追加すると、既存のカートに追"
"このイベントのカートに品が入っています。品を追加すると、既存のカートに追"
"加されます。"
#: pretix/static/pretixpresale/js/widget/widget.js:57

View File

@@ -82,7 +82,8 @@ class CheckInListMixin(BaseExporter):
widget=forms.RadioSelect(
attrs={'class': 'scrolling-choice'}
),
initial=self.event.checkin_lists.first()
initial=self.event.checkin_lists.first(),
required=True
)),
('date_range',
DateFrameField(
@@ -143,7 +144,6 @@ class CheckInListMixin(BaseExporter):
if not self.event.has_subevents:
del d['date_range']
d['list'].queryset = self.event.checkin_lists.all()
d['list'].widget = Select2(
attrs={
'data-model-select2': 'generic',
@@ -155,7 +155,6 @@ class CheckInListMixin(BaseExporter):
}
)
d['list'].widget.choices = d['list'].choices
d['list'].required = True
return d

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.13 on 2026-05-06 15:45
from django.db import migrations
from django.db.models import F
def remove_cross_event_scheduled_mails(apps, schema_editor):
Rule = apps.get_model("sendmail", "Rule")
ScheduledMail = apps.get_model("sendmail", "ScheduledMail")
ScheduledMail.objects.filter(subevent__isnull=False).exclude(subevent__event=F('rule__event')).delete()
Rule.objects.filter(subevent__isnull=False).exclude(subevent__event=F('event')).delete()
class Migration(migrations.Migration):
replaces = [('sendmail', '0011_remove_cross_event_scheduled_mails'), ('sendmail', '0012_remove_cross_event_scheduled_mails')]
dependencies = [
('sendmail', '0010_auto_20250801_1342'),
]
operations = [
migrations.RunPython(
code=remove_cross_event_scheduled_mails,
),
]

View File

@@ -0,0 +1,17 @@
from django.db import migrations
from django.db.models import F
def remove_cross_event_scheduled_mails(apps, schema_editor):
ScheduledMail = apps.get_model("sendmail", "ScheduledMail")
ScheduledMail.objects.filter(subevent__isnull=False).exclude(subevent__event=F('rule__event')).delete()
class Migration(migrations.Migration):
dependencies = [
("sendmail", "0011_remove_cross_event_scheduled_mails"),
]
operations = [
migrations.RunPython(remove_cross_event_scheduled_mails),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,9 +122,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
@@ -153,13 +166,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')
@@ -181,20 +197,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')
@@ -215,15 +236,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):
@@ -231,21 +259,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'] = '*'

View File

@@ -888,3 +888,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')

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
<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
// space between icon and string
|
| {{ gettext("Edit") }}
li(role="presentation")
a(href="#rules-viz", role="tab", data-toggle="tab")
span.fa.fa-eye
// space between icon and string
|
| {{ gettext("Visualize") }}
//- Tab panes
.tab-content
#rules-edit.tab-pane.active(v-if="items", role="tabpanel")
RulesEditor
#rules-viz.tab-pane(role="tabpanel")
RulesVisualization
.alert.alert-info(v-if="missingItems.length")
p {{ gettext("Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:") }}
ul
li(v-for="h in missingItems", :key="h") {{ h }}
p {{ gettext("Please double-check if this was intentional.") }}
</template>
<style lang="stylus">
</style>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,198 @@
/* 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'
}
}
export const TIME_OPTIONS = {
...DATETIME_OPTIONS,
format: document.body.dataset.timeformat,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,8 +114,13 @@ var setCookie = function (cname, cvalue, exdays) {
var expires = "expires=Thu, 01 Jan 1970 00:00:00 GMT";
cvalue = "";
}
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
var same_site = "";
if (site_is_secure()) {
same_site = ";SameSite=None;Secure"
}
document.cookie = cname + "=" + cvalue + ";" + expires + same_site + ";path=/";
};
var getCookie = function (name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
@@ -2052,11 +2057,16 @@ var shared_root_methods = {
})
},
get_cart_id: function() {
if (this.$root.keep_cart) {
return getCookie(this.$root.cookieName);
if (!this.$root.keep_cart) {
return null
}
if (this.$root.cart_id) {
return this.$root.cart_id
}
return getCookie(this.$root.cookieName);
},
set_cart_id: function(newValue) {
this.$root.cart_id = newValue
setCookie(this.$root.cookieName, newValue, 30);
},
};
@@ -2359,6 +2369,7 @@ var create_widget = function (element, html_id=null) {
has_seating_plan_waitinglist: false,
meta_filter_fields: [],
keep_cart: true,
cart_id: null
}
},
created: function () {
@@ -2450,6 +2461,7 @@ var create_button = function (element, html_id=null) {
html_id: html_id,
button_text: button_text,
keep_cart: keep_cart || items.length > 0,
cart_id: null
}
},
created: function () {
@@ -2525,7 +2537,8 @@ window.PretixWidget.open = function (target_url, voucher, subevent, items, widge
widget_data: all_widget_data,
widget_id: 'pretix-widget-' + widget_id,
button_text: "",
keep_cart: true
keep_cart: true,
cart_id: null
}
},
created: function () {

View File

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

View File

@@ -0,0 +1,93 @@
import type { Category, DayEntry, EventEntry, MetaFilterField } from '~/types'
export class ApiError extends Error {
status: number
responseUrl: string
constructor (status: number, responseUrl: string) {
super(`HTTP ${status}`)
this.status = status
this.responseUrl = responseUrl
}
}
// --- Product list ---
export interface ProductListResponse {
target_url?: string
subevent?: string | number
name?: string
frontpage_text?: string
date_range?: string
location?: string
items_by_category?: Category[]
currency?: string
display_net_prices?: boolean
voucher_explanation_text?: string
error?: string
display_add_to_cart?: boolean
waiting_list_enabled?: boolean
show_variations_expanded?: boolean
cart_exists?: boolean
vouchers_exist?: boolean
has_seating_plan?: boolean
has_seating_plan_waitinglist?: boolean
itemnum?: number
poweredby?: string
events?: EventEntry[]
has_more_events?: boolean
meta_filter_fields?: MetaFilterField[]
weeks?: DayEntry[][]
date?: string
days?: DayEntry[]
week?: [number, number]
}
export async function fetchProductList (url: string) {
const response = await fetch(url)
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return {
data: await response.json() as ProductListResponse,
responseUrl: response.url,
}
}
export interface CartResponse {
redirect?: string
cart_id?: string
success?: boolean
message?: string
has_cart?: boolean
async_id?: string
check_url?: string
}
export async function submitCart (endpoint: string, formData: FormData) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(formData as any).toString(),
})
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return await response.json() as CartResponse
}
export async function checkAsyncTask (url: string) {
const response = await fetch(url)
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return await response.json() as CartResponse
}
export async function createCart (url: string) {
const response = await fetch(url)
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return await response.json() as CartResponse
}

View File

@@ -0,0 +1,67 @@
import { createApp, type App } from 'vue'
import ButtonComponent from '~/components/Button.vue'
import { createWidgetStore, StoreKey } from '~/sharedStore'
import { makeid } from '~/utils'
import type { WidgetData } from '~/types'
export function createButtonInstance (element: Element, htmlId?: string): App {
let targetUrl = element.attributes.event.value
if (!targetUrl.match(/\/$/)) {
targetUrl += '/'
}
const widgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data))
for (const attr of Array.from(element.attributes)) {
if (attr.name.match(/^data-.*$/)) {
widgetData[attr.name.replace(/^data-/, '')] = attr.value
}
}
const rawItems = element.attributes.items?.value || ''
// Parse items string (format: "item_1=2,item_3=1")
const buttonItems: { item: string; count: string }[] = []
for (const itemStr of rawItems.split(',')) {
if (itemStr.includes('=')) {
const [item, count] = itemStr.split('=')
buttonItems.push({ item, count })
}
}
const store = createWidgetStore({
targetUrl,
voucher: element.attributes.voucher?.value || null,
subevent: element.attributes.subevent?.value || null,
skipSsl: 'skip-ssl-check' in element.attributes,
disableIframe: 'disable-iframe' in element.attributes,
widgetData,
htmlId: htmlId || element.id || makeid(16),
isButton: true,
buttonItems,
buttonText: element.innerHTML,
keepCart: 'keep-cart' in element.attributes || buttonItems.length > 0,
})
const observer = new MutationObserver((mutationList) => {
for (const mutation of mutationList) {
if (mutation.type === 'attributes' && mutation.attributeName?.startsWith('data-')) {
const attrName = mutation.attributeName.substring(5)
const attrValue = (mutation.target as Element).getAttribute(mutation.attributeName)
if (attrValue !== null) {
store.widgetData[attrName] = attrValue
}
}
}
})
const app = createApp(ButtonComponent)
app.provide(StoreKey, store)
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-button]', info, error)
}
app.mount(element)
observer.observe(element, { attributes: true })
return app
}

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, ref, inject, onMounted } from 'vue'
import type { Item, Variation } from '~/types'
import { StoreKey, globalWidgetId } from '~/sharedStore'
import { STRINGS } from '~/i18n'
const props = defineProps<{
item: Item
variation?: Variation
}>()
const store = inject(StoreKey)!
const quantity = ref<HTMLInputElement>()
const avail = computed(() => props.item.has_variations ? props.variation.avail : props.item.avail)
const orderMax = computed(() => props.item.has_variations ? props.variation.order_max : props.item.order_max)
const inputName = computed(() => {
if (props.item.has_variations) {
return `variation_${props.item.id}_${props.variation.id}`
}
return `item_${props.item.id}`
})
const unavailabilityReasonMessage = computed(() => {
const reason = props.item.current_unavailability_reason || props.variation?.current_unavailability_reason
if (reason) {
return STRINGS[`unavailable_${reason}`] || reason
}
return ''
})
const voucherJumpLink = computed(() => `#${store.htmlId}-voucher-input`)
const ariaLabelledby = computed(() => `${store.htmlId}-item-label-${props.item.id}`)
const decLabel = computed(() => {
// TODO
const name = props.item.has_variations ? props.variation.value : props.item.name
return `- ${name}: ${STRINGS.quantity_dec}`
})
const incLabel = computed(() => {
const name = props.item.has_variations ? props.variation.value : props.item.name
return `+ ${name}: ${STRINGS.quantity_inc}`
})
const labelSelectItem = computed(() => {
if (props.item.has_variations) return STRINGS.select_variant.replace('%s', props.variation.value)
return STRINGS.select_item.replace('%s', props.item.name)
})
const waitingListShow = computed(() => avail.value[0] < 100 && store.waitingListEnabled && props.item.allow_waitinglist)
const waitingListUrl = computed(() => {
let u = `${store.targetUrl}w/${globalWidgetId}/waitinglist/?locale=${LANG}&item=${props.item.id}`
if (props.item.has_variations && props.variation) {
u += `&var=${props.variation.id}`
}
if (store.subevent) {
u += `&subevent=${store.subevent}`
}
u += `&widget_data=${encodeURIComponent(store.widgetDataJson)}`
u += store.consentParameter
return u
})
function onStep (e: Event) {
const target = e.target as HTMLElement
const button = target.tagName === 'BUTTON' ? target : target.closest('button')
if (!button || !quantity.value) return
const step = parseFloat(button.getAttribute('data-step') || '0')
const input = quantity.value
const min = parseFloat(input.min) || 0
const max = parseFloat(input.max) || Number.MAX_SAFE_INTEGER
const currentValue = parseInt(input.value || '0')
input.value = String(Math.max(min, Math.min(max, currentValue + step)))
input.dispatchEvent(new CustomEvent('change', { bubbles: true }))
}
onMounted(() => {
// Auto-select first item if single item with no variations
if (
!store.cartExists
&& store.itemnum === 1
&& (!store.categories[0]?.items[0]?.has_variations || store.categories[0]?.items[0]?.variations.length < 2)
&& !store.hasSeatingPlan
&& quantity.value
) {
quantity.value.value = '1'
if (orderMax.value === 1 && quantity.value.type === 'checkbox') {
;(quantity.value as HTMLInputElement).checked = true
}
}
})
</script>
<template lang="pug">
.pretix-widget-availability-box
.pretix-widget-availability-unavailable(v-if="item.current_unavailability_reason === 'require_voucher'")
small
a(:href="voucherJumpLink", :aria-describedby="ariaLabelledby") {{ unavailabilityReasonMessage }}
.pretix-widget-availability-unavailable(v-else-if="unavailabilityReasonMessage")
small {{ unavailabilityReasonMessage }}
.pretix-widget-availability-unavailable(v-else-if="avail[0] < 100 && avail[0] > 10") {{ STRINGS.reserved }}
.pretix-widget-availability-gone(v-else-if="avail[0] <= 10") {{ STRINGS.sold_out }}
.pretix-widget-waiting-list-link(v-if="waitingListShow && !unavailabilityReasonMessage")
a(:href="waitingListUrl", target="_blank", @click="$root.open_link_in_frame") {{ STRINGS.waiting_list }}
.pretix-widget-availability-available(v-if="!unavailabilityReasonMessage && avail[0] === 100")
label.pretix-widget-item-count-single-label.pretix-widget-btn-checkbox(v-if="orderMax === 1")
input(
ref="quantity",
type="checkbox",
value="1",
:name="inputName",
:aria-label="labelSelectItem"
)
span.pretix-widget-icon-cart(aria-hidden="true")
| {{ STRINGS.select }}
.pretix-widget-item-count-group(v-else, role="group", :aria-label="item.name")
button.pretix-widget-btn-default.pretix-widget-item-count-dec(
type="button",
data-step="-1",
:data-controls="`input_${inputName}`",
:aria-label="decLabel",
@click.prevent.stop="onStep"
)
span -
input.pretix-widget-item-count-multiple(
:id="`input_${inputName}`",
ref="quantity",
type="number",
inputmode="numeric",
pattern="\\d*",
placeholder="0",
min="0",
:max="orderMax",
:name="inputName",
:aria-labelledby="ariaLabelledby"
)
button.pretix-widget-btn-default.pretix-widget-item-count-inc(
type="button",
data-step="1",
:data-controls="`input_${inputName}`",
:aria-label="incLabel",
@click.prevent.stop="onStep"
)
span +
</template>
<style lang="sass">
</style>

View File

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

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { Category } from '~/types'
import Item from './Item.vue'
defineProps<{
category: Category
}>()
</script>
<template lang="pug">
.pretix-widget-category(:data-id="category.id")
h3.pretix-widget-category-name(v-if="category.name") {{ category.name }}
.pretix-widget-category-description(v-if="category.description", v-html="category.description")
.pretix-widget-category-items
Item(
v-for="item in category.items",
:key="item.id",
:item="item",
:category="category"
)
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import { padNumber } from '~/utils'
import EventCalendarRow from './EventCalendarRow.vue'
import EventListFilterForm from './EventListFilterForm.vue'
defineProps<{
mobile: boolean
}>()
const store = inject(StoreKey)!
const calendar = ref<HTMLDivElement>()
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && store.parentStack.length > 0))
const monthname = computed(() => {
// TODO proper date formatting?
if (!store.date) return ''
const monthNum = store.date.substr(5, 2)
const year = store.date.substr(0, 4)
return `${STRINGS.months[monthNum]} ${year}`
})
const id = computed(() => `${store.htmlId}-event-calendar-table`)
const ariaLabelledby = computed(() => `${store.htmlId}-event-calendar-table-label`)
function backToList () {
// TODO should be in store
store.weeks = null
store.view = 'events'
store.name = null
store.frontpageText = null
}
function prevmonth () {
if (!store.date) return
let curMonth = parseInt(store.date.substr(5, 2))
let curYear = parseInt(store.date.substr(0, 4))
curMonth--
if (curMonth < 1) {
curMonth = 12
curYear--
}
store.date = `${curYear}-${padNumber(curMonth, 2)}-01`
store.loading++
store.reload({ focus: `#${id.value}` })
}
function nextmonth () {
if (!store.date) return
let curMonth = parseInt(store.date.substr(5, 2))
let curYear = parseInt(store.date.substr(0, 4))
curMonth++
if (curMonth > 12) {
curMonth = 1
curYear++
}
store.date = `${curYear}-${padNumber(curMonth, 2)}-01`
store.loading++
store.reload({ focus: `#${id.value}` })
}
</script>
<template lang="pug">
.pretix-widget-event-calendar(ref="calendar")
//- Back navigation
.pretix-widget-back(v-if="store.events !== null")
a(href="#", role="button", @click.prevent.stop="backToList")
| &lsaquo; {{ STRINGS.back }}
//- Headline
.pretix-widget-event-header(v-if="displayEventInfo")
strong {{ store.name }}
.pretix-widget-event-description(
v-if="displayEventInfo && store.frontpageText",
v-html="store.frontpageText"
)
//- Filter
EventListFilterForm(v-if="!store.disableFilters && store.metaFilterFields.length > 0")
//- Calendar navigation
.pretix-widget-event-calendar-head
a.pretix-widget-event-calendar-previous-month(href="#", @click.prevent.stop="prevmonth")
| &laquo; {{ STRINGS.previous_month }}
|
strong(:id="ariaLabelledby") {{ monthname }}
|
a.pretix-widget-event-calendar-next-month(href="#", @click.prevent.stop="nextmonth")
| {{ STRINGS.next_month }} &raquo;
//- Calendar
table.pretix-widget-event-calendar-table(
:id="id",
tabindex="0",
:aria-labelledby="ariaLabelledby"
)
thead
tr
th(:aria-label="STRINGS.days.MONDAY") {{ STRINGS.days.MO }}
th(:aria-label="STRINGS.days.TUESDAY") {{ STRINGS.days.TU }}
th(:aria-label="STRINGS.days.WEDNESDAY") {{ STRINGS.days.WE }}
th(:aria-label="STRINGS.days.THURSDAY") {{ STRINGS.days.TH }}
th(:aria-label="STRINGS.days.FRIDAY") {{ STRINGS.days.FR }}
th(:aria-label="STRINGS.days.SATURDAY") {{ STRINGS.days.SA }}
th(:aria-label="STRINGS.days.SUNDAY") {{ STRINGS.days.SU }}
tbody
EventCalendarRow(
v-for="(week, idx) in store.weeks",
:key="idx",
:week="week",
:mobile="mobile"
)
</template>
<style lang="sass">
</style>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, inject, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import Category from './Category.vue'
const store = inject(StoreKey)!
const form = ref<HTMLFormElement>()
const voucherinput = ref<HTMLInputElement>()
const isItemsSelected = ref(false)
const localVoucher = ref('')
const idVoucherInput = computed(() => `${store.htmlId}-voucher-input`)
const ariaLabelledby = computed(() => `${store.htmlId}-voucher-headline`)
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && (store.events || store.weeks || store.days)))
const idCartExistsMsg = computed(() => `${store.htmlId}-cart-exists`)
const buyLabel = computed(() => {
let allFree = true
for (const cat of store.categories) {
for (const item of cat.items) {
for (const v of item.variations) {
if (v.price.gross !== '0.00') {
allFree = false
break
}
}
if ((item.variations.length === 0 && item.price.gross !== '0.00') || item.mandatory_priced_addons) {
allFree = false
break
}
}
if (!allFree) break
}
return allFree ? STRINGS.register : STRINGS.buy
})
const hiddenParams = computed(() => {
const params = new URL(store.getVoucherFormTarget()).searchParams
params.delete('iframe')
params.delete('take_cart_id')
return [...params.entries()]
})
const showVoucherForm = computed(() => store.vouchersExist && !store.disableVouchers && !store.voucherCode)
async function backToList () {
store.targetUrl = store.parentStack.pop() || store.targetUrl
store.error = null
if (!store.subevent) {
// reset if we are not in a series
store.name = null
store.frontpageText = null
}
store.subevent = null
store.offset = 0
store.appendEvents = false
store.triggerLoadCallback()
if (store.events !== undefined && store.events !== null) {
store.view = 'events'
} else if (store.days !== undefined && store.days !== null) {
store.view = 'days'
} else {
store.view = 'weeks'
}
// wait for redraw, then focus content element for better a11y
const rootEl = form.value?.closest('.pretix-widget-wrapper') as HTMLElement | null
await nextTick()
rootEl?.focus()
}
function calcItemsSelected () {
if (!form.value) return
const checkboxes = form.value.querySelectorAll<HTMLInputElement>('input[type=checkbox], input[type=radio]')
const hasChecked = Array.from(checkboxes).some((el) => el.checked)
const numberInputs = form.value.querySelectorAll<HTMLInputElement>('.pretix-widget-item-count-group input')
const hasQuantity = Array.from(numberInputs).some((el) => parseInt(el.value || '0') > 0)
isItemsSelected.value = hasChecked || hasQuantity
}
function _focusVoucherField () {
voucherinput.value?.focus()
}
function handleBuy (event: Event) {
if (form.value) {
const formData = new FormData(form.value)
store.buy(formData, event)
}
}
function handleRedeem (event: Event) {
store.redeem(localVoucher.value, event)
}
onMounted(() => {
if (form.value) {
form.value.addEventListener('change', calcItemsSelected)
}
})
onBeforeUnmount(() => {
if (form.value) {
form.value.removeEventListener('change', calcItemsSelected)
}
})
watch(() => store.overlay?.frameShown, (newValue) => {
if (!newValue && form.value) {
form.value.reset()
calcItemsSelected()
}
})
</script>
<template lang="pug">
.pretix-widget-event-form
//- Back navigation
.pretix-widget-event-list-back(v-if="store.events || store.weeks || store.days")
a(v-if="!store.subevent", href="#", rel="back", @click.prevent.stop="backToList")
| &lsaquo; {{ STRINGS.back_to_list }}
a(v-if="store.subevent", href="#", rel="back", @click.prevent.stop="backToList")
| &lsaquo; {{ STRINGS.back_to_dates }}
//- Event name
.pretix-widget-event-header(v-if="displayEventInfo")
strong(role="heading", aria-level="2") {{ store.name }}
//- Date range
.pretix-widget-event-details(v-if="displayEventInfo && store.dateRange") {{ store.dateRange }}
//- Location
.pretix-widget-event-location(
v-if="displayEventInfo && store.location",
v-html="store.location"
)
//- Description
.pretix-widget-event-description(
v-if="displayEventInfo && store.frontpageText",
v-html="store.frontpageText"
)
//- Form start
form(
ref="form",
method="post",
:action="store.formAction",
:target="store.formTarget",
@submit="handleBuy"
)
input(v-if="store.voucherCode", type="hidden", name="_voucher_code", :value="store.voucherCode")
input(type="hidden", name="subevent", :value="store.subevent")
input(type="hidden", name="widget_data", :value="store.widgetDataJson")
input(v-if="store.consentParameterValue", type="hidden", name="consent", :value="store.consentParameterValue")
//- Error message
.pretix-widget-error-message(v-if="store.error") {{ store.error }}
//- Resume cart
.pretix-widget-info-message.pretix-widget-clickable(v-if="store.cartExists")
span(:id="idCartExistsMsg") {{ STRINGS.cart_exists }}
button.pretix-widget-resume-button(
type="button",
:aria-describedby="idCartExistsMsg",
@click.prevent.stop="store.resume()"
) {{ STRINGS.resume_checkout }}
//- Seating plan
.pretix-widget-seating-link-wrapper(v-if="store.hasSeatingPlan")
button.pretix-widget-seating-link(type="button", @click.prevent.stop="store.startseating()")
| {{ STRINGS.show_seating }}
//- Waiting list for seating plan
.pretix-widget-seating-waitinglist(v-if="store.hasSeatingPlan && store.hasSeatingPlanWaitinglist")
.pretix-widget-seating-waitinglist-text {{ STRINGS.seating_plan_waiting_list }}
.pretix-widget-seating-waitinglist-button-wrap
button.pretix-widget-seating-waitinglist-button(@click.prevent.stop="store.startwaiting()")
| {{ STRINGS.waiting_list }}
.pretix-widget-clear
//- Actual Product list
Category(v-for="category in store.categories", :key="category.id", :category="category")
//- Buy button
.pretix-widget-action(v-if="store.displayAddToCart")
button(
v-if="!store.cartExists || isItemsSelected",
type="submit",
:aria-describedby="idCartExistsMsg"
) {{ buyLabel }}
button(
v-else,
type="button",
:aria-describedby="idCartExistsMsg",
@click.prevent.stop="store.resume()"
) {{ STRINGS.resume_checkout }}
//- Voucher form
form(
v-if="showVoucherForm",
method="get",
:action="store.getVoucherFormTarget()",
target="_blank"
)
.pretix-widget-voucher
h3.pretix-widget-voucher-headline(:id="ariaLabelledby") {{ STRINGS.redeem_voucher }}
.pretix-widget-voucher-text(
v-if="store.voucherExplanationText",
v-html="store.voucherExplanationText"
)
.pretix-widget-voucher-input-wrap
input.pretix-widget-voucher-input(
:id="idVoucherInput",
ref="voucherinput",
v-model="localVoucher",
type="text",
name="voucher",
:placeholder="STRINGS.voucher_code",
:aria-labelledby="ariaLabelledby"
)
input(
v-for="p in hiddenParams",
:key="p[0]",
type="hidden",
:name="p[0]",
:value="p[1]"
)
.pretix-widget-voucher-button-wrap
button(@click="handleRedeem") {{ STRINGS.redeem }}
.pretix-widget-clear
</template>
<style lang="sass">
</style>

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { MetaFilterField } from '~/types'
import { StoreKey, globalWidgetId } from '~/sharedStore'
const props = defineProps<{
field: MetaFilterField
}>()
const store = inject(StoreKey)!
const id = computed(() => `${globalWidgetId}_${props.field.key}`)
const currentValue = computed(() => {
const filterParams = new URLSearchParams(store.filter || '')
return filterParams.get(props.field.key) || ''
})
</script>
<template lang="pug">
.pretix-widget-event-list-filter-field
label(:for="id") {{ field.label }}
select(:id="id", :name="field.key", :value="currentValue")
option(v-for="choice in field.choices", :key="choice[0]", :value="choice[0]") {{ choice[1] }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import EventListFilterField from './EventListFilterField.vue'
const store = inject(StoreKey)!
const filterform = ref<HTMLFormElement>()
function onSubmit (e: Event) {
e.preventDefault()
if (!filterform.value) return
const formData = new FormData(filterform.value)
const filterParams = new URLSearchParams()
formData.forEach((value, key) => {
if (value !== '') {
filterParams.set(key, value as string)
}
})
store.filter = filterParams.toString()
store.loading++
store.reload()
}
</script>
<template lang="pug">
form.pretix-widget-event-list-filter-form(ref="filterform", @submit="onSubmit")
fieldset.pretix-widget-event-list-filter-fieldset
legend {{ STRINGS.filter_events_by }}
EventListFilterField(
v-for="field in store.metaFilterFields",
:key="field.key",
:field="field"
)
button {{ STRINGS.filter }}
</template>
<style lang="sass">
</style>

View File

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

View File

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

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, inject, ref, watch, onMounted } from 'vue'
import type { Item, Category } from '~/types'
import { StoreKey } from '~/sharedStore'
import { STRINGS, interpolate } from '~/i18n'
import { floatformat } from '~/utils'
import AvailBox from './AvailBox.vue'
import PriceBox from './PriceBox.vue'
import Variation from './Variation.vue'
const props = defineProps<{
item: Item
category: Category
}>()
const store = inject(StoreKey)!
const expanded = ref(store.showVariationsExpanded)
const variations = ref<HTMLDivElement>()
const classObject = computed(() => ({
'pretix-widget-item': true,
'pretix-widget-item-with-picture': !!props.item.picture,
'pretix-widget-item-with-variations': props.item.has_variations,
}))
const varClasses = computed(() => ({
'pretix-widget-item-variations': true,
'pretix-widget-item-variations-expanded': expanded.value,
}))
const pictureAltText = computed(() => interpolate(STRINGS.image_of, [props.item.name]))
const headingLevel = computed(() => props.category.name ? '4' : '3')
const itemLabelId = computed(() => `${store.htmlId}-item-label-${props.item.id}`)
const itemDescId = computed(() => `${store.htmlId}-item-desc-${props.item.id}`)
const itemPriceId = computed(() => `${store.htmlId}-item-price-${props.item.id}`)
const ariaLabelledby = computed(() => `${itemLabelId.value} ${itemPriceId.value}`)
const minOrderStr = computed(() => interpolate(STRINGS.order_min, [props.item.order_min]))
const quotaLeftStr = computed(() => interpolate(STRINGS.quota_left, [props.item.avail[1]]))
const showToggle = computed(() => props.item.has_variations && !store.showVariationsExpanded)
// TODO dedupe?
const showPrices = computed(() => {
let hasPriced = false
let cntItems = 0
for (const cat of store.categories) {
for (const item of cat.items) {
if (item.has_variations) {
cntItems += item.variations.length
hasPriced = true
} else {
cntItems++
hasPriced = hasPriced || item.price.gross !== '0.00' || item.free_price
}
}
}
return hasPriced || cntItems > 1
})
// TODO XSS
const pricerange = computed(() => {
if (props.item.free_price) {
return interpolate(
STRINGS.price_from,
{
currency: store.currency,
price: floatformat(props.item.min_price || '0', 2),
},
true
).replace(
store.currency,
`<span class="pretix-widget-pricebox-currency">${store.currency}</span>`
)
} else if (props.item.min_price !== props.item.max_price) {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', 2)} ${floatformat(props.item.max_price || '0', 2)}`
} else if (props.item.min_price === '0.00' && props.item.max_price === '0.00') {
if (props.item.mandatory_priced_addons) {
return '\xA0' // nbsp, because an empty string would cause the HTML element to collapse
}
return STRINGS.free
} else {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', 2)}`
}
})
const variationsToggleLabel = computed(() => expanded.value ? STRINGS.hide_variations : STRINGS.variations)
function expand () {
expanded.value = !expanded.value
}
function lightbox () {
if (store.overlay) {
store.overlay.lightbox = {
image: props.item.picture_fullsize || '',
description: props.item.name,
loading: true, // TODO why?
}
}
}
onMounted(() => {
if (variations.value && !expanded.value) {
variations.value.hidden = true
variations.value.addEventListener('transitionend', function (event) {
if (event.target === variations.value) {
if (variations.value) {
variations.value.hidden = !expanded.value
variations.value.style.maxHeight = 'none'
}
}
})
}
})
watch(expanded, (newValue) => {
const v = variations.value
if (!v) return
v.hidden = false
v.style.maxHeight = `${newValue ? 0 : v.scrollHeight}px`
// Vue.nextTick does not work here
setTimeout(() => {
v.style.maxHeight = `${!newValue ? 0 : v.scrollHeight}px`
}, 50)
})
</script>
<template lang="pug">
div(
:class="classObject",
:data-id="item.id",
role="group",
:aria-labelledby="ariaLabelledby",
:aria-describedby="itemDescId"
)
.pretix-widget-item-row.pretix-widget-main-item-row
//- Product description
.pretix-widget-item-info-col
a.pretix-widget-item-picture-link(
v-if="item.picture",
:href="item.picture_fullsize",
@click.prevent.stop="lightbox"
)
img.pretix-widget-item-picture(:src="item.picture", :alt="pictureAltText")
.pretix-widget-item-title-and-description
strong.pretix-widget-item-title(
:id="itemLabelId",
role="heading",
:aria-level="headingLevel"
) {{ item.name }}
.pretix-widget-item-description(
v-if="item.description",
:id="itemDescId",
v-html="item.description"
)
p.pretix-widget-item-meta(v-if="item.order_min && item.order_min > 1")
small {{ minOrderStr }}
p.pretix-widget-item-meta(
v-if="!item.has_variations && item.avail[1] !== null && item.avail[0] === 100"
)
small {{ quotaLeftStr }}
//- Price
.pretix-widget-item-price-col(:id="itemPriceId")
PriceBox(
v-if="!item.has_variations && showPrices",
:price="item.price",
:freePrice="item.free_price",
:mandatoryPricedAddons="item.mandatory_priced_addons",
:suggestedPrice="item.suggested_price",
:fieldName="`price_${item.id}`",
:originalPrice="item.original_price",
:itemId="item.id"
)
.pretix-widget-pricebox(v-if="item.has_variations && showPrices", v-html="pricerange")
span(v-if="!showPrices") &nbsp;
//- Availability
.pretix-widget-item-availability-col
button.pretix-widget-collapse-indicator(
v-if="showToggle",
type="button",
:aria-expanded="expanded ? 'true' : 'false'",
:aria-controls="`${item.id}-variants`",
:aria-describedby="itemDescId",
@click.prevent.stop="expand"
) {{ variationsToggleLabel }}
AvailBox(v-if="!item.has_variations", :item="item")
.pretix-widget-clear
//- Variations
div(
v-if="item.has_variations",
:id="`${item.id}-variants`",
ref="variations",
:class="varClasses"
)
Variation(
v-for="variation in item.variations",
:key="variation.id",
:variation="variation",
:item="item",
:category="category"
)
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, inject, nextTick } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
const store = inject(StoreKey)!
const cancelBlocked = ref(false)
const lightboxImage = ref<HTMLImageElement>()
const frameDialog = ref<HTMLDialogElement>()
const alertDialog = ref<HTMLDialogElement>()
const lightboxDialog = ref<HTMLDialogElement>()
const iframe = ref<HTMLIFrameElement>()
const closeButton = ref<HTMLButtonElement>()
const frameClasses = computed(() => ({
'pretix-widget-frame-holder': true,
'pretix-widget-frame-shown': store.overlay.frameShown || store.overlay.frameLoading,
'pretix-widget-frame-isloading': store.overlay.frameLoading,
}))
const alertClasses = computed(() => ({
'pretix-widget-alert-holder': true,
'pretix-widget-alert-shown': store.overlay.errorMessage,
}))
const lightboxClasses = computed(() => ({
'pretix-widget-lightbox-holder': true,
'pretix-widget-lightbox-shown': store.overlay.lightbox,
'pretix-widget-lightbox-isloading': store.overlay.lightbox?.loading,
}))
const cancelBlockedClasses = computed(() => ({
'pretix-widget-visibility-hidden': !cancelBlocked.value,
}))
const errorMessageId = computed(() => `${store.htmlId}-error-message`)
function onMessage (e: MessageEvent) {
if (e.data.type && e.data.type === 'pretix:widget:title') {
if (iframe.value) {
iframe.value.title = e.data.title
}
}
}
function lightboxClose () {
store.overlay.lightbox = null
}
function lightboxLoaded () {
if (store.overlay.lightbox) {
store.overlay.lightbox.loading = false
}
}
function errorClose (e: Event) {
const dialog = e.target as HTMLDialogElement
if (dialog.returnValue === 'continue' && store.overlay.errorUrlAfter) {
if (store.overlay.errorUrlAfterNewTab) {
window.open(store.overlay.errorUrlAfter)
} else {
store.overlay.frameSrc = store.overlay.errorUrlAfter
store.overlay.frameLoading = true
}
}
store.overlay.errorMessage = null
store.overlay.errorUrlAfter = null
store.overlay.errorUrlAfterNewTab = false
}
function close (e) {
if (store.overlay.frameLoading) {
// Chrome does not allow blocking dialog.cancel event more than once
// => wiggle the loading-element and re-open the modal
cancel(e)
frameDialog.value?.showModal()
return
}
store.overlay.frameShown = false
store.frameDismissed = true
store.overlay.frameSrc = ''
store.reload()
triggerCloseCallback()
}
function cancel (e: Event) {
// do not allow to cancel while frame is loading as we cannot abort the operation
if (store.overlay.frameLoading) {
e.preventDefault()
const target = e.target as HTMLElement
target.addEventListener('animationend', function () {
target.classList.remove('pretix-widget-shake-once')
}, { once: true })
target.classList.add('pretix-widget-shake-once')
cancelBlocked.value = true
}
}
function iframeLoaded () {
if (store.overlay.frameLoading) {
store.overlay.frameLoading = false
cancelBlocked.value = false
if (store.overlay.frameSrc) {
store.overlay.frameShown = true
}
}
}
function triggerCloseCallback () {
nextTick(() => {
for (const callback of (window as any).PretixWidget._closed || []) {
callback()
}
})
}
// TODO check if watchEffect is better for the following watchers
watch(() => store.overlay.lightbox, (newValue, oldValue) => {
if (!newValue) return
if (newValue.image !== oldValue?.image) {
newValue.loading = true
}
if (!oldValue) {
lightboxDialog.value?.showModal()
}
})
watch(() => store.overlay.errorMessage, (newValue, oldValue) => {
if (newValue && !oldValue) {
alertDialog.value?.showModal()
}
})
watch(() => store.overlay.frameShown, async (newValue) => {
if (!newValue) return
await nextTick()
closeButton.value?.focus()
})
watch(() => store.overlay.frameSrc, (newValue, oldValue) => {
// show loading spinner only when previously no frame_src was set
if (newValue && !oldValue) {
store.overlay.frameLoading = true
}
if (iframe.value) {
// to close and unload the iframe, frame_src can be empty -> make it valid HTML with about:blank
iframe.value.src = newValue || 'about:blank'
}
})
watch(() => store.overlay.frameLoading, (newValue) => {
if (newValue) {
if (frameDialog.value && !frameDialog.value.open) {
frameDialog.value.showModal()
}
} else {
if (!store.overlay.frameSrc && frameDialog.value?.open) { // finished loading, but no iframe to display => close
frameDialog.value.close()
}
}
})
onMounted(() => {
window.addEventListener('message', onMessage, false)
})
onUnmounted(() => {
window.removeEventListener('message', onMessage, false)
})
</script>
<template lang="pug">
Teleport(to="body")
.pretix-widget-overlay
dialog(ref="frameDialog", :class="frameClasses", :aria-label="STRINGS.checkout", @close="close", @cancel="cancel")
.pretix-widget-frame-loading(v-show="store.overlay.frameLoading")
svg(width="256", height="256", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
path.pretix-widget-primary-color(d="M1152 896q0-106-75-181t-181-75-181 75-75 181 75 181 181 75 181-75 75-181zm512-109v222q0 12-8 23t-20 13l-185 28q-19 54-39 91 35 50 107 138 10 12 10 25t-9 23q-27 37-99 108t-94 71q-12 0-26-9l-138-108q-44 23-91 38-16 136-29 186-7 28-36 28h-222q-14 0-24.5-8.5t-11.5-21.5l-28-184q-49-16-90-37l-141 107q-10 9-25 9-14 0-25-11-126-114-165-168-7-10-7-23 0-12 8-23 15-21 51-66.5t54-70.5q-27-50-41-99l-183-27q-13-2-21-12.5t-8-23.5v-222q0-12 8-23t19-13l186-28q14-46 39-92-40-57-107-138-10-12-10-24 0-10 9-23 26-36 98.5-107.5t94.5-71.5q13 0 26 10l138 107q44-23 91-38 16-136 29-186 7-28 36-28h222q14 0 24.5 8.5t11.5 21.5l28 184q49 16 90 37l142-107q9-9 24-9 13 0 25 10 129 119 165 170 7 8 7 22 0 12-8 23-15 21-51 66.5t-54 70.5q26 50 41 98l183 28q13 2 21 12.5t8 23.5z")
p(:class="cancelBlockedClasses")
strong {{ STRINGS.cancel_blocked }}
.pretix-widget-frame-inner(v-show="store.overlay.frameShown")
form.pretix-widget-frame-close(method="dialog")
button(ref="closeButton", :aria-label="STRINGS.close_checkout", autofocus)
svg(:alt="STRINGS.close", height="16", viewBox="0 0 512 512", width="16", xmlns="http://www.w3.org/2000/svg")
path(fill="#fff", d="M437.5,386.6L306.9,256l130.6-130.6c14.1-14.1,14.1-36.8,0-50.9c-14.1-14.1-36.8-14.1-50.9,0L256,205.1L125.4,74.5 c-14.1-14.1-36.8-14.1-50.9,0c-14.1,14.1-14.1,36.8,0,50.9L205.1,256L74.5,386.6c-14.1,14.1-14.1,36.8,0,50.9 c14.1,14.1,36.8,14.1,50.9,0L256,306.9l130.6,130.6c14.1,14.1,36.8,14.1,50.9,0C451.5,423.4,451.5,400.6,437.5,386.6z")
iframe(
ref="iframe",
frameborder="0",
width="650",
height="650",
:name="store.widgetId",
src="about:blank",
allow="autoplay *; camera *; fullscreen *; payment *",
:title="STRINGS.checkout",
referrerpolicy="origin",
@load="iframeLoaded"
) Please enable frames in your browser!
dialog(ref="alertDialog", :class="alertClasses", role="alertdialog", :aria-labelledby="errorMessageId", @close="errorClose")
form.pretix-widget-alert-box(method="dialog")
p(:id="errorMessageId") {{ store.overlay.errorMessage }}
p
button(v-if="store.overlay.errorUrlAfter", value="continue", autofocus, :aria-describedby="errorMessageId")
| {{ STRINGS.continue }}
button(v-else, autofocus, :aria-describedby="errorMessageId") {{ STRINGS.close }}
transition(name="bounce")
svg.pretix-widget-alert-icon(v-if="store.overlay.errorMessage", width="64", height="64", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
path(style="fill:#ffffff;", d="M 599.86438,303.72882 H 1203.5254 V 1503.4576 H 599.86438 Z")
path.pretix-widget-primary-color(d="M896 128q209 0 385.5 103t279.5 279.5 103 385.5-103 385.5-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103zm128 1247v-190q0-14-9-23.5t-22-9.5h-192q-13 0-23 10t-10 23v190q0 13 10 23t23 10h192q13 0 22-9.5t9-23.5zm-2-344l18-621q0-12-10-18-10-8-24-8h-220q-14 0-24 8-10 6-10 18l17 621q0 10 10 17.5t24 7.5h185q14 0 23.5-7.5t10.5-17.5z")
dialog(ref="lightboxDialog", :class="lightboxClasses", role="alertdialog", @close="lightboxClose")
.pretix-widget-lightbox-loading(v-if="store.overlay.lightbox?.loading")
svg(width="256", height="256", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
path.pretix-widget-primary-color(d="M1152 896q0-106-75-181t-181-75-181 75-75 181 75 181 181 75 181-75 75-181zm512-109v222q0 12-8 23t-20 13l-185 28q-19 54-39 91 35 50 107 138 10 12 10 25t-9 23q-27 37-99 108t-94 71q-12 0-26-9l-138-108q-44 23-91 38-16 136-29 186-7 28-36 28h-222q-14 0-24.5-8.5t-11.5-21.5l-28-184q-49-16-90-37l-141 107q-10 9-25 9-14 0-25-11-126-114-165-168-7-10-7-23 0-12 8-23 15-21 51-66.5t54-70.5q-27-50-41-99l-183-27q-13-2-21-12.5t-8-23.5v-222q0-12 8-23t19-13l186-28q14-46 39-92-40-57-107-138-10-12-10-24 0-10 9-23 26-36 98.5-107.5t94.5-71.5q13 0 26 10l138 107q44-23 91-38 16-136 29-186 7-28 36-28h222q14 0 24.5 8.5t11.5 21.5l28 184q49 16 90 37l142-107q9-9 24-9 13 0 25 10 129 119 165 170 7 8 7 22 0 12-8 23-15 21-51 66.5t-54 70.5q26 50 41 98l183 28q13 2 21 12.5t8 23.5z")
.pretix-widget-lightbox-inner(v-if="store.overlay.lightbox")
form.pretix-widget-lightbox-close(method="dialog")
button(:aria-label="STRINGS.close", autofocus)
svg(:alt="STRINGS.close", height="16", viewBox="0 0 512 512", width="16", xmlns="http://www.w3.org/2000/svg")
path(fill="#fff", d="M437.5,386.6L306.9,256l130.6-130.6c14.1-14.1,14.1-36.8,0-50.9c-14.1-14.1-36.8-14.1-50.9,0L256,205.1L125.4,74.5 c-14.1-14.1-36.8-14.1-50.9,0c-14.1,14.1-14.1,36.8,0,50.9L205.1,256L74.5,386.6c-14.1,14.1-14.1,36.8,0,50.9 c14.1,14.1,36.8,14.1,50.9,0L256,306.9l130.6,130.6c14.1,14.1,36.8,14.1,50.9,0C451.5,423.4,451.5,400.6,437.5,386.6z")
figure.pretix-widget-lightbox-image
img(
ref="lightboxImage",
:src="store.overlay.lightbox.image",
:alt="store.overlay.lightbox.description",
crossorigin,
@load="lightboxLoaded"
)
figcaption(v-if="store.overlay.lightbox.description") {{ store.overlay.lightbox.description }}
</template>
<style lang="sass">
</style>

Some files were not shown because too many files have changed in this diff Show More