Compare commits

...
Author SHA1 Message Date
Kara Engelhardt 9520c411db Format js code with prettier 2026-11-13 16:41:15 +01:00
Kara Engelhardt 749211c206 Add prettier config 2026-11-13 16:41:15 +01:00
Raphael Michel f346acb0d2 API: Only require transmission fields depending on method logic (#6765)
* API: Do not mark all invoice transmission fields as required

* Fix test
2026-11-13 14:34:41 +01:00
Phin Wolkwitz 0478559fd7 Accounting report: Add annotation and full daterange… (Z#23245563) (#6742)
Add an annotation and full daterange in accounting reports if "all time" is selected
2026-11-13 12:33:22 +01:00
Raphael Michel 137059dd4e Customer account login: Add rate limit (Z#23252382) (#6760)
* Customer account login: Add rate limit

* Apply suggestion from @raphaelm
2026-11-11 10:29:51 +01:00
Raphael Michel bd0b55bdd3 Reusable media API: Limit data exposed on cross-organizer requests (Z#23253153) (#6761) 2026-11-11 10:29:02 +01:00
64 changed files with 3593 additions and 2887 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
/*
+2
View File
@@ -5,6 +5,7 @@ import ts from 'typescript-eslint'
import stylistic from '@stylistic/eslint-plugin'
import vue from 'eslint-plugin-vue'
import vuePug from 'eslint-plugin-vue-pug'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
const ignores = globalIgnores([
'**/node_modules',
@@ -161,4 +162,5 @@ export default defineConfig([
},
},
},
eslintPluginPrettierRecommended,
])
+127
View File
@@ -21,9 +21,12 @@
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/language-plugin-pug": "^3.2.5",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.7",
"eslint-plugin-vue": "^10.8.0",
"eslint-plugin-vue-pug": "^1.0.0-alpha.5",
"globals": "^17.4.0",
"prettier": "3.10.1",
"pug": "^3.0.3",
"sass-embedded": "^1.98.0",
"smol-toml": "^1.6.1",
@@ -92,6 +95,17 @@
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@dual-bundle/import-meta-resolve": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/JounQin"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -718,6 +732,19 @@
"node": ">=14"
}
},
"node_modules/@pkgr/core": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz",
"integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
@@ -1966,6 +1993,53 @@
}
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"funding": {
"url": "https://opencollective.com/eslint-config-prettier"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz",
"integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.1",
"synckit": "^0.12.3"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-prettier"
},
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
"@types/eslint": {
"optional": true
},
"eslint-config-prettier": {
"optional": true
}
}
},
"node_modules/eslint-plugin-vue": {
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz",
@@ -2190,6 +2264,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -3361,6 +3442,35 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.10.1.tgz",
"integrity": "sha512-70vp3ZUYoPlFbF9uiTdXHUUCGEIp7d4+6p+8DYbkm2jP7x/3i/paXVF6dopSI3zd/CEitrZN1rc4i85xWmmXlg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-linter-helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-diff": "^1.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
@@ -4300,6 +4410,23 @@
"node": ">=16.0.0"
}
},
"node_modules/synckit": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz",
"integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dual-bundle/import-meta-resolve": "^4.2.1",
"@pkgr/core": "^0.3.6"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+3
View File
@@ -39,9 +39,12 @@
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/language-plugin-pug": "^3.2.5",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.7",
"eslint-plugin-vue": "^10.8.0",
"eslint-plugin-vue-pug": "^1.0.0-alpha.5",
"globals": "^17.4.0",
"prettier": "3.10.1",
"pug": "^3.0.3",
"sass-embedded": "^1.98.0",
"smol-toml": "^1.6.1",
+2
View File
@@ -197,6 +197,8 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
'linked_giftcard',
'info',
'notes',
# When adding fields, check if they should be hidden in ReusableMediaViewSet
# for cross-organizer lookups
)
+1
View File
@@ -117,6 +117,7 @@ class TransmissionInfoSerializer(serializers.Serializer):
super().__init__(*args, **kwargs)
for k, v in transmission_type.invoice_address_form_fields.items():
self.fields[k] = form_field_to_serializer_field(v)
self.fields[k].required = False # invoice_address_form_fields_required is checked by parent serializer
class InvoiceAddressSerializer(I18nAwareModelSerializer):
+11 -2
View File
@@ -183,9 +183,18 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
type=s.validated_data["type"],
identifier=s.validated_data["identifier"],
)
m.customer = None # not relevant for cross-organizer
s = self.get_serializer(m)
return Response({"result": s.data})
data = s.data
# Remove fields that are not relevant for cross-organizer usage
data["customer"] = None
data["linked_orderposition"] = None
data["linked_orderpositions"] = []
data["info"] = {}
data["notes"] = None
data["claim_token"] = None
data["label"] = None
return Response({"result": data})
except ReusableMedium.DoesNotExist:
mt = MEDIA_TYPES.get(s.validated_data["type"])
if mt:
+28 -17
View File
@@ -27,6 +27,7 @@ from decimal import Decimal
from django import forms
from django.db.models import F, Sum
from django.db.models.aggregates import Min
from django.db.models.functions import Coalesce
from django.utils.formats import date_format
from django.utils.html import escape
@@ -41,7 +42,8 @@ from reportlab.platypus import (
from pretix.base.exporter import BaseExporter
from pretix.base.models import (
GiftCardTransaction, OrderFee, OrderPayment, OrderRefund, Transaction,
GiftCardTransaction, Order, OrderFee, OrderPayment, OrderRefund,
Transaction,
)
from pretix.base.templatetags.money import money_filter, tax_rate_format
from pretix.base.timeframes import (
@@ -116,25 +118,34 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
dt_start, df_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(
now(), form_data["date_range"], self.timezone
)
if dt_start:
filters.append(
_("Begin")
+ ": "
+ date_format(
else:
dt_start = Order.objects.filter(
event__in=self.events,
).aggregate(min_datetime=Min('datetime'))['min_datetime']
df_end = now()
if dt_start:
filters.append(
"{label}: {value}{hint}".format(
label=_("Begin"),
value=date_format(
dt_start.astimezone(self.timezone), "SHORT_DATETIME_FORMAT"
)
),
hint="" if form_data["date_range"] else " ({})".format(_("first order received"))
)
if df_end:
filters.append(
_("End")
+ ": "
+ date_format(
(df_end - datetime.timedelta.resolution).astimezone(
self.timezone
),
"SHORT_DATETIME_FORMAT",
)
)
if df_end:
filters.append(
_("End")
+ ": "
+ date_format(
(df_end - datetime.timedelta.resolution).astimezone(
self.timezone
),
"SHORT_DATETIME_FORMAT",
)
)
if not form_data["no_testmode"]:
filters.append(_("Report includes test orders which may be deleted later!"))
@@ -1,271 +1,298 @@
import type { I18nString, SubEvent } from './i18n'
import type { I18nString, SubEvent } from "./i18n";
const settingsEl = document.getElementById('api-settings')
const { urls } = JSON.parse(settingsEl.textContent || '{}') as { urls: {
lists: string
questions: string
} }
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[]
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
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
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
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>
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[]
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[]
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[]
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
id: number;
identifier: string;
position: number;
answer: I18nString;
}
export interface Addon {
item: { name: I18nString; internal_name?: string }
variation: { value: I18nString } | null
item: { name: I18nString; internal_name?: string };
variation: { value: I18nString } | null;
}
export interface CheckinStatusVariation {
id: number
value: string
checkin_count: number
position_count: number
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[]
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[]
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
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
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
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 ?? ''
const CSRF_TOKEN =
document.querySelector<HTMLInputElement>("input[name=csrfmiddlewaretoken]")
?.value ?? "";
function handleAuthError (response: Response): void {
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
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)
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)
throw new Error("HTTP status " + response.status);
}
return response.json()
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 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 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 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 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 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 (
async redeemPosition(
listId: number,
positionId: string,
data: RedeemRequest,
untrusted: boolean = false
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'
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',
method: "POST",
headers: {
'X-CSRFToken': CSRF_TOKEN,
'Content-Type': 'application/json',
"X-CSRFToken": CSRF_TOKEN,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
});
handleAuthError(response)
handleAuthError(response);
if (response.status === 404) {
return { status: 'error', reason: 'invalid' }
return { status: "error", reason: "invalid" };
}
if (!response.ok && response.status !== 400) {
throw new Error('HTTP status ' + response.status)
throw new Error("HTTP status " + response.status);
}
return response.json()
}
}
return response.json();
},
};
@@ -1,292 +1,316 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { api } from '../api'
import type { CheckinList, Position, RedeemResponse, CheckinStatus, AnswerQuestion } from '../api'
import { STRINGS, i18nstringLocalize, formatSubevent, formatAnswer } from '../i18n'
import { ref, computed, nextTick, onMounted, onUnmounted } from "vue";
import { api } from "../api";
import type {
CheckinList,
Position,
RedeemResponse,
CheckinStatus,
AnswerQuestion,
} from "../api";
import {
STRINGS,
i18nstringLocalize,
formatSubevent,
formatAnswer,
} from "../i18n";
import CheckinlistSelect from './checkinlist-select.vue'
import SearchresultItem from './searchresult-item.vue'
import Datefield from './datefield.vue'
import Timefield from './timefield.vue'
import Datetimefield from './datetimefield.vue'
import CheckinlistSelect from "./checkinlist-select.vue";
import SearchresultItem from "./searchresult-item.vue";
import Datefield from "./datefield.vue";
import Timefield from "./timefield.vue";
import Datetimefield from "./datetimefield.vue";
const props = defineProps<{
eventName?: string
}>()
eventName?: string;
}>();
const COUNTRIES = JSON.parse(document.querySelector('#countries')?.textContent ?? '[]')
const COUNTRIES = JSON.parse(
document.querySelector("#countries")?.textContent ?? "[]",
);
let clearTimeoutId: ReturnType<typeof setTimeout> | null = null
const answers = ref<Record<string, string>>({})
let clearTimeoutId: ReturnType<typeof setTimeout> | null = null;
const answers = ref<Record<string, string>>({});
// Checkin list selection
const checkinlist = ref<CheckinList | null>(null)
const subevent = computed(() => formatSubevent(checkinlist.value?.subevent))
const checkinlist = ref<CheckinList | null>(null);
const subevent = computed(() => formatSubevent(checkinlist.value?.subevent));
function selectList (list: CheckinList) {
checkinlist.value = list
location.hash = '#' + list.id
refocus()
fetchStatus()
function selectList(list: CheckinList) {
checkinlist.value = list;
location.hash = "#" + list.id;
refocus();
fetchStatus();
}
function switchList () {
location.hash = ''
checkinlist.value = null
function switchList() {
location.hash = "";
checkinlist.value = null;
}
// Entry/Exit type
const type = ref<'entry' | 'exit'>('entry')
const type = ref<"entry" | "exit">("entry");
function switchType () {
type.value = type.value === 'exit' ? 'entry' : 'exit'
refocus()
function switchType() {
type.value = type.value === "exit" ? "entry" : "exit";
refocus();
}
// Input/query
const inputEl = ref<HTMLInputElement>()
const query = ref('')
const inputEl = ref<HTMLInputElement>();
const query = ref("");
function refocus () {
function refocus() {
nextTick(() => {
inputEl.value?.focus()
})
inputEl.value?.focus();
});
}
function inputKeyup (e: KeyboardEvent) {
if (e.key === 'Enter') {
startSearch(true)
} else if (query.value === '') {
clear()
function inputKeyup(e: KeyboardEvent) {
if (e.key === "Enter") {
startSearch(true);
} else if (query.value === "") {
clear();
}
}
function globalKeydown (e: KeyboardEvent | Event) {
function globalKeydown(e: KeyboardEvent | Event) {
if (!(e instanceof KeyboardEvent)) {
refocus()
return
refocus();
return;
}
if (document.activeElement?.classList.contains('searchresult')) {
if (e.key === 'ArrowDown') {
(document.activeElement.nextElementSibling as HTMLElement)?.focus()
e.preventDefault()
return
if (document.activeElement?.classList.contains("searchresult")) {
if (e.key === "ArrowDown") {
(document.activeElement.nextElementSibling as HTMLElement)?.focus();
e.preventDefault();
return;
}
if (e.key === 'ArrowUp') {
(document.activeElement.previousElementSibling as HTMLElement)?.focus()
e.preventDefault()
return
if (e.key === "ArrowUp") {
(document.activeElement.previousElementSibling as HTMLElement)?.focus();
e.preventDefault();
return;
}
}
const nodeName = document.activeElement?.nodeName?.toLowerCase()
if (nodeName !== 'input' && nodeName !== 'textarea') {
const nodeName = document.activeElement?.nodeName?.toLowerCase();
if (nodeName !== "input" && nodeName !== "textarea") {
if (e.key?.match(/^[a-z0-9A-Z+/=<>#]$/)) {
query.value = ''
refocus()
query.value = "";
refocus();
}
}
}
// Status
const status = ref<CheckinStatus | null>(null)
const statusLoading = ref(0)
let statusInterval: ReturnType<typeof setInterval> | null = null
const status = ref<CheckinStatus | null>(null);
const statusLoading = ref(0);
let statusInterval: ReturnType<typeof setInterval> | null = null;
async function fetchStatus () {
if (!checkinlist.value) return
async function fetchStatus() {
if (!checkinlist.value) return;
statusLoading.value++
statusLoading.value++;
try {
status.value = await api.fetchStatus(checkinlist.value.id)
status.value = await api.fetchStatus(checkinlist.value.id);
} finally {
statusLoading.value--
statusLoading.value--;
}
}
// Check/redeem
const checkLoading = ref(false)
const checkError = ref<string | null>(null)
const checkResult = ref<RedeemResponse | null>(null)
const checkLoading = ref(false);
const checkError = ref<string | null>(null);
const checkResult = ref<RedeemResponse | null>(null);
const checkResultAddons = computed(() => {
if (!checkResult.value?.position?.addons) return ''
return checkResult.value.position.addons.map((addon) => {
if (addon.variation) {
return `+ ${addon.item.internal_name || i18nstringLocalize(addon.item.name)} – ${i18nstringLocalize(addon.variation.value)}`
}
return '+ ' + (addon.item.internal_name || i18nstringLocalize(addon.item.name))
}).join('\n')
})
if (!checkResult.value?.position?.addons) return "";
return checkResult.value.position.addons
.map((addon) => {
if (addon.variation) {
return `+ ${addon.item.internal_name || i18nstringLocalize(addon.item.name)} – ${i18nstringLocalize(addon.variation.value)}`;
}
return (
"+ " + (addon.item.internal_name || i18nstringLocalize(addon.item.name))
);
})
.join("\n");
});
const checkResultSubevent = computed(() => formatSubevent(checkResult.value?.position?.subevent))
const checkResultSubevent = computed(() =>
formatSubevent(checkResult.value?.position?.subevent),
);
const checkResultItemvar = computed(() => {
if (!checkResult.value?.position) return ''
if (!checkResult.value?.position) return "";
if (checkResult.value.position.variation) {
return `${i18nstringLocalize(checkResult.value.position.item.name)} – ${i18nstringLocalize(checkResult.value.position.variation.value)}`
return `${i18nstringLocalize(checkResult.value.position.item.name)} – ${i18nstringLocalize(checkResult.value.position.variation.value)}`;
}
return i18nstringLocalize(checkResult.value.position.item.name)
})
return i18nstringLocalize(checkResult.value.position.item.name);
});
const checkResultText = computed(() => {
if (!checkResult.value) return ''
if (checkResult.value.status === 'ok') {
return type.value === 'exit' ? STRINGS['result.exit'] : STRINGS['result.ok']
} else if (checkResult.value.status === 'incomplete') {
return STRINGS['result.questions']
} else if (checkResult.value.reason && STRINGS['result.' + checkResult.value.reason]) {
return STRINGS['result.' + checkResult.value.reason]
if (!checkResult.value) return "";
if (checkResult.value.status === "ok") {
return type.value === "exit"
? STRINGS["result.exit"]
: STRINGS["result.ok"];
} else if (checkResult.value.status === "incomplete") {
return STRINGS["result.questions"];
} else if (
checkResult.value.reason &&
STRINGS["result." + checkResult.value.reason]
) {
return STRINGS["result." + checkResult.value.reason];
}
return checkResult.value.reason ?? ''
})
return checkResult.value.reason ?? "";
});
const checkResultColor = computed(() => {
if (!checkResult.value) return ''
if (checkResult.value.status === 'ok') return 'green'
if (checkResult.value.status === 'incomplete') return 'purple'
if (checkResult.value.reason === 'already_redeemed') return 'orange'
return 'red'
})
if (!checkResult.value) return "";
if (checkResult.value.status === "ok") return "green";
if (checkResult.value.status === "incomplete") return "purple";
if (checkResult.value.reason === "already_redeemed") return "orange";
return "red";
});
// Modals
const showUnpaidModal = ref(false)
const showQuestionsModal = ref(false)
const questionsModalEl = ref<HTMLFormElement>()
const showUnpaidModal = ref(false);
const showQuestionsModal = ref(false);
const questionsModalEl = ref<HTMLFormElement>();
function answerSetM (qid: string, opid: string, checked: boolean) {
let arr = answers.value[qid] ? answers.value[qid].split(',') : []
function answerSetM(qid: string, opid: string, checked: boolean) {
let arr = answers.value[qid] ? answers.value[qid].split(",") : [];
if (checked && !arr.includes(opid)) {
arr.push(opid)
arr.push(opid);
} else if (!checked) {
arr = arr.filter(o => opid !== o)
arr = arr.filter((o) => opid !== o);
}
answers.value[qid] = arr.join(',')
answers.value[qid] = arr.join(",");
}
// Search
const searchLoading = ref(false)
const searchResults = ref<Position[] | null>(null)
const searchNextUrl = ref<string | null>(null)
const searchError = ref<unknown>(null)
const resultEls = ref<InstanceType<typeof SearchresultItem>[]>([])
const searchLoading = ref(false);
const searchResults = ref<Position[] | null>(null);
const searchNextUrl = ref<string | null>(null);
const searchError = ref<unknown>(null);
const resultEls = ref<InstanceType<typeof SearchresultItem>[]>([]);
function selectResult (position: Position) {
check(position.secret, false, false, false, false)
function selectResult(position: Position) {
check(position.secret, false, false, false, false);
}
async function startSearch (fallbackToScan: boolean) {
async function startSearch(fallbackToScan: boolean) {
if (query.value.length >= 32 && fallbackToScan) {
check(query.value, false, false, true, true)
return
check(query.value, false, false, true, true);
return;
}
checkResult.value = null
searchLoading.value = true
searchError.value = null
searchResults.value = []
answers.value = {}
checkResult.value = null;
searchLoading.value = true;
searchError.value = null;
searchResults.value = [];
answers.value = {};
if (clearTimeoutId) {
clearTimeout(clearTimeoutId)
clearTimeoutId = null
clearTimeout(clearTimeoutId);
clearTimeoutId = null;
}
try {
const data = await api.searchPositions(checkinlist.value!.id, query.value)
searchLoading.value = false
const data = await api.searchPositions(checkinlist.value!.id, query.value);
searchLoading.value = false;
if (data.results) {
searchResults.value = data.results
searchNextUrl.value = data.next
searchResults.value = data.results;
searchNextUrl.value = data.next;
if (data.results.length) {
if (data.results[0].secret === query.value) {
nextTick(() => {
inputEl.value?.blur()
resultEls.value[0]?.el?.click()
})
inputEl.value?.blur();
resultEls.value[0]?.el?.click();
});
} else {
nextTick(() => {
resultEls.value[0]?.el?.focus()
})
resultEls.value[0]?.el?.focus();
});
}
} else {
nextTick(() => {
inputEl.value?.blur()
})
inputEl.value?.blur();
});
}
} else {
searchError.value = data
searchError.value = data;
}
clearTimeoutId = setTimeout(clear, 30 * 1000)
clearTimeoutId = setTimeout(clear, 30 * 1000);
} catch (e) {
searchLoading.value = false
searchResults.value = []
searchError.value = e
clearTimeoutId = setTimeout(clear, 30 * 1000)
searchLoading.value = false;
searchResults.value = [];
searchError.value = e;
clearTimeoutId = setTimeout(clear, 30 * 1000);
}
}
async function searchNext () {
if (!searchNextUrl.value) return
async function searchNext() {
if (!searchNextUrl.value) return;
searchLoading.value = true
searchError.value = null
searchLoading.value = true;
searchError.value = null;
if (clearTimeoutId) {
clearTimeout(clearTimeoutId)
clearTimeoutId = null
clearTimeout(clearTimeoutId);
clearTimeoutId = null;
}
try {
const data = await api.fetchNextPage<Position>(searchNextUrl.value)
searchLoading.value = false
const data = await api.fetchNextPage<Position>(searchNextUrl.value);
searchLoading.value = false;
if (data.results) {
searchResults.value = [...(searchResults.value ?? []), ...data.results]
searchNextUrl.value = data.next
searchResults.value = [...(searchResults.value ?? []), ...data.results];
searchNextUrl.value = data.next;
} else {
searchError.value = data
searchError.value = data;
}
clearTimeoutId = setTimeout(clear, 30 * 1000)
clearTimeoutId = setTimeout(clear, 30 * 1000);
} catch (e) {
searchLoading.value = false
searchError.value = e
clearTimeoutId = setTimeout(clear, 30 * 1000)
searchLoading.value = false;
searchError.value = e;
clearTimeoutId = setTimeout(clear, 30 * 1000);
}
}
async function check (
async function check(
id: string,
ignoreUnpaid: boolean,
keepAnswers: boolean,
fallbackToSearch: boolean,
untrusted: boolean
untrusted: boolean,
) {
if (!keepAnswers) {
answers.value = {}
answers.value = {};
} else if (showQuestionsModal.value) {
if (!questionsModalEl.value?.reportValidity()) return
if (!questionsModalEl.value?.reportValidity()) return;
}
showUnpaidModal.value = false
showQuestionsModal.value = false
checkLoading.value = true
checkError.value = null
checkResult.value = {} as RedeemResponse
showUnpaidModal.value = false;
showQuestionsModal.value = false;
checkLoading.value = true;
checkError.value = null;
checkResult.value = {} as RedeemResponse;
if (clearTimeoutId) {
clearTimeout(clearTimeoutId)
clearTimeoutId = null
clearTimeout(clearTimeoutId);
clearTimeoutId = null;
}
nextTick(() => {
inputEl.value?.blur()
})
inputEl.value?.blur();
});
try {
const data = await api.redeemPosition(
@@ -299,73 +323,87 @@ async function check (
type: type.value,
answers: answers.value,
},
untrusted
)
untrusted,
);
checkLoading.value = false
checkResult.value = data
checkLoading.value = false;
checkResult.value = data;
if (checkinlist.value?.include_pending && data.status === 'error' && data.reason === 'unpaid') {
showUnpaidModal.value = true
if (
checkinlist.value?.include_pending &&
data.status === "error" &&
data.reason === "unpaid"
) {
showUnpaidModal.value = true;
nextTick(() => {
document.querySelector<HTMLButtonElement>('.modal-unpaid .btn-primary')?.focus()
})
} else if (data.status === 'incomplete' && data.questions) {
showQuestionsModal.value = true
document
.querySelector<HTMLButtonElement>(".modal-unpaid .btn-primary")
?.focus();
});
} else if (data.status === "incomplete" && data.questions) {
showQuestionsModal.value = true;
for (const q of data.questions) {
if (!answers.value[q.id.toString()]) {
answers.value[q.id.toString()] = ''
answers.value[q.id.toString()] = "";
}
;(q as AnswerQuestion).question = i18nstringLocalize(q.question)
(q as AnswerQuestion).question = i18nstringLocalize(q.question);
for (const o of q.options) {
;(o as { answer: string }).answer = i18nstringLocalize(o.answer)
(o as { answer: string }).answer = i18nstringLocalize(o.answer);
}
}
nextTick(() => {
document.querySelector<HTMLElement>('.modal-questions input, .modal-questions select, .modal-questions textarea')?.focus()
})
} else if (data.status === 'error' && data.reason === 'invalid' && fallbackToSearch) {
startSearch(false)
document
.querySelector<HTMLElement>(
".modal-questions input, .modal-questions select, .modal-questions textarea",
)
?.focus();
});
} else if (
data.status === "error" &&
data.reason === "invalid" &&
fallbackToSearch
) {
startSearch(false);
} else {
clearTimeoutId = setTimeout(clear, 30 * 1000)
fetchStatus()
clearTimeoutId = setTimeout(clear, 30 * 1000);
fetchStatus();
}
} catch (e) {
checkLoading.value = false
checkResult.value = {} as RedeemResponse
checkError.value = String(e)
clearTimeoutId = setTimeout(clear, 30 * 1000)
checkLoading.value = false;
checkResult.value = {} as RedeemResponse;
checkError.value = String(e);
clearTimeoutId = setTimeout(clear, 30 * 1000);
}
}
function clear () {
query.value = ''
searchLoading.value = false
searchResults.value = null
searchNextUrl.value = null
searchError.value = null
checkLoading.value = false
checkError.value = null
checkResult.value = null
showUnpaidModal.value = false
showQuestionsModal.value = false
answers.value = {}
function clear() {
query.value = "";
searchLoading.value = false;
searchResults.value = null;
searchNextUrl.value = null;
searchError.value = null;
checkLoading.value = false;
checkError.value = null;
checkResult.value = null;
showUnpaidModal.value = false;
showQuestionsModal.value = false;
answers.value = {};
}
onMounted(() => {
window.addEventListener('focus', globalKeydown)
document.addEventListener('visibilitychange', globalKeydown)
document.addEventListener('keydown', globalKeydown)
statusInterval = setInterval(fetchStatus, 120 * 1000)
})
window.addEventListener("focus", globalKeydown);
document.addEventListener("visibilitychange", globalKeydown);
document.addEventListener("keydown", globalKeydown);
statusInterval = setInterval(fetchStatus, 120 * 1000);
});
onUnmounted(() => {
window.removeEventListener('focus', globalKeydown)
document.removeEventListener('visibilitychange', globalKeydown)
document.removeEventListener('keydown', globalKeydown)
if (statusInterval) clearInterval(statusInterval)
if (clearTimeoutId) clearTimeout(clearTimeoutId)
})
window.removeEventListener("focus", globalKeydown);
document.removeEventListener("visibilitychange", globalKeydown);
document.removeEventListener("keydown", globalKeydown);
if (statusInterval) clearInterval(statusInterval);
if (clearTimeoutId) clearTimeout(clearTimeoutId);
});
</script>
<template lang="pug">
.container
@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { CheckinList } from '../api'
import { formatSubevent } from '../i18n'
import { computed } from "vue";
import type { CheckinList } from "../api";
import { formatSubevent } from "../i18n";
const props = defineProps<{
list: CheckinList
}>()
list: CheckinList;
}>();
defineEmits<{
selected: [list: CheckinList]
}>()
selected: [list: CheckinList];
}>();
const subevent = computed(() => formatSubevent(props.list.subevent))
const subevent = computed(() => formatSubevent(props.list.subevent));
</script>
<template lang="pug">
a.list-group-item(href="#", @click.prevent="$emit('selected', list)")
@@ -1,86 +1,86 @@
<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'
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]
}>()
selected: [list: CheckinList];
}>();
const loading = ref(false)
const error = ref<unknown>(null)
const lists = ref<CheckinList[] | null>(null)
const nextUrl = ref<string | null>(null)
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
async function load() {
loading.value = true;
error.value = null;
try {
if (location.hash) {
const listId = location.hash.substring(1)
const listId = location.hash.substring(1);
try {
const data = await api.fetchCheckinList(listId)
loading.value = false
const data = await api.fetchCheckinList(listId);
loading.value = false;
if (data.id) {
emit('selected', data)
emit("selected", data);
} else {
location.hash = ''
load()
location.hash = "";
load();
}
} catch {
location.hash = ''
load()
location.hash = "";
load();
}
return
return;
}
const data = await api.fetchCheckinLists()
loading.value = false
const data = await api.fetchCheckinLists();
loading.value = false;
if (data.results) {
lists.value = data.results
nextUrl.value = data.next
lists.value = data.results;
nextUrl.value = data.next;
} else if (data.results === 0) {
error.value = STRINGS['checkinlist.none']
error.value = STRINGS["checkinlist.none"];
} else {
error.value = data
error.value = data;
}
} catch (e) {
loading.value = false
error.value = e
loading.value = false;
error.value = e;
}
}
async function loadNext () {
if (!nextUrl.value) return
async function loadNext() {
if (!nextUrl.value) return;
loading.value = true
error.value = null
loading.value = true;
error.value = null;
try {
const data = await api.fetchNextPage<CheckinList>(nextUrl.value)
loading.value = false
const data = await api.fetchNextPage<CheckinList>(nextUrl.value);
loading.value = false;
if (data.results) {
lists.value.push(...data.results)
nextUrl.value = data.next
lists.value.push(...data.results);
nextUrl.value = data.next;
} else if (data.results === 0) {
error.value = STRINGS['checkinlist.none']
error.value = STRINGS["checkinlist.none"];
} else {
error.value = data
error.value = data;
}
} catch (e) {
loading.value = false
error.value = e
loading.value = false;
error.value = e;
}
}
onMounted(() => {
load()
})
load();
});
</script>
<template lang="pug">
.panel.panel-primary.checkinlist-select
@@ -1,18 +1,18 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { dateFormat, datetimeLocale } from '../i18n'
import { ref, watch, onMounted, onUnmounted } from "vue";
import { dateFormat, datetimeLocale } from "../i18n";
const props = defineProps<{
required?: boolean
modelValue?: string
id?: string
}>()
required?: boolean;
modelValue?: string;
id?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
"update:modelValue": [value: string];
}>();
const input = ref<HTMLInputElement>()
const input = ref<HTMLInputElement>();
const opts = {
format: dateFormat,
@@ -20,44 +20,50 @@ const opts = {
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',
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(() => props.modelValue, (val) => {
if (val) {
$(input.value!).data('DateTimePicker').date(moment(val))
}
})
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'))
})
.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))
$(input.value!)
.data("DateTimePicker")
.viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
$(input.value!).data("DateTimePicker").date(moment(props.modelValue));
}
})
});
onUnmounted(() => {
$(input.value!)
.off()
.datetimepicker('destroy')
})
$(input.value!).off().datetimepicker("destroy");
});
</script>
<template lang="pug">
input.form-control(:id="id", ref="input", :required="required")
@@ -1,18 +1,18 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { datetimeFormat, datetimeLocale, timezone } from '../i18n'
import { ref, watch, onMounted, onUnmounted } from "vue";
import { datetimeFormat, datetimeLocale, timezone } from "../i18n";
const props = defineProps<{
required?: boolean
modelValue?: string
id?: string
}>()
required?: boolean;
modelValue?: string;
id?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
"update:modelValue": [value: string];
}>();
const input = ref<HTMLInputElement>()
const input = ref<HTMLInputElement>();
const opts = {
format: datetimeFormat,
@@ -21,44 +21,50 @@ const opts = {
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',
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(() => props.modelValue, (val) => {
if (val) {
$(input.value!).data('DateTimePicker').date(moment(val))
}
})
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())
})
.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))
$(input.value!)
.data("DateTimePicker")
.viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
$(input.value!).data("DateTimePicker").date(moment(props.modelValue));
}
})
});
onUnmounted(() => {
$(input.value!)
.off()
.datetimepicker('destroy')
})
$(input.value!).off().datetimepicker("destroy");
});
</script>
<template lang="pug">
input.form-control(:id="id", ref="input", :required="required")
@@ -1,35 +1,43 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Position } from '../api'
import { STRINGS, i18nstringLocalize, formatSubevent } from '../i18n'
import { computed, ref } from "vue";
import type { Position } from "../api";
import { STRINGS, i18nstringLocalize, formatSubevent } from "../i18n";
const props = defineProps<{
position: Position
}>()
position: Position;
}>();
defineEmits<{
selected: [position: Position]
}>()
selected: [position: Position];
}>();
const rootEl = ref<HTMLAnchorElement>()
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
})
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)} – ${i18nstringLocalize(props.position.variation.value)}`;
}
return i18nstringLocalize(props.position.item.name)
})
return i18nstringLocalize(props.position.item.name);
});
const subevent = computed(() => formatSubevent(props.position.subevent))
const subevent = computed(() => formatSubevent(props.position.subevent));
defineExpose({ el: rootEl })
defineExpose({ el: rootEl });
</script>
<template lang="pug">
a.list-group-item.searchresult(ref="rootEl", href="#", @click.prevent="$emit('selected', position)")
@@ -1,18 +1,18 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { timeFormat, datetimeLocale } from '../i18n'
import { ref, watch, onMounted, onUnmounted } from "vue";
import { timeFormat, datetimeLocale } from "../i18n";
const props = defineProps<{
required?: boolean
modelValue?: string
id?: string
}>()
required?: boolean;
modelValue?: string;
id?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
"update:modelValue": [value: string];
}>();
const input = ref<HTMLInputElement>()
const input = ref<HTMLInputElement>();
const opts = {
format: timeFormat,
@@ -20,44 +20,50 @@ const opts = {
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',
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(() => props.modelValue, (val) => {
if (val) {
$(input.value!).data('DateTimePicker').date(moment(val))
}
})
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'))
})
.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))
$(input.value!)
.data("DateTimePicker")
.viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
$(input.value!).data("DateTimePicker").date(moment(props.modelValue));
}
})
});
onUnmounted(() => {
$(input.value!)
.off()
.datetimepicker('destroy')
})
$(input.value!).off().datetimepicker("destroy");
});
</script>
<template lang="pug">
input.form-control(:id="id", ref="input", :required="required")
@@ -1,106 +1,115 @@
const body = document.body
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'
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)
moment.locale(datetimeLocale);
export function gettext (msgid: string): string {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid)
export function gettext(msgid: string): string {
if (typeof django !== "undefined" && typeof django.gettext !== "undefined") {
return django.gettext(msgid);
}
return 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)
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
return plural;
}
export type I18nString = string | Record<string, string> | null | undefined
export type I18nString = string | Record<string, string> | null | undefined;
export function i18nstringLocalize (obj: I18nString): string {
export function i18nstringLocalize(obj: I18nString): string {
// external
return i18nstring_localize(obj)
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'),
}
"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
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 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
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')
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
return value;
}
@@ -1,17 +1,17 @@
import { createApp } from 'vue'
import { createApp } from "vue";
// import './scss/main.scss'
import App from './components/app.vue'
import App from "./components/app.vue";
const mountEl = document.querySelector<HTMLElement>('#app')!
const mountEl = document.querySelector<HTMLElement>("#app")!;
const app = createApp(App, mountEl.dataset)
app.mount('#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)
}
console.error("[VUE]", info, error);
};
+7
View File
@@ -75,6 +75,7 @@ class AuthenticationForm(forms.Form):
'unverified': _("You have not yet activated your account and set a password. Please click the link in the "
"email we sent you. In case you cannot find it, click \"Forgot your password?\" to receive "
"a new email."),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
}
def __init__(self, request=None, *args, **kwargs):
@@ -93,6 +94,12 @@ class AuthenticationForm(forms.Form):
email = self.cleaned_data.get('email')
password = self.cleaned_data.get('password')
if rate_limit("customer_login", include_ip_from_request=self.request, max_num=10, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if email is not None and password:
try:
u = self.request.organizer.customers.get(email=email.lower(), provider__isnull=True)
@@ -1,72 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rules as rawRules, allItems, activeItems, allProducts, limitProducts } from './django-interop'
import { convertToDNF } from './jsonlogic-boolalg'
import { computed } from "vue";
import {
rules as rawRules,
allItems,
activeItems,
allProducts,
limitProducts,
} from "./django-interop";
import { convertToDNF } from "./jsonlogic-boolalg";
import RulesEditor from './checkin-rules-editor.vue'
import RulesVisualization from './checkin-rules-visualization.vue'
import RulesEditor from "./checkin-rules-editor.vue";
import RulesVisualization from "./checkin-rules-visualization.vue";
const gettext = (window as any).gettext
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
let productsSeen = {};
let variationsSeen = {};
let rules = convertToDNF(rawRules.value);
let branchWithoutProductFilter = false;
if (!rules.or) {
rules = { or: [rules] }
rules = { or: [rules] };
}
for (let part of rules.or) {
if (!part.and) {
part = { and: [part] }
part = { and: [part] };
}
let thisBranchWithoutProductFilter = true
let thisBranchWithoutProductFilter = true;
for (let subpart of part.and) {
if (subpart.inList) {
if (subpart.inList[0].var === 'product' && subpart.inList[1]) {
thisBranchWithoutProductFilter = false
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
productsSeen[parseInt(listentry.lookup[1])] = true;
}
} else if (subpart.inList[0].var === 'variation' && subpart.inList[1]) {
thisBranchWithoutProductFilter = false
} 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
variationsSeen[parseInt(listentry_.lookup[1])] = true;
}
}
}
}
if (thisBranchWithoutProductFilter) {
branchWithoutProductFilter = true
break
branchWithoutProductFilter = true;
break;
}
}
if (branchWithoutProductFilter || (!Object.keys(productsSeen).length && !Object.keys(variationsSeen).length)) {
if (
branchWithoutProductFilter ||
(!Object.keys(productsSeen).length && !Object.keys(variationsSeen).length)
) {
// At least one branch with no product filters at all – that's fine.
return []
return [];
}
let missing = []
let missing = [];
for (const item of activeItems.value) {
if (productsSeen[item.id]) continue
if (!allProducts.value && !limitProducts.value.includes(item.id)) continue
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)
if (variationsSeen[variation.id]) continue;
missing.push(item.name + " – " + variation.name);
}
} else {
missing.push(item.name)
missing.push(item.name);
}
}
return missing
})
return missing;
});
</script>
<template lang="pug">
#rules-editor.form-inline
@@ -98,5 +107,4 @@ const missingItems = computed(() => {
li(v-for="h in missingItems", :key="h") {{ h }}
p {{ gettext("Please double-check if this was intentional.") }}
</template>
<style lang="stylus">
</style>
<style lang="stylus"></style>
@@ -1,230 +1,258 @@
<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'
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";
const props = defineProps<{
rule: any
level: number
index: number
}>()
rule: any;
level: number;
index: number;
}>();
const emit = defineEmits<{
remove: []
duplicate: []
}>()
remove: [];
duplicate: [];
}>();
const operator = computed(() => Object.keys(props.rule)[0])
const operands = computed(() => props.rule[operator.value])
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
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']
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
})
return null;
});
const rightoperand = computed(() => {
const op = operator.value
if (op === 'and' || op === 'or') return null
return props.rule[op]?.[1] ?? null
})
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
}))
"checkin-rule": true,
["checkin-rule-" + variable.value]: true,
}));
const vartype = computed(() => VARS[variable.value]?.type)
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]
if (vartype.value === "int_by_datetime") {
return props.rule[operator.value]?.[0]?.[variable.value]?.[0]
?.buildTime?.[0];
}
return rightoperand.value?.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]
const op = operator.value;
if (
(op === "isBefore" || op === "isAfter") &&
props.rule[op]?.[2] !== undefined
) {
return props.rule[op][2];
}
return null
})
return null;
});
const timeValue = computed(() => {
if (vartype.value === 'int_by_datetime') {
return props.rule[operator.value]?.[0]?.[variable.value]?.[0]?.buildTime?.[1]
if (vartype.value === "int_by_datetime") {
return props.rule[operator.value]?.[0]?.[variable.value]?.[0]
?.buildTime?.[1];
}
return rightoperand.value?.buildTime?.[1]
})
return rightoperand.value?.buildTime?.[1];
});
const cardinality = computed(() => TYPEOPS[vartype.value]?.[operator.value]?.cardinality)
const operators = computed(() => TYPEOPS[vartype.value])
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]
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]
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)) }
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
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 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 }]
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 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)
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)
props.rule[operator.value].push(val);
} else {
props.rule[operator.value][1] = val
props.rule[operator.value][1] = val;
}
}
function setTimeTolerance (event: Event) {
const val = parseInt((event.target as HTMLInputElement).value)
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)
props.rule[operator.value].push(val);
} else {
props.rule[operator.value][2] = val
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
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)
props.rule[operator.value].push(time);
} else {
props.rule[operator.value][1] = time
props.rule[operator.value][1] = time;
}
if (val === 'custom') {
props.rule[operator.value][2] = 0
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
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
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] })) }
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)
props.rule[operator.value].push(products);
} else {
props.rule[operator.value][1] = products
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] })) }
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)
props.rule[operator.value].push(products);
} else {
props.rule[operator.value][1] = products
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] })) }
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)
props.rule[operator.value].push(products);
} else {
props.rule[operator.value][1] = products
props.rule[operator.value][1] = products;
}
}
function setRightOperandEnum (event: Event) {
const val = (event.target as HTMLSelectElement).value
function setRightOperandEnum(event: Event) {
const val = (event.target as HTMLSelectElement).value;
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(val)
props.rule[operator.value].push(val);
} else {
props.rule[operator.value][1] = val
props.rule[operator.value][1] = val;
}
}
function addOperand () {
props.rule[operator.value].push({ '': [] })
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 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 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 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 remove() {
emit("remove");
}
function duplicate () {
emit('duplicate')
function duplicate() {
emit("duplicate");
}
function removeChild (index: number) {
props.rule[operator.value].splice(index, 1)
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)
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">
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import { TEXTS } from './constants'
import { rules } from './django-interop'
import CheckinRule from './checkin-rule.vue'
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)
const hasRules = computed(() => !!Object.keys(rules.value).length);
function addRule () {
rules.value.and = []
function addRule() {
rules.value.and = [];
}
</script>
<template lang="pug">
@@ -1,36 +1,36 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { rules } from './django-interop'
import VizNode from './viz-node.vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { rules } from "./django-interop";
import VizNode from "./viz-node.vue";
declare const d3: any
declare const d3: any;
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 }))
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 }));
const boxWidth = 300
const boxHeight = 62
const paddingX = 50
const marginX = 50
const marginY = 20
const boxWidth = 300;
const boxHeight = 62;
const paddingX = 50;
const marginX = 50;
const marginY = 20;
interface GraphNode {
rule: any
column: number
children: string[]
y?: number
parent?: 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
nodes_by_id: Record<string, GraphNode>;
children: string[];
columns: number;
height: number;
y?: number;
}
const graph = computed<Graph>(() => {
@@ -69,175 +69,198 @@ const graph = computed<Graph>(() => {
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()
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]]
};
return [[node_id], [node_id]];
}
const operator = Object.keys(rule)[0]
const operands = rule[operator]
const operator = Object.keys(rule)[0];
const operands = rule[operator];
if (operator === 'and') {
let children: string[] = []
let tails: string[] | null = null
operands.reverse()
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)
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)
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]
graphData.nodes_by_id[c].parent = graphData.nodes_by_id[new_child];
}
}
if (tails === null) {
tails = new_tails
tails = new_tails;
}
children = new_children
children = new_children;
}
return [children, tails!]
} else if (operator === 'or') {
const children: string[] = []
const tails: string[] = []
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)
const [new_children, new_tails] = _add_to_graph(operand);
children.push(...new_children);
tails.push(...new_tails);
}
return [children, tails]
return [children, tails];
} else {
const node_id = (counter++).toString()
const node_id = (counter++).toString();
graphData.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
};
return [[node_id], [node_id]];
}
}
graphData.children = _add_to_graph(JSON.parse(JSON.stringify(rules.value)))[0]
};
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)
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)
};
_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') {
if (typeof node.y === "undefined") {
// We only take the first value we found for each node
node.y = offset
node.y = offset;
}
let used = 0
let used = 0;
for (const cid of node.children) {
used += Math.max(0, _set_y(graphData.nodes_by_id[cid], offset + used) - 1)
used++
used += Math.max(
0,
_set_y(graphData.nodes_by_id[cid], offset + used) - 1,
);
used++;
}
return used
}
_set_y(graphData, 0)
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
graphData.height = 1;
for (const node of [...Object.values(graphData.nodes_by_id)]) {
graphData.height = Math.max(graphData.height, (node.y ?? 0) + 1)
graphData.height = Math.max(graphData.height, (node.y ?? 0) + 1);
}
return graphData
})
return graphData;
});
const contentWidth = computed(() => {
return graph.value.columns * (boxWidth + marginX) + 2 * paddingX
})
return graph.value.columns * (boxWidth + marginX) + 2 * paddingX;
});
const contentHeight = computed(() => {
return graph.value.height * (boxHeight + marginY)
})
return graph.value.height * (boxHeight + marginY);
});
const viewBox = computed(() => {
return `0 0 ${contentWidth.value} ${contentHeight.value}`
})
return `0 0 ${contentWidth.value} ${contentHeight.value}`;
});
function createZoom () {
if (!svg.value) return
function createZoom() {
if (!svg.value) return;
const viewportHeight = svg.value.clientHeight
const viewportWidth = svg.value.clientWidth
defaultScale.value = 1
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]])
.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
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
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
.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)
d3.select(svg.value).call(zoom.value.transform, initTransform);
const svgSelection = d3.select(svg.value).call(zoom.value)
svgSelection.on('touchmove.zoom', null)
const svgSelection = d3.select(svg.value).call(zoom.value);
svgSelection.on("touchmove.zoom", null);
// TODO touch support
}
watch(maximized, () => {
nextTick(() => {
createZoom()
})
})
createZoom();
});
});
onMounted(() => {
createZoom()
window.addEventListener('resize', createZoom)
})
createZoom();
window.addEventListener("resize", createZoom);
});
onUnmounted(() => {
window.removeEventListener('resize', createZoom)
})
window.removeEventListener("resize", createZoom);
});
</script>
<template lang="pug">
div(:class="'checkin-rules-visualization ' + (maximized ? 'maximized' : '')")
@@ -1,20 +1,20 @@
/* 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'),
}
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
@@ -26,173 +26,173 @@ export const TYPEOPS = {
// * in pretixscan-ios
product: {
inList: {
label: gettext('is one of'),
label: gettext("is one of"),
cardinality: 2,
}
},
},
variation: {
inList: {
label: gettext('is one of'),
label: gettext("is one of"),
cardinality: 2,
}
},
},
gate: {
inList: {
label: gettext('is one of'),
label: gettext("is one of"),
cardinality: 2,
}
},
},
datetime: {
isBefore: {
label: gettext('is before'),
label: gettext("is before"),
cardinality: 2,
},
isAfter: {
label: gettext('is after'),
label: gettext("is after"),
cardinality: 2,
},
},
enum_entry_status: {
'==': {
label: gettext('='),
"==": {
label: gettext("="),
cardinality: 2,
},
},
int_by_datetime: {
'<': {
label: '<',
"<": {
label: "<",
cardinality: 2,
},
'<=': {
label: '≤',
"<=": {
label: "≤",
cardinality: 2,
},
'>': {
label: '>',
">": {
label: ">",
cardinality: 2,
},
'>=': {
label: '≥',
">=": {
label: "≥",
cardinality: 2,
},
'==': {
label: '=',
"==": {
label: "=",
cardinality: 2,
},
'!=': {
label: '≠',
"!=": {
label: "≠",
cardinality: 2,
},
},
int: {
'<': {
label: '<',
"<": {
label: "<",
cardinality: 2,
},
'<=': {
label: '≤',
"<=": {
label: "≤",
cardinality: 2,
},
'>': {
label: '>',
">": {
label: ">",
cardinality: 2,
},
'>=': {
label: '≥',
">=": {
label: "≥",
cardinality: 2,
},
'==': {
label: '=',
"==": {
label: "=",
cardinality: 2,
},
'!=': {
label: '≠',
"!=": {
label: "≠",
cardinality: 2,
},
},
}
};
export const VARS = {
product: {
label: gettext('Product'),
type: 'product',
label: gettext("Product"),
type: "product",
},
variation: {
label: gettext('Product variation'),
type: 'variation',
label: gettext("Product variation"),
type: "variation",
},
gate: {
label: gettext('Gate'),
type: 'gate',
label: gettext("Gate"),
type: "gate",
},
now: {
label: gettext('Current date and time'),
type: 'datetime',
label: gettext("Current date and time"),
type: "datetime",
},
now_isoweekday: {
label: gettext('Current day of the week (1 = Monday, 7 = Sunday)'),
type: 'int',
label: gettext("Current day of the week (1 = Monday, 7 = Sunday)"),
type: "int",
},
entry_status: {
label: gettext('Current entry status'),
type: 'enum_entry_status',
label: gettext("Current entry status"),
type: "enum_entry_status",
},
entries_number: {
label: gettext('Number of previous entries'),
type: 'int',
label: gettext("Number of previous entries"),
type: "int",
},
entries_today: {
label: gettext('Number of previous entries since midnight'),
type: 'int',
label: gettext("Number of previous entries since midnight"),
type: "int",
},
entries_since: {
label: gettext('Number of previous entries since'),
type: 'int_by_datetime',
label: gettext("Number of previous entries since"),
type: "int_by_datetime",
},
entries_before: {
label: gettext('Number of previous entries before'),
type: 'int_by_datetime',
label: gettext("Number of previous entries before"),
type: "int_by_datetime",
},
entries_days: {
label: gettext('Number of days with a previous entry'),
type: 'int',
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',
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',
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',
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',
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'
}
}
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,
}
};
@@ -1,21 +1,24 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { DATETIME_OPTIONS } from './constants'
import { ref, watch, onMounted, onUnmounted } from "vue";
import { DATETIME_OPTIONS } from "./constants";
const props = defineProps<{
required?: boolean
value?: string
}>()
required?: boolean;
value?: string;
}>();
const emit = defineEmits<{
input: [value: string]
}>()
input: [value: string];
}>();
const input = ref<HTMLInputElement | null>(null)
const input = ref<HTMLInputElement | null>(null);
watch(() => props.value, (val) => {
$(input.value).data('DateTimePicker').date(moment(val))
})
watch(
() => props.value,
(val) => {
$(input.value).data("DateTimePicker").date(moment(val));
},
);
onMounted(() => {
$(input.value)
@@ -23,22 +26,22 @@ onMounted(() => {
...DATETIME_OPTIONS,
showClear: props.required,
})
.trigger('change')
.on('dp.change', function (this: HTMLElement) {
emit('input', $(this).data('DateTimePicker').date().toISOString())
})
.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))
$(input.value)
.data("DateTimePicker")
.viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(input.value).data('DateTimePicker').date(moment(props.value))
$(input.value).data("DateTimePicker").date(moment(props.value));
}
})
});
onUnmounted(() => {
$(input.value)
.off()
.datetimepicker('destroy')
})
$(input.value).off().datetimepicker("destroy");
});
</script>
<template lang="pug">
input.form-control(ref="input")
@@ -1,70 +1,95 @@
import { ref, watch } from 'vue'
import { ref, watch } from "vue";
export const allProducts = ref(false)
export const limitProducts = ref<number[]>([])
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))
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()
document
.querySelectorAll("#id_all_products, input[name=limit_products]")
.forEach((el) => el.addEventListener("change", updateProducts));
updateProducts();
export const rules = ref<any>({})
export const rules = ref<any>({});
// grab rules from hidden input
const rulesInput = document.querySelector<HTMLInputElement>('#id_rules')
const rulesInput = document.querySelector<HTMLInputElement>("#id_rules");
if (rulesInput?.value) {
rules.value = JSON.parse(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 })
watch(
rules,
(newVal) => {
if (!rulesInput) return;
rulesInput.value = JSON.stringify(newVal);
},
{ deep: true },
);
export const activeItems = ref<any[]>([])
export const allItems = ref<any[]>([])
export const activeItems = ref<any[]>([]);
export const allItems = ref<any[]>([]);
const itemsEl = document.querySelector('#items')
const itemsEl = document.querySelector("#items");
if (itemsEl?.textContent) {
allItems.value = JSON.parse(itemsEl.textContent || '[]')
activeItems.value = allItems.value.filter(item => item.active)
allItems.value = JSON.parse(itemsEl.textContent || "[]");
activeItems.value = allItems.value.filter((item) => item.active);
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]]
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]]
} 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(allItems.value.map(p => [p.id, p.name])),
Object.fromEntries(allItems.value.flatMap(p => p.variations?.map(v => [v.id, p.name + ' – ' + v.name]) ?? [])),
rules.value
)
Object.fromEntries(allItems.value.map((p) => [p.id, p.name])),
Object.fromEntries(
allItems.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('#gates-select2')?.textContent)
export const productSelectURL = ref(
document.querySelector("#product-select2")?.textContent,
);
export const variationSelectURL = ref(
document.querySelector("#variations-select2")?.textContent,
);
export const gateSelectURL = ref(
document.querySelector("#gates-select2")?.textContent,
);
@@ -1,12 +1,12 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createApp } from "vue";
import App from "./App.vue";
const app = createApp(App)
app.mount('#rules-editor')
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)
}
console.error("[VUE]", info, error);
};
@@ -1,93 +1,110 @@
export function convertToDNF (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
if (typeof rules !== "object" || Array.isArray(rules) || rules === null) {
return rules;
}
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 = []
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
if (
typeof arg === "object" &&
!Array.isArray(arg) &&
typeof arg["or"] !== "undefined" &&
arg_to_distribute === null
) {
arg_to_distribute = arg;
} else {
other_args.push(arg)
other_args.push(arg);
}
}
if (arg_to_distribute === null) {
return r
return r;
}
let or_operands = []
for (let dval of arg_to_distribute['or']) {
or_operands.push({ and: other_args.concat([dval]) })
let or_operands = [];
for (let dval of arg_to_distribute["or"]) {
or_operands.push({ and: other_args.concat([dval]) });
}
return {
or: or_operands
}
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
return r;
} else if (
operator === "!" ||
operator === "!!" ||
operator === "?:" ||
operator === "if"
) {
console.warn(
"Operator " + operator + " currently unsupported by convert_to_dnf",
);
return r;
} else {
return r
return r;
}
}
function _simplify_chained_operators (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
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 operator = Object.keys(r)[0];
let values = r[operator];
if (operator !== "or" && operator !== "and") {
return r;
}
let new_values = []
let new_values = [];
for (let v of values) {
if (typeof v !== 'object' || Array.isArray(v) || typeof v[operator] === 'undefined') {
new_values.push(v)
if (
typeof v !== "object" ||
Array.isArray(v) ||
typeof v[operator] === "undefined"
) {
new_values.push(v);
} else {
new_values.push(...v[operator])
new_values.push(...v[operator]);
}
}
let result = {}
result[operator] = new_values
return result
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.
let old_rules = rules
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
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
values = [values];
no_list = true;
}
rules = {}
rules = {};
if (!no_list) {
rules[operator] = []
rules[operator] = [];
for (let v of values) {
rules[operator].push(convertToDNF(v))
rules[operator].push(convertToDNF(v));
}
} else {
rules[operator] = convertToDNF(values[0])
rules[operator] = convertToDNF(values[0]);
}
if (JSON.stringify(old_rules) === JSON.stringify(rules)) { // Let's hope this is good enough...
break
if (JSON.stringify(old_rules) === JSON.stringify(rules)) {
// Let's hope this is good enough...
break;
}
old_rules = rules
old_rules = rules;
}
rules = _simplify_chained_operators(rules)
return rules
rules = _simplify_chained_operators(rules);
return rules;
}
@@ -1,119 +1,141 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, watch, onMounted, onUnmounted } from "vue";
declare const $: any
declare const $: any;
export interface ObjectListItem {
lookup: [string, number | string, string]
lookup: [string, number | string, string];
}
export interface ObjectList {
objectList: ObjectListItem[]
objectList: ObjectListItem[];
}
const props = defineProps<{
required?: boolean
value?: ObjectList
placeholder?: string
url?: string
multiple?: boolean
}>()
required?: boolean;
value?: ObjectList;
placeholder?: string;
url?: string;
multiple?: boolean;
}>();
const emit = defineEmits<{
input: [value: any[]]
}>()
input: [value: any[]];
}>();
const select = ref<HTMLSelectElement | null>(null)
const select = ref<HTMLSelectElement | null>(null);
function opts () {
function opts() {
return {
theme: 'bootstrap',
theme: "bootstrap",
delay: 100,
width: '100%',
width: "100%",
multiple: true,
allowClear: props.required,
language: $('body').attr('data-select2-locale'),
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
}
}
page: params.page || 1,
};
},
},
templateResult: function (res: { id?: string; text: string }) {
if (!res.id) {
return res.text
return res.text;
}
const $ret = $('<span>').append(
$('<span>').addClass('primary').append($('<div>').text(res.text).html())
)
return $ret
const $ret = $("<span>").append(
$("<span>")
.addClass("primary")
.append($("<div>").text(res.text).html()),
);
return $ret;
},
}
};
}
function build () {
function build() {
$(select.value)
.empty()
.select2(opts())
.val(props.value || '')
.trigger('change')
.on('change', function (this: HTMLElement) {
emit('input', $(this).select2('data'))
})
.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)
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')
$(select.value).trigger("change");
}
watch(() => props.placeholder, () => {
$(select.value).select2('destroy')
build()
})
watch(
() => props.placeholder,
() => {
$(select.value).select2("destroy");
build();
},
);
watch(() => props.required, () => {
$(select.value).select2('destroy')
build()
})
watch(
() => props.required,
() => {
$(select.value).select2("destroy");
build();
},
);
watch(() => props.url, () => {
$(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)
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");
}
$(select.value).trigger('change')
}
})
},
);
let rawSelectEl: HTMLSelectElement | null = null
let rawSelectEl: HTMLSelectElement | null = null;
onMounted(() => {
rawSelectEl = select.value
build()
})
rawSelectEl = select.value;
build();
});
onUnmounted(() => {
if (!rawSelectEl) return
$(rawSelectEl)
.off()
.select2('destroy')
rawSelectEl = null
})
if (!rawSelectEl) return;
$(rawSelectEl).off().select2("destroy");
rawSelectEl = null;
});
</script>
<template lang="pug">
select(ref="select")
@@ -1,21 +1,24 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { TIME_OPTIONS } from './constants'
import { ref, watch, onMounted, onUnmounted } from "vue";
import { TIME_OPTIONS } from "./constants";
const props = defineProps<{
required?: boolean
value?: string
}>()
required?: boolean;
value?: string;
}>();
const emit = defineEmits<{
input: [value: string]
}>()
input: [value: string];
}>();
const input = ref<HTMLInputElement | null>(null)
const input = ref<HTMLInputElement | null>(null);
watch(() => props.value, (val) => {
$(input.value).data('DateTimePicker').date(val)
})
watch(
() => props.value,
(val) => {
$(input.value).data("DateTimePicker").date(val);
},
);
onMounted(() => {
$(input.value)
@@ -23,22 +26,22 @@ onMounted(() => {
...TIME_OPTIONS,
showClear: props.required,
})
.trigger('change')
.on('dp.change', function (this: HTMLElement) {
emit('input', $(this).data('DateTimePicker').date().format('HH:mm:ss'))
})
.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))
$(input.value)
.data("DateTimePicker")
.viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(input.value).data('DateTimePicker').date(props.value)
$(input.value).data("DateTimePicker").date(props.value);
}
})
});
onUnmounted(() => {
$(input.value)
.off()
.datetimepicker('destroy')
})
$(input.value).off().datetimepicker("destroy");
});
</script>
<template lang="pug">
input.form-control(ref="input")
@@ -1,43 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue'
import { TEXTS, VARS, TYPEOPS } from './constants'
import { computed } from "vue";
import { TEXTS, VARS, TYPEOPS } from "./constants";
declare const $: any
declare const moment: any
declare const $: any;
declare const moment: any;
interface GraphNode {
rule: any
column: number
children: string[]
y: number
parent?: GraphNode
rule: any;
column: number;
children: string[];
y: number;
parent?: GraphNode;
}
const props = defineProps<{
node: GraphNode
nodeid: string
children: GraphNode[]
boxWidth: number
boxHeight: number
marginX: number
marginY: number
paddingX: number
}>()
node: GraphNode;
nodeid: string;
children: GraphNode[];
boxWidth: number;
boxHeight: number;
marginX: number;
marginY: number;
paddingX: number;
}>();
const x = computed(() => {
return props.node.column * (props.boxWidth + props.marginX) + props.marginX / 2 + props.paddingX
})
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
})
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
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
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}
@@ -45,25 +56,25 @@ const edges = computed(() => {
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}
`
})
})
`;
});
});
const checkEdge = computed(() => {
const startX = x.value + props.boxWidth + 1
const startY = y.value + props.boxHeight / 2
const startX = x.value + props.boxWidth + 1;
const startY = y.value + props.boxHeight / 2;
return `M ${startX} ${startY} L ${startX + 25} ${startY}`
})
return `M ${startX} ${startY} L ${startX + 25} ${startY}`;
});
const rootEdge = computed(() => {
if (props.node.column > 0) {
return
return;
}
const startX = 0
const startY = props.boxHeight / 2 + props.marginY / 2
const endX = x.value - 1
const endY = y.value + props.boxHeight / 2
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}
@@ -71,98 +82,101 @@ const rootEdge = computed(() => {
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}
`
})
`;
});
const operator = computed(() => {
return Object.keys(props.node.rule).filter((k) => !k.startsWith('__'))[0]
})
return Object.keys(props.node.rule).filter((k) => !k.startsWith("__"))[0];
});
const variable = computed(() => {
const op = operator.value
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_since"]) {
return "entries_since";
}
if (props.node.rule[op][0]['entries_before']) {
return 'entries_before'
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_since"]) {
return "entries_days_since";
}
if (props.node.rule[op][0]['entries_days_before']) {
return 'entries_days_before'
if (props.node.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return props.node.rule[op][0]['var']
return props.node.rule[op][0]["var"];
} else {
return ''
return "";
}
})
});
const vardata = computed(() => {
return VARS[variable.value as keyof typeof VARS]
})
return VARS[variable.value as keyof typeof VARS];
});
const varresult = computed(() => {
const op = operator.value
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']
if (typeof props.node.rule[op][0]["__result"] === "undefined") return null;
return props.node.rule[op][0]["__result"];
} else {
return ''
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]
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
return null;
}
})
});
const op = computed(() => {
return TYPEOPS[vardata.value.type as keyof typeof TYPEOPS]?.[operator.value as any]
})
return TYPEOPS[vardata.value.type as keyof typeof TYPEOPS]?.[
operator.value as any
];
});
const operands = computed(() => {
return props.node.rule[operator.value]
})
return props.node.rule[operator.value];
});
const result = computed(() => {
return typeof props.node.rule.__result === 'undefined' ? null : !!props.node.rule.__result
})
return typeof props.node.rule.__result === "undefined"
? null
: !!props.node.rule.__result;
});
const resultInclParents = computed(() => {
if (typeof props.node.rule.__result === 'undefined') return null
if (typeof props.node.rule.__result === "undefined") return null;
function _p (node: GraphNode): boolean {
function _p(node: GraphNode): boolean {
if (node.parent) {
return node.rule.__result && _p(node.parent)
return node.rule.__result && _p(node.parent);
}
return node.rule.__result
return node.rule.__result;
}
return _p(props.node)
})
return _p(props.node);
});
const nodeClass = computed(() => {
return {
node: true,
'node-true': result.value === true,
'node-false': result.value === false,
}
})
"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 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)
function tf(val: string) {
const format = $("body").attr("data-timeformat");
return moment(val, "HH:mm:ss").format(format);
}
</script>
<template lang="pug">
@@ -1,95 +1,95 @@
import type { Category, DayEntry, EventEntry, MetaFilterField } from '~/types'
import type { Category, DayEntry, EventEntry, MetaFilterField } from "~/types";
export class ApiError extends Error {
status: number
responseUrl: string
status: number;
responseUrl: string;
constructor (status: number, responseUrl: string) {
super(`HTTP ${status}`)
this.status = status
this.responseUrl = responseUrl
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
currency_places?: number
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[]
empty_text?: string
weeks?: DayEntry[][]
date?: string
days?: DayEntry[]
week?: [number, number]
target_url?: string;
subevent?: string | number;
name?: string;
frontpage_text?: string;
date_range?: string;
location?: string;
items_by_category?: Category[];
currency?: string;
currency_places?: number;
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[];
empty_text?: string;
weeks?: DayEntry[][];
date?: string;
days?: DayEntry[];
week?: [number, number];
}
export async function fetchProductList (url: string) {
const response = await fetch(url)
export async function fetchProductList(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(response.status, response.url)
throw new ApiError(response.status, response.url);
}
return {
data: await response.json() as ProductListResponse,
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
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) {
export async function submitCart(endpoint: string, formData: FormData) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
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)
throw new ApiError(response.status, response.url);
}
return await response.json() as CartResponse
return (await response.json()) as CartResponse;
}
export async function checkAsyncTask (url: string) {
const response = await fetch(url)
export async function checkAsyncTask(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(response.status, response.url)
throw new ApiError(response.status, response.url);
}
return await response.json() as CartResponse
return (await response.json()) as CartResponse;
}
export async function createCart (url: string) {
const response = await fetch(url)
export async function createCart(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(response.status, response.url)
throw new ApiError(response.status, response.url);
}
return await response.json() as CartResponse
return (await response.json()) as CartResponse;
}
@@ -1,31 +1,33 @@
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'
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
export function createButtonInstance(element: Element, htmlId?: string): App {
let targetUrl = element.attributes.event.value;
if (!targetUrl.match(/\/$/)) {
targetUrl += '/'
targetUrl += "/";
}
const widgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data))
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
widgetData[attr.name.replace(/^data-/, "")] = attr.value;
}
}
const rawItems = element.attributes.items?.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 buttonItems: { item: string; count: string }[] = [];
for (const itemStr of rawItems.split(",")) {
if (itemStr.includes("=")) {
const [item, count] = itemStr.split("=");
buttonItems.push({ item, count });
}
}
@@ -33,45 +35,50 @@ export function createButtonInstance (element: Element, htmlId?: string): App {
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,
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,
})
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 (
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
store.widgetData[attrName] = attrValue;
}
}
}
})
});
const app = createApp(ButtonComponent)
app.provide(StoreKey, store)
const app = createApp(ButtonComponent);
app.provide(StoreKey, store);
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-button]', info, error)
}
console.error("[pretix-button]", info, error);
};
// Instead of mounting to the element directly, replicate vue2.7 behaviour, where
// the HTML-element gets replaced instead of vue3’s behaviour to replace innerHTML.
// The latter can cause issues with custom CSS as HTML structure would change.
// app.mount(element)
// observer.observe(element, { attributes: true })
const fragment = document.createDocumentFragment()
app.mount(fragment)
const fragment = document.createDocumentFragment();
app.mount(fragment);
for (const attr of element.attributes) {
fragment.firstChild.setAttribute(attr.name, attr.value)
fragment.firstChild.setAttribute(attr.name, attr.value);
}
observer.observe(fragment.firstChild, { attributes: true })
element.parentNode.replaceChild(fragment, element)
observer.observe(fragment.firstChild, { attributes: true });
element.parentNode.replaceChild(fragment, element);
return app
return app;
}
@@ -1,100 +1,120 @@
<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'
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
}>()
item: Item;
variation?: Variation;
}>();
const store = inject(StoreKey)!
const quantity = ref<HTMLInputElement>()
const store = inject(StoreKey)!;
const quantity = ref<HTMLInputElement>();
const avail = computed(() => props.item.has_variations ? props.variation.avail : props.item.avail)
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 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 `variation_${props.item.id}_${props.variation.id}`;
}
return `item_${props.item.id}`
})
return `item_${props.item.id}`;
});
const unavailabilityReasonMessage = computed(() => {
const reason = props.item.current_unavailability_reason || props.variation?.current_unavailability_reason
const reason =
props.item.current_unavailability_reason ||
props.variation?.current_unavailability_reason;
if (reason) {
return STRINGS[`unavailable_${reason}`] || reason
return STRINGS[`unavailable_${reason}`] || reason;
}
return ''
})
return "";
});
const voucherJumpLink = computed(() => `#${store.htmlId}-voucher-input`)
const voucherJumpLink = computed(() => `#${store.htmlId}-voucher-input`);
const ariaLabelledby = computed(() => `${store.htmlId}-item-label-${props.item.id}`)
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 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 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)
})
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 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}`
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}`
u += `&var=${props.variation.id}`;
}
if (store.subevent) {
u += `&subevent=${store.subevent}`
u += `&subevent=${store.subevent}`;
}
u += `&widget_data=${encodeURIComponent(store.widgetDataJson)}`
u += store.consentParameter
return u
})
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
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 }))
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
!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
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
@@ -149,5 +169,4 @@ onMounted(() => {
)
span +
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,32 +1,32 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import Overlay from './Overlay.vue'
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 lang = LANG; // we need this so the template sees the variable
const store = inject(StoreKey)!
const store = inject(StoreKey)!;
const form = ref<HTMLFormElement>()
const form = ref<HTMLFormElement>();
const formMethod = computed(() => {
if (!store.useIframe && store.isButton && store.items.length === 0) {
return 'get'
return "get";
}
return 'post'
})
return "post";
});
function handleBuy (event: Event) {
function handleBuy(event: Event) {
if (form.value) {
const formData = new FormData(form.value)
store.buy(formData, event)
const formData = new FormData(form.value);
store.buy(formData, event);
}
}
defineExpose({
form,
buy: handleBuy,
})
});
</script>
<template lang="pug">
.pretix-widget-wrapper
@@ -50,5 +50,4 @@ defineExpose({
Overlay
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { Category } from '~/types'
import Item from './Item.vue'
import type { Category } from "~/types";
import Item from "./Item.vue";
defineProps<{
category: Category
}>()
category: Category;
}>();
</script>
<template lang="pug">
@@ -19,5 +19,4 @@ defineProps<{
:category="category"
)
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,66 +1,72 @@
<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'
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
}>()
mobile: boolean;
}>();
const store = inject(StoreKey)!
const calendar = ref<HTMLDivElement>()
const store = inject(StoreKey)!;
const calendar = ref<HTMLDivElement>();
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && store.parentStack.length > 0))
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}`
})
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 id = computed(() => `${store.htmlId}-event-calendar-table`);
const ariaLabelledby = computed(() => `${store.htmlId}-event-calendar-table-label`)
const ariaLabelledby = computed(
() => `${store.htmlId}-event-calendar-table-label`,
);
function backToList () {
function backToList() {
// TODO should be in store
store.weeks = null
store.view = 'events'
store.name = null
store.frontpageText = null
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--
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--
curMonth = 12;
curYear--;
}
store.date = `${curYear}-${padNumber(curMonth, 2)}-01`
store.loading++
store.reload({ focus: `#${id.value}` })
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++
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++
curMonth = 1;
curYear++;
}
store.date = `${curYear}-${padNumber(curMonth, 2)}-01`
store.loading++
store.reload({ focus: `#${id.value}` })
store.date = `${curYear}-${padNumber(curMonth, 2)}-01`;
store.loading++;
store.reload({ focus: `#${id.value}` });
}
</script>
<template lang="pug">
@@ -116,5 +122,4 @@ function nextmonth () {
:mobile="mobile"
)
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,108 +1,117 @@
<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'
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
}>()
day: DayEntry | null;
mobile: boolean;
}>();
const store = inject(StoreKey)!
const cellEl = ref<HTMLTableCellElement>()
const store = inject(StoreKey)!;
const cellEl = ref<HTMLTableCellElement>();
const daynum = computed(() => {
if (!props.day) return ''
return props.day.date.substr(8)
})
if (!props.day) return "";
return props.day.date.substr(8);
});
const dateStr = computed(() => props.day ? new Date(props.day.date).toLocaleDateString() : '')
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 role = computed(() =>
!props.day || !props.day.events.length || !props.mobile ? "cell" : "button",
);
const tabindex = computed(() => role.value === 'button' ? '0' : '-1')
const tabindex = computed(() => (role.value === "button" ? "0" : "-1"));
const classObject = computed(() => {
const o: Record<string, boolean> = {}
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
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
if (ev.availability.color === "green") {
best = "green";
if (ev.availability.reason !== "low") {
allLow = false;
}
} else if (ev.availability.color === 'orange' && best !== 'green') {
best = 'orange'
} 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
o[`pretix-widget-day-availability-${best}`] = true;
if (best === "green" && allLow) {
o["pretix-widget-day-availability-low"] = true;
}
}
return o
})
return o;
});
function selectDay (e: Event) {
if (!props.day || !props.day.events.length || !props.mobile) return
e.preventDefault()
e.stopPropagation()
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()
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'
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) {
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)
e.preventDefault();
selectDay(e);
}
}
function attachListeners () {
if (role.value === 'button' && cellEl.value) {
cellEl.value.addEventListener('click', selectDay)
cellEl.value.addEventListener('keydown', onKeyDown)
function attachListeners() {
if (role.value === "button" && cellEl.value) {
cellEl.value.addEventListener("click", selectDay);
cellEl.value.addEventListener("keydown", onKeyDown);
}
}
function detachListeners () {
function detachListeners() {
if (cellEl.value) {
cellEl.value.removeEventListener('click', selectDay)
cellEl.value.removeEventListener('keydown', onKeyDown)
cellEl.value.removeEventListener("click", selectDay);
cellEl.value.removeEventListener("keydown", onKeyDown);
}
}
onMounted(() => {
attachListeners()
})
attachListeners();
});
// TODO why different from old version?
watch(role, (newValue, oldValue) => {
if (newValue === 'button' && oldValue !== 'button') {
attachListeners()
} else if (newValue !== 'button' && oldValue === 'button') {
detachListeners()
if (newValue === "button" && oldValue !== "button") {
attachListeners();
} else if (newValue !== "button" && oldValue === "button") {
detachListeners();
}
})
});
</script>
<template lang="pug">
td(
@@ -116,5 +125,4 @@ td(
.pretix-widget-event-calendar-events(v-if="day")
EventCalendarEvent(v-for="e in day.events", :key="e.event_url+'-'+(e.subevent||'')", :event="e")
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,33 +1,35 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { EventEntry } from '~/types'
import { StoreKey } from '~/sharedStore'
import { computed, inject } from "vue";
import type { EventEntry } from "~/types";
import { StoreKey } from "~/sharedStore";
const props = defineProps<{
event: EventEntry
describedby?: string
}>()
event: EventEntry;
describedby?: string;
}>();
const store = inject(StoreKey)!
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
"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
o[`pretix-widget-event-availability-${props.event.availability.reason}`] =
true;
}
return o
})
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()
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">
@@ -41,5 +43,4 @@ a.pretix-widget-event-calendar-event(
.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>
<style lang="sass"></style>
@@ -1,15 +1,14 @@
<script setup lang="ts">
import type { DayEntry } from '~/types'
import EventCalendarCell from './EventCalendarCell.vue'
import type { DayEntry } from "~/types";
import EventCalendarCell from "./EventCalendarCell.vue";
defineProps<{
week: (DayEntry | null)[]
mobile: boolean
}>()
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>
<style lang="sass"></style>
@@ -1,119 +1,148 @@
<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'
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 store = inject(StoreKey)!;
const form = ref<HTMLFormElement>()
const voucherinput = ref<HTMLInputElement>()
const isItemsSelected = ref(false)
const localVoucher = ref('')
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 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
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 (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 (
(item.variations.length === 0 && item.price.gross !== "0.00") ||
item.mandatory_priced_addons
) {
allFree = false;
break;
}
}
if (!allFree) break
if (!allFree) break;
}
return allFree ? STRINGS.register : STRINGS.buy
})
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 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)
const showVoucherForm = computed(
() => store.vouchersExist && !store.disableVouchers && !store.voucherCode,
);
async function backToList () {
store.targetUrl = store.parentStack.pop() || store.targetUrl
store.error = null
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.name = null;
store.frontpageText = null;
}
store.subevent = null
store.offset = 0
store.appendEvents = false
store.triggerLoadCallback()
store.subevent = null;
store.offset = 0;
store.appendEvents = false;
store.triggerLoadCallback();
if (store.events !== undefined && store.events !== null) {
store.view = 'events'
store.view = "events";
} else if (store.days !== undefined && store.days !== null) {
store.view = 'days'
store.view = "days";
} else {
store.view = 'weeks'
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()
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 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 _focusVoucherField() {
voucherinput.value?.focus();
}
function handleBuy (event: Event) {
function handleBuy(event: Event) {
if (form.value) {
const formData = new FormData(form.value)
store.buy(formData, event)
const formData = new FormData(form.value);
store.buy(formData, event);
}
}
function handleRedeem (event: Event) {
store.redeem(localVoucher.value, event)
function handleRedeem(event: Event) {
store.redeem(localVoucher.value, event);
}
onMounted(() => {
if (form.value) {
form.value.addEventListener('change', calcItemsSelected)
form.value.addEventListener("change", calcItemsSelected);
}
})
});
onBeforeUnmount(() => {
if (form.value) {
form.value.removeEventListener('change', calcItemsSelected)
form.value.removeEventListener("change", calcItemsSelected);
}
})
});
watch(() => store.overlay?.frameShown, (newValue) => {
if (!newValue && form.value) {
form.value.reset()
calcItemsSelected()
}
})
watch(
() => store.overlay?.frameShown,
(newValue) => {
if (!newValue && form.value) {
form.value.reset();
calcItemsSelected();
}
},
);
</script>
<template lang="pug">
@@ -233,5 +262,4 @@ watch(() => store.overlay?.frameShown, (newValue) => {
button(@click="handleRedeem") {{ STRINGS.redeem }}
.pretix-widget-clear
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,44 +1,50 @@
<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'
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 store = inject(StoreKey)!;
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && store.parentStack.length > 0))
const displayEventInfo = computed(
() =>
store.displayEventInfo ||
(store.displayEventInfo === null && store.parentStack.length > 0),
);
async function backToCalendar (event: MouseEvent) {
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()
await nextTick();
const rootEl = (event.target as HTMLElement).closest(
".pretix-widget-wrapper",
) as HTMLElement | null;
rootEl?.focus();
store.offset = 0
store.appendEvents = false
store.offset = 0;
store.appendEvents = false;
if (store.weeks) {
store.events = null
store.view = 'weeks'
store.name = null
store.frontpageText = null
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()
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()
function loadMore() {
store.appendEvents = true;
store.offset += 50;
store.loading++;
store.reload();
}
console.log(store)
console.log(store);
</script>
<template lang="pug">
.pretix-widget-event-list
@@ -67,5 +73,4 @@ console.log(store)
p.pretix-widget-event-list-load-more(v-if="store.hasMoreEvents")
button(@click.prevent.stop="loadMore") {{ STRINGS.load_more }}
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,34 +1,38 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { EventEntry } from '~/types'
import { StoreKey } from '~/sharedStore'
import { computed, inject } from "vue";
import type { EventEntry } from "~/types";
import { StoreKey } from "~/sharedStore";
const props = defineProps<{
event: EventEntry
}>()
event: EventEntry;
}>();
const store = inject(StoreKey)!
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
"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
o[`pretix-widget-event-availability-${props.event.availability.reason}`] =
true;
}
return o
})
return o;
});
const location = computed(() => props.event.location.replace(/\s*\n\s*/g, ', '))
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()
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">
@@ -40,5 +44,4 @@ a.pretix-widget-event-list-entry(href="#", :class="classObject", @click.prevent.
.pretix-widget-event-list-entry-availability
span {{ event.availability.text }}
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,20 +1,20 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { MetaFilterField } from '~/types'
import { StoreKey, globalWidgetId } from '~/sharedStore'
import { computed, inject } from "vue";
import type { MetaFilterField } from "~/types";
import { StoreKey, globalWidgetId } from "~/sharedStore";
const props = defineProps<{
field: MetaFilterField
}>()
field: MetaFilterField;
}>();
const store = inject(StoreKey)!
const store = inject(StoreKey)!;
const id = computed(() => `${globalWidgetId}_${props.field.key}`)
const id = computed(() => `${globalWidgetId}_${props.field.key}`);
const currentValue = computed(() => {
const filterParams = new URLSearchParams(store.filter || '')
return filterParams.get(props.field.key) || ''
})
const filterParams = new URLSearchParams(store.filter || "");
return filterParams.get(props.field.key) || "";
});
</script>
<template lang="pug">
.pretix-widget-event-list-filter-field
@@ -22,5 +22,4 @@ const currentValue = computed(() => {
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>
<style lang="sass"></style>
@@ -1,28 +1,28 @@
<script setup lang="ts">
import { inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import EventListFilterField from './EventListFilterField.vue'
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>()
const store = inject(StoreKey)!;
const filterform = ref<HTMLFormElement>();
function onSubmit (e: Event) {
e.preventDefault()
if (!filterform.value) return
function onSubmit(e: Event) {
e.preventDefault();
if (!filterform.value) return;
const formData = new FormData(filterform.value)
const filterParams = new URLSearchParams()
const formData = new FormData(filterform.value);
const filterParams = new URLSearchParams();
formData.forEach((value, key) => {
if (value !== '') {
filterParams.set(key, value as string)
if (value !== "") {
filterParams.set(key, value as string);
}
})
});
store.filter = filterParams.toString()
store.loading++
store.reload()
store.filter = filterParams.toString();
store.loading++;
store.reload();
}
</script>
<template lang="pug">
@@ -36,5 +36,4 @@ form.pretix-widget-event-list-filter-form(ref="filterform", @submit="onSubmit")
)
button {{ STRINGS.filter }}
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,62 +1,66 @@
<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'
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
}>()
mobile: boolean;
}>();
const store = inject(StoreKey)!
const weekcalendar = ref<HTMLDivElement>()
const store = inject(StoreKey)!;
const weekcalendar = ref<HTMLDivElement>();
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && store.parentStack.length > 0))
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}`
})
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`)
const id = computed(() => `${store.htmlId}-event-week-table`);
function backToList () {
store.weeks = null
store.name = null
store.frontpageText = null
store.view = 'events'
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--
function prevweek() {
if (!store.week) return;
let curWeek = store.week[1];
let curYear = store.week[0];
curWeek--;
if (curWeek < 1) {
curYear--
curWeek = getISOWeeks(curYear)
curYear--;
curWeek = getISOWeeks(curYear);
}
store.week = [curYear, curWeek]
store.loading++
store.reload({ focus: `#${id.value}` })
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++
function nextweek() {
if (!store.week) return;
let curWeek = store.week[1];
let curYear = store.week[0];
curWeek++;
if (curWeek > getISOWeeks(curYear)) {
curWeek = 1
curYear++
curWeek = 1;
curYear++;
}
store.week = [curYear, curWeek]
store.loading++
store.reload({ focus: `#${id.value}` })
store.week = [curYear, curWeek];
store.loading++;
store.reload({ focus: `#${id.value}` });
}
</script>
<template lang="pug">
@@ -93,5 +97,4 @@ function nextweek () {
.pretix-widget-event-week-col(v-for="d in store.days", :key="d?.date || ''")
EventWeekCell(:day="d", :mobile="mobile")
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,62 +1,64 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { DayEntry } from '~/types'
import { StoreKey } from '~/sharedStore'
import EventCalendarEvent from './EventCalendarEvent.vue'
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?
}>()
day: DayEntry | null;
mobile: boolean; // TODO inject?
}>();
const store = inject(StoreKey)!
const store = inject(StoreKey)!;
const id = computed(() => props.day ? `${store.htmlId}-${props.day.date}` : '')
const id = computed(() =>
props.day ? `${store.htmlId}-${props.day.date}` : "",
);
const dayhead = computed(() => {
if (!props.day) return ''
return props.day.day_formatted
})
if (!props.day) return "";
return props.day.day_formatted;
});
const classObject = computed(() => {
const o: Record<string, boolean> = {}
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
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
if (ev.availability.color === "green") {
best = "green";
if (ev.availability.reason !== "low") {
allLow = false;
}
} else if (ev.availability.color === 'orange' && best !== 'green') {
best = 'orange'
} 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
o[`pretix-widget-day-availability-${best}`] = true;
if (best === "green" && allLow) {
o["pretix-widget-day-availability-low"] = true;
}
}
return o
})
return o;
});
function selectDay () {
if (!props.day || !props.day.events.length || !props.mobile) return
function selectDay() {
if (!props.day || !props.day.events.length || !props.mobile) return;
if (props.day.events.length === 1) {
const ev = props.day.events[0]
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()
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'
store.events = props.day.events;
store.view = "events";
}
}
</script>
@@ -71,5 +73,4 @@ div(:class="classObject", @click.prevent.stop="selectDay")
:describedby="id"
)
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,69 +1,83 @@
<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'
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
}>()
item: Item;
category: Category;
}>();
const store = inject(StoreKey)!
const store = inject(StoreKey)!;
const expanded = ref(store.showVariationsExpanded)
const variations = ref<HTMLDivElement>()
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,
}))
"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,
}))
"pretix-widget-item-variations": true,
"pretix-widget-item-variations-expanded": expanded.value,
}));
const pictureAltText = computed(() => interpolate(STRINGS.image_of, [props.item.name]))
const pictureAltText = computed(() =>
interpolate(STRINGS.image_of, [props.item.name]),
);
const headingLevel = computed(() => props.category.name ? '4' : '3')
const headingLevel = computed(() => (props.category.name ? "4" : "3"));
const itemLabelId = computed(() => `${store.htmlId}-item-label-${props.item.id}`)
const itemLabelId = computed(
() => `${store.htmlId}-item-label-${props.item.id}`,
);
const itemDescId = computed(() => `${store.htmlId}-item-desc-${props.item.id}`)
const itemDescId = computed(() => `${store.htmlId}-item-desc-${props.item.id}`);
const itemPriceId = computed(() => `${store.htmlId}-item-price-${props.item.id}`)
const itemPriceId = computed(
() => `${store.htmlId}-item-price-${props.item.id}`,
);
const ariaLabelledby = computed(() => `${itemLabelId.value} ${itemPriceId.value}`)
const ariaLabelledby = computed(
() => `${itemLabelId.value} ${itemPriceId.value}`,
);
const minOrderStr = computed(() => interpolate(STRINGS.order_min, [props.item.order_min]))
const minOrderStr = computed(() =>
interpolate(STRINGS.order_min, [props.item.order_min]),
);
const quotaLeftStr = computed(() => interpolate(STRINGS.quota_left, [props.item.avail[1]]))
const quotaLeftStr = computed(() =>
interpolate(STRINGS.quota_left, [props.item.avail[1]]),
);
const showToggle = computed(() => props.item.has_variations && !store.showVariationsExpanded)
const showToggle = computed(
() => props.item.has_variations && !store.showVariationsExpanded,
);
// TODO dedupe?
const showPrices = computed(() => {
let hasPriced = false
let cntItems = 0
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
cntItems += item.variations.length;
hasPriced = true;
} else {
cntItems++
hasPriced = hasPriced || item.price.gross !== '0.00' || item.free_price
cntItems++;
hasPriced = hasPriced || item.price.gross !== "0.00" || item.free_price;
}
}
}
return hasPriced || cntItems > 1
})
return hasPriced || cntItems > 1;
});
// TODO XSS
const pricerange = computed(() => {
@@ -72,68 +86,73 @@ const pricerange = computed(() => {
STRINGS.price_from,
{
currency: store.currency,
price: floatformat(props.item.min_price || '0', store.currency_places),
price: floatformat(props.item.min_price || "0", store.currency_places),
},
true
true,
).replace(
store.currency,
`<span class="pretix-widget-pricebox-currency">${store.currency}</span>`
)
`<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', store.currency_places)} – ${floatformat(props.item.max_price || '0', store.currency_places)}`
} else if (props.item.min_price === '0.00' && props.item.max_price === '0.00') {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || "0", store.currency_places)} – ${floatformat(props.item.max_price || "0", store.currency_places)}`;
} 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 "\xA0"; // nbsp, because an empty string would cause the HTML element to collapse
}
return STRINGS.free
return STRINGS.free;
} else {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', store.currency_places)}`
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || "0", store.currency_places)}`;
}
})
});
const variationsToggleLabel = computed(() => expanded.value ? STRINGS.hide_variations : STRINGS.variations)
const variationsToggleLabel = computed(() =>
expanded.value ? STRINGS.hide_variations : STRINGS.variations,
);
function expand () {
expanded.value = !expanded.value
function expand() {
expanded.value = !expanded.value;
}
function lightbox () {
function lightbox() {
if (store.overlay) {
store.overlay.lightbox = {
image: props.item.picture_fullsize || '',
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.hidden = true;
variations.value.addEventListener('transitionend', function (event) {
variations.value.addEventListener("transitionend", function (event) {
if (event.target === variations.value) {
if (variations.value) {
variations.value.hidden = !expanded.value
variations.value.style.maxHeight = 'none'
variations.value.hidden = !expanded.value;
variations.value.style.maxHeight = "none";
}
}
})
});
}
})
});
watch(expanded, (newValue) => {
const v = variations.value
if (!v) return
const v = variations.value;
if (!v) return;
v.hidden = false
v.style.maxHeight = `${newValue ? 0 : v.scrollHeight}px`
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)
})
v.style.maxHeight = `${!newValue ? 0 : v.scrollHeight}px`;
}, 50);
});
</script>
<template lang="pug">
div(
@@ -214,5 +233,4 @@ div(
:category="category"
)
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,177 +1,206 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, inject, nextTick } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import {
computed,
ref,
watch,
onMounted,
onUnmounted,
inject,
nextTick,
} from "vue";
import { StoreKey } from "~/sharedStore";
import { STRINGS } from "~/i18n";
const store = inject(StoreKey)!
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 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,
}))
"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,
}))
"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,
}))
"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,
}))
"pretix-widget-visibility-hidden": !cancelBlocked.value,
}));
const errorMessageId = computed(() => `${store.htmlId}-error-message`)
const errorMessageId = computed(() => `${store.htmlId}-error-message`);
function onMessage (e: MessageEvent) {
if (e.data.type && e.data.type === 'pretix:widget:title') {
function onMessage(e: MessageEvent) {
if (e.data.type && e.data.type === "pretix:widget:title") {
if (iframe.value) {
iframe.value.title = e.data.title
iframe.value.title = e.data.title;
}
}
}
function lightboxClose () {
store.overlay.lightbox = null
function lightboxClose() {
store.overlay.lightbox = null;
}
function lightboxLoaded () {
function lightboxLoaded() {
if (store.overlay.lightbox) {
store.overlay.lightbox.loading = false
store.overlay.lightbox.loading = false;
}
}
function errorContinue () {
function errorContinue() {
if (store.overlay.errorUrlAfterNewTab) {
window.open(store.overlay.errorUrlAfter)
window.open(store.overlay.errorUrlAfter);
} else {
store.overlay.frameSrc = store.overlay.errorUrlAfter
store.overlay.frameLoading = true
store.overlay.frameSrc = store.overlay.errorUrlAfter;
store.overlay.frameLoading = true;
}
}
function errorClose () {
store.overlay.errorMessage = null
store.overlay.errorUrlAfter = null
store.overlay.errorUrlAfterNewTab = false
function errorClose() {
store.overlay.errorMessage = null;
store.overlay.errorUrlAfter = null;
store.overlay.errorUrlAfterNewTab = false;
}
function close (e) {
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
cancel(e);
frameDialog.value?.showModal();
return;
}
store.overlay.frameShown = false
store.frameDismissed = true
store.overlay.frameSrc = ''
store.reload()
triggerCloseCallback()
store.overlay.frameShown = false;
store.frameDismissed = true;
store.overlay.frameSrc = "";
store.reload();
triggerCloseCallback();
}
function cancel (e: Event) {
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
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 () {
function iframeLoaded() {
if (store.overlay.frameLoading) {
store.overlay.frameLoading = false
cancelBlocked.value = false
store.overlay.frameLoading = false;
cancelBlocked.value = false;
if (store.overlay.frameSrc) {
store.overlay.frameShown = true
store.overlay.frameShown = true;
}
}
}
function triggerCloseCallback () {
function triggerCloseCallback() {
nextTick(() => {
for (const callback of (window as any).PretixWidget._closed || []) {
callback()
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()
watch(
() => store.overlay.lightbox,
(newValue, oldValue) => {
if (!newValue) return;
if (newValue.image !== oldValue?.image) {
newValue.loading = true;
}
} else {
if (!store.overlay.frameSrc && frameDialog.value?.open) { // finished loading, but no iframe to display => close
frameDialog.value.close()
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)
})
window.addEventListener("message", onMessage, false);
});
onUnmounted(() => {
window.removeEventListener('message', onMessage, false)
})
window.removeEventListener("message", onMessage, false);
});
const vAutofocus = {
mounted: (el) => el.focus()
}
mounted: (el) => el.focus(),
};
</script>
<template lang="pug">
@@ -231,5 +260,4 @@ Teleport(to="body")
)
figcaption(v-if="store.overlay.lightbox.description") {{ store.overlay.lightbox.description }}
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,93 +1,114 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { Price } from '~/types'
import { StoreKey } from '~/sharedStore'
import { STRINGS, interpolate } from '~/i18n'
import { floatformat, autofloatformat, stripHTML } from '~/utils'
import { computed, inject } from "vue";
import type { Price } from "~/types";
import { StoreKey } from "~/sharedStore";
import { STRINGS, interpolate } from "~/i18n";
import { floatformat, autofloatformat, stripHTML } from "~/utils";
const props = defineProps<{
price: Price
freePrice: boolean
fieldName: string
suggestedPrice?: Price | null
originalPrice?: string | null
mandatoryPricedAddons?: boolean
itemId: number
}>()
price: Price;
freePrice: boolean;
fieldName: string;
suggestedPrice?: Price | null;
originalPrice?: string | null;
mandatoryPricedAddons?: boolean;
itemId: number;
}>();
const store = inject(StoreKey)!
const store = inject(StoreKey)!;
const priceBoxId = computed(() => `${store.htmlId}-item-pricebox-${props.itemId}`)
const priceBoxId = computed(
() => `${store.htmlId}-item-pricebox-${props.itemId}`,
);
const priceDescId = computed(() => `${store.htmlId}-item-pricedesc-${props.itemId}`)
const priceDescId = computed(
() => `${store.htmlId}-item-pricedesc-${props.itemId}`,
);
const ariaLabelledby = computed(() => `${store.htmlId}-item-label-${props.itemId} ${priceBoxId.value}`)
const ariaLabelledby = computed(
() => `${store.htmlId}-item-label-${props.itemId} ${priceBoxId.value}`,
);
const displayPrice = computed(() => {
if (store.displayNetPrices) {
return floatformat(props.price.net, store.currency_places)
return floatformat(props.price.net, store.currency_places);
}
return floatformat(props.price.gross, store.currency_places)
})
return floatformat(props.price.gross, store.currency_places);
});
const displayPriceNonlocalized = computed(() => {
if (store.displayNetPrices) {
return parseFloat(props.price.net).toFixed(2)
return parseFloat(props.price.net).toFixed(2);
}
return parseFloat(props.price.gross).toFixed(2)
})
return parseFloat(props.price.gross).toFixed(2);
});
const suggestedPriceNonlocalized = computed(() => {
const price = props.suggestedPrice ?? props.price
const price = props.suggestedPrice ?? props.price;
if (store.displayNetPrices) {
return parseFloat(price.net).toFixed(store.currency_places)
return parseFloat(price.net).toFixed(store.currency_places);
}
return parseFloat(price.gross).toFixed(store.currency_places)
})
return parseFloat(price.gross).toFixed(store.currency_places);
});
// TODO BAD
const originalLine = computed(() => {
if (!props.originalPrice) return ''
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.originalPrice, store.currency_places)}`
})
if (!props.originalPrice) return "";
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.originalPrice, store.currency_places)}`;
});
// TODO BAD
const priceline = computed(() => {
if (props.price.gross === '0.00') {
if (props.price.gross === "0.00") {
if (props.mandatoryPricedAddons && !props.originalPrice) {
return '\u00A0' // nbsp
return "\u00A0"; // nbsp
}
return STRINGS.free
return STRINGS.free;
}
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${displayPrice.value}`
})
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${displayPrice.value}`;
});
const originalPriceAriaLabel = computed(() => interpolate(STRINGS.original_price, [stripHTML(originalLine.value)]))
const originalPriceAriaLabel = computed(() =>
interpolate(STRINGS.original_price, [stripHTML(originalLine.value)]),
);
const newPriceAriaLabel = computed(() => interpolate(STRINGS.new_price, [stripHTML(priceline.value)]))
const newPriceAriaLabel = computed(() =>
interpolate(STRINGS.new_price, [stripHTML(priceline.value)]),
);
const taxline = computed(() => {
if (store.displayNetPrices) {
if (props.price.includes_mixed_tax_rate) {
return STRINGS.tax_plus_mixed
return STRINGS.tax_plus_mixed;
}
return interpolate(STRINGS.tax_plus, {
rate: autofloatformat(props.price.rate, 2),
taxname: props.price.name,
}, true)
return interpolate(
STRINGS.tax_plus,
{
rate: autofloatformat(props.price.rate, 2),
taxname: props.price.name,
},
true,
);
} else {
if (props.price.includes_mixed_tax_rate) {
return STRINGS.tax_incl_mixed
return STRINGS.tax_incl_mixed;
}
return interpolate(STRINGS.tax_incl, {
rate: autofloatformat(props.price.rate, 2),
taxname: props.price.name,
}, true)
return interpolate(
STRINGS.tax_incl,
{
rate: autofloatformat(props.price.rate, 2),
taxname: props.price.name,
},
true,
);
}
})
});
// rate can either be "0.00" or "0" => parseFloat to check
const showTaxline = computed(() => Number.parseFloat(props.price.rate) && Number.parseFloat(props.price.gross))
const showTaxline = computed(
() =>
Number.parseFloat(props.price.rate) && Number.parseFloat(props.price.gross),
);
</script>
<template lang="pug">
.pretix-widget-pricebox
@@ -111,5 +132,4 @@ const showTaxline = computed(() => Number.parseFloat(props.price.rate) && Number
)
small.pretix-widget-pricebox-tax(v-if="showTaxline", :id="priceDescId") {{ taxline }}
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,53 +1,69 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { Variation, Item, Category } from '~/types'
import { StoreKey } from '~/sharedStore'
import { STRINGS, interpolate } from '~/i18n'
import AvailBox from './AvailBox.vue'
import PriceBox from './PriceBox.vue'
import { computed, inject } from "vue";
import type { Variation, Item, Category } from "~/types";
import { StoreKey } from "~/sharedStore";
import { STRINGS, interpolate } from "~/i18n";
import AvailBox from "./AvailBox.vue";
import PriceBox from "./PriceBox.vue";
const props = defineProps<{
variation: Variation
item: Item
category: Category
}>()
variation: Variation;
item: Item;
category: Category;
}>();
const store = inject(StoreKey)!
const store = inject(StoreKey)!;
const origPrice = computed(() => props.variation.original_price || props.item.original_price)
const origPrice = computed(
() => props.variation.original_price || props.item.original_price,
);
const quotaLeftStr = computed(() => interpolate(STRINGS.quota_left, [props.variation.avail[1]]))
const quotaLeftStr = computed(() =>
interpolate(STRINGS.quota_left, [props.variation.avail[1]]),
);
const variationLabelId = computed(() => `${store.htmlId}-variation-label-${props.item.id}-${props.variation.id}`)
const variationLabelId = computed(
() =>
`${store.htmlId}-variation-label-${props.item.id}-${props.variation.id}`,
);
const variationDescId = computed(() => `${store.htmlId}-variation-desc-${props.item.id}-${props.variation.id}`)
const variationDescId = computed(
() => `${store.htmlId}-variation-desc-${props.item.id}-${props.variation.id}`,
);
const variationPriceId = computed(() => `${store.htmlId}-variation-price-${props.item.id}-${props.variation.id}`)
const variationPriceId = computed(
() =>
`${store.htmlId}-variation-price-${props.item.id}-${props.variation.id}`,
);
const ariaLabelledby = computed(() => `${variationLabelId.value} ${variationPriceId.value}`)
const ariaLabelledby = computed(
() => `${variationLabelId.value} ${variationPriceId.value}`,
);
const headingLevel = computed(() => props.category.name ? '5' : '4')
const headingLevel = computed(() => (props.category.name ? "5" : "4"));
const showQuotaLeft = computed(() => props.variation.avail[1] !== null && props.variation.avail[0] === 100)
const showQuotaLeft = computed(
() => props.variation.avail[1] !== null && props.variation.avail[0] === 100,
);
// TODO dedupe?
const showPrices = computed(() => {
// Determine if prices should be shown
let hasPriced = false
let cntItems = 0
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
cntItems += item.variations.length;
hasPriced = true;
} else {
cntItems++
hasPriced = hasPriced || item.price.gross !== '0.00' || item.free_price
cntItems++;
hasPriced = hasPriced || item.price.gross !== "0.00" || item.free_price;
}
}
}
return hasPriced || cntItems > 1
})
return hasPriced || cntItems > 1;
});
</script>
<template lang="pug">
.pretix-widget-variation(
@@ -93,5 +109,4 @@ const showPrices = computed(() => {
.pretix-widget-clear
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
@@ -1,55 +1,58 @@
<script setup lang="ts">
import { computed, inject, ref, onMounted, watch } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import EventForm from './EventForm.vue'
import EventList from './EventList.vue'
import EventCalendar from './EventCalendar.vue'
import EventWeekCalendar from './EventWeekCalendar.vue'
import Overlay from './Overlay.vue'
import { computed, inject, ref, onMounted, watch } from "vue";
import { StoreKey } from "~/sharedStore";
import { STRINGS } from "~/i18n";
import EventForm from "./EventForm.vue";
import EventList from "./EventList.vue";
import EventCalendar from "./EventCalendar.vue";
import EventWeekCalendar from "./EventWeekCalendar.vue";
import Overlay from "./Overlay.vue";
const emit = defineEmits<{
mounted: []
}>()
mounted: [];
}>();
const store = inject(StoreKey)!
const store = inject(StoreKey)!;
const wrapper = ref<HTMLDivElement>()
const formcomp = ref<InstanceType<typeof EventForm>>()
const mobile = ref(false)
const wrapper = ref<HTMLDivElement>();
const formcomp = ref<InstanceType<typeof EventForm>>();
const mobile = ref(false);
const classObject = computed(() => ({
'pretix-widget': true,
'pretix-widget-mobile': mobile.value,
'pretix-widget-use-custom-spinners': true,
}))
"pretix-widget": true,
"pretix-widget-mobile": mobile.value,
"pretix-widget-use-custom-spinners": true,
}));
watch(mobile, (newValue) => {
store.mobile = newValue
})
store.mobile = newValue;
});
onMounted(() => {
if (wrapper.value) {
const resizeObserver = new ResizeObserver((entries) => {
mobile.value = entries[0].contentRect.width <= 800
})
resizeObserver.observe(wrapper.value)
mobile.value = entries[0].contentRect.width <= 800;
});
resizeObserver.observe(wrapper.value);
}
store.reload() // TODO call earlier?
emit('mounted') // TODO where does this go?
})
store.reload(); // TODO call earlier?
emit("mounted"); // TODO where does this go?
});
watch(() => store.view, (_newValue, oldValue) => {
if (oldValue && wrapper.value) {
// always make sure the widget is scrolled to the top
// as we only check top, we do not need to wait for a redraw
const rect = wrapper.value.getBoundingClientRect()
if (rect.top < 0) {
wrapper.value.scrollIntoView()
watch(
() => store.view,
(_newValue, oldValue) => {
if (oldValue && wrapper.value) {
// always make sure the widget is scrolled to the top
// as we only check top, we do not need to wait for a redraw
const rect = wrapper.value.getBoundingClientRect();
if (rect.top < 0) {
wrapper.value.scrollIntoView();
}
}
}
})
},
);
</script>
<template lang="pug">
.pretix-widget-wrapper(ref="wrapper", tabindex="0", role="article", :aria-label="store.name")
@@ -72,5 +75,4 @@ watch(() => store.view, (_newValue, oldValue) => {
Overlay
</template>
<style lang="sass">
</style>
<style lang="sass"></style>
+144 -102
View File
@@ -5,122 +5,164 @@
// with a local context.
interface Django {
pgettext: (context: string, text: string) => string
gettext: (text: string) => string
interpolate: (fmt: string, obj: Record<string, unknown> | unknown[], named?: boolean) => string
get_format: (formatType: string) => string | number
pgettext: (context: string, text: string) => string;
gettext: (text: string) => string;
interpolate: (
fmt: string,
obj: Record<string, unknown> | unknown[],
named?: boolean,
) => string;
get_format: (formatType: string) => string | number;
}
let django: Django
let django: Django;
if (import.meta.env.DEV) {
// TODO this does not actually grab the correct language strings
const modules = import.meta.glob('../../../jsi18n/*/djangojs.js', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
const key = `../../../jsi18n/${LANG}/djangojs.js`
const raw = modules[key]
if (!raw) throw new Error(`Missing i18n module for lang "${LANG}": ${key}`)
const context: { django?: Django } = {}
new Function(raw).call(context)
django = context.django!
const modules = import.meta.glob("../../../jsi18n/*/djangojs.js", {
eager: true,
query: "?raw",
import: "default",
}) as Record<string, string>;
const key = `../../../jsi18n/${LANG}/djangojs.js`;
const raw = modules[key];
if (!raw) throw new Error(`Missing i18n module for lang "${LANG}": ${key}`);
const context: { django?: Django } = {};
new Function(raw).call(context);
django = context.django!;
} else {
django = (globalThis as any).django
django = (globalThis as any).django;
}
export const STRINGS = {
quantity: django.pgettext('widget', 'Quantity'),
quantity_dec: django.pgettext('widget', 'Decrease quantity'),
quantity_inc: django.pgettext('widget', 'Increase quantity'),
filter_events_by: django.pgettext('widget', 'Filter events by'),
filter: django.pgettext('widget', 'Filter'),
price: django.pgettext('widget', 'Price'),
original_price: django.pgettext('widget', 'Original price: %s'),
new_price: django.pgettext('widget', 'New price: %s'),
select: django.pgettext('widget', 'Select'),
select_item: django.pgettext('widget', 'Select %s'),
select_variant: django.pgettext('widget', 'Select variant %s'),
sold_out: django.pgettext('widget', 'Sold out'),
buy: django.pgettext('widget', 'Buy'),
register: django.pgettext('widget', 'Register'),
reserved: django.pgettext('widget', 'Reserved'),
free: django.pgettext('widget', 'FREE'),
price_from: django.pgettext('widget', 'from %(currency)s %(price)s'),
image_of: django.pgettext('widget', 'Image of %s'),
tax_incl: django.pgettext('widget', 'incl. %(rate)s% %(taxname)s'),
tax_plus: django.pgettext('widget', 'plus %(rate)s% %(taxname)s'),
tax_incl_mixed: django.pgettext('widget', 'incl. taxes'),
tax_plus_mixed: django.pgettext('widget', 'plus taxes'),
quota_left: django.pgettext('widget', 'currently available: %s'),
unavailable_require_voucher: django.pgettext('widget', 'Only available with a voucher'),
unavailable_available_from: django.pgettext('widget', 'Not yet available'),
unavailable_available_until: django.pgettext('widget', 'Not available anymore'),
unavailable_active: django.pgettext('widget', 'Currently not available'),
unavailable_hidden_if_item_available: django.pgettext('widget', 'Not yet available'),
order_min: django.pgettext('widget', 'minimum amount to order: %s'),
exit: django.pgettext('widget', 'Close ticket shop'),
loading_error: django.pgettext('widget', 'The ticket shop could not be loaded.'),
loading_error_429: django.pgettext('widget', 'There are currently a lot of users in this ticket shop. Please open the shop in a new tab to continue.'),
open_new_tab: django.pgettext('widget', 'Open ticket shop'),
checkout: django.pgettext('widget', 'Checkout'),
cart_error: django.pgettext('widget', 'The cart could not be created. Please try again later'),
cart_error_429: django.pgettext('widget', 'We could not create your cart, since there are currently too many users in this ticket shop. Please click "Continue" to retry in a new tab.'),
waiting_list: django.pgettext('widget', 'Waiting list'),
cart_exists: django.pgettext('widget', 'You currently have an active cart for this event. If you select more products, they will be added to your existing cart.'),
resume_checkout: django.pgettext('widget', 'Resume checkout'),
redeem_voucher: django.pgettext('widget', 'Redeem a voucher'),
redeem: django.pgettext('widget', 'Redeem'),
voucher_code: django.pgettext('widget', 'Voucher code'),
close: django.pgettext('widget', 'Close'),
close_checkout: django.pgettext('widget', 'Close checkout'),
cancel_blocked: django.pgettext('widget', 'You cannot cancel this operation. Please wait for loading to finish.'),
continue: django.pgettext('widget', 'Continue'),
variations: django.pgettext('widget', 'Show variants'),
hide_variations: django.pgettext('widget', 'Hide variants'),
back_to_list: django.pgettext('widget', 'Choose a different event'),
back_to_dates: django.pgettext('widget', 'Choose a different date'),
back: django.pgettext('widget', 'Back'),
next_month: django.pgettext('widget', 'Next month'),
previous_month: django.pgettext('widget', 'Previous month'),
next_week: django.pgettext('widget', 'Next week'),
previous_week: django.pgettext('widget', 'Previous week'),
show_seating: django.pgettext('widget', 'Open seat selection'),
seating_plan_waiting_list: django.pgettext('widget', 'Some or all ticket categories are currently sold out. If you want, you can add yourself to the waiting list. We will then notify if seats are available again.'),
load_more: django.pgettext('widget', 'Load more'),
quantity: django.pgettext("widget", "Quantity"),
quantity_dec: django.pgettext("widget", "Decrease quantity"),
quantity_inc: django.pgettext("widget", "Increase quantity"),
filter_events_by: django.pgettext("widget", "Filter events by"),
filter: django.pgettext("widget", "Filter"),
price: django.pgettext("widget", "Price"),
original_price: django.pgettext("widget", "Original price: %s"),
new_price: django.pgettext("widget", "New price: %s"),
select: django.pgettext("widget", "Select"),
select_item: django.pgettext("widget", "Select %s"),
select_variant: django.pgettext("widget", "Select variant %s"),
sold_out: django.pgettext("widget", "Sold out"),
buy: django.pgettext("widget", "Buy"),
register: django.pgettext("widget", "Register"),
reserved: django.pgettext("widget", "Reserved"),
free: django.pgettext("widget", "FREE"),
price_from: django.pgettext("widget", "from %(currency)s %(price)s"),
image_of: django.pgettext("widget", "Image of %s"),
tax_incl: django.pgettext("widget", "incl. %(rate)s% %(taxname)s"),
tax_plus: django.pgettext("widget", "plus %(rate)s% %(taxname)s"),
tax_incl_mixed: django.pgettext("widget", "incl. taxes"),
tax_plus_mixed: django.pgettext("widget", "plus taxes"),
quota_left: django.pgettext("widget", "currently available: %s"),
unavailable_require_voucher: django.pgettext(
"widget",
"Only available with a voucher",
),
unavailable_available_from: django.pgettext("widget", "Not yet available"),
unavailable_available_until: django.pgettext(
"widget",
"Not available anymore",
),
unavailable_active: django.pgettext("widget", "Currently not available"),
unavailable_hidden_if_item_available: django.pgettext(
"widget",
"Not yet available",
),
order_min: django.pgettext("widget", "minimum amount to order: %s"),
exit: django.pgettext("widget", "Close ticket shop"),
loading_error: django.pgettext(
"widget",
"The ticket shop could not be loaded.",
),
loading_error_429: django.pgettext(
"widget",
"There are currently a lot of users in this ticket shop. Please open the shop in a new tab to continue.",
),
open_new_tab: django.pgettext("widget", "Open ticket shop"),
checkout: django.pgettext("widget", "Checkout"),
cart_error: django.pgettext(
"widget",
"The cart could not be created. Please try again later",
),
cart_error_429: django.pgettext(
"widget",
'We could not create your cart, since there are currently too many users in this ticket shop. Please click "Continue" to retry in a new tab.',
),
waiting_list: django.pgettext("widget", "Waiting list"),
cart_exists: django.pgettext(
"widget",
"You currently have an active cart for this event. If you select more products, they will be added to your existing cart.",
),
resume_checkout: django.pgettext("widget", "Resume checkout"),
redeem_voucher: django.pgettext("widget", "Redeem a voucher"),
redeem: django.pgettext("widget", "Redeem"),
voucher_code: django.pgettext("widget", "Voucher code"),
close: django.pgettext("widget", "Close"),
close_checkout: django.pgettext("widget", "Close checkout"),
cancel_blocked: django.pgettext(
"widget",
"You cannot cancel this operation. Please wait for loading to finish.",
),
continue: django.pgettext("widget", "Continue"),
variations: django.pgettext("widget", "Show variants"),
hide_variations: django.pgettext("widget", "Hide variants"),
back_to_list: django.pgettext("widget", "Choose a different event"),
back_to_dates: django.pgettext("widget", "Choose a different date"),
back: django.pgettext("widget", "Back"),
next_month: django.pgettext("widget", "Next month"),
previous_month: django.pgettext("widget", "Previous month"),
next_week: django.pgettext("widget", "Next week"),
previous_week: django.pgettext("widget", "Previous week"),
show_seating: django.pgettext("widget", "Open seat selection"),
seating_plan_waiting_list: django.pgettext(
"widget",
"Some or all ticket categories are currently sold out. If you want, you can add yourself to the waiting list. We will then notify if seats are available again.",
),
load_more: django.pgettext("widget", "Load more"),
days: {
MO: django.gettext('Mo'),
TU: django.gettext('Tu'),
WE: django.gettext('We'),
TH: django.gettext('Th'),
FR: django.gettext('Fr'),
SA: django.gettext('Sa'),
SU: django.gettext('Su'),
MONDAY: django.gettext('Monday'),
TUESDAY: django.gettext('Tuesday'),
WEDNESDAY: django.gettext('Wednesday'),
THURSDAY: django.gettext('Thursday'),
FRIDAY: django.gettext('Friday'),
SATURDAY: django.gettext('Saturday'),
SUNDAY: django.gettext('Sunday'),
MO: django.gettext("Mo"),
TU: django.gettext("Tu"),
WE: django.gettext("We"),
TH: django.gettext("Th"),
FR: django.gettext("Fr"),
SA: django.gettext("Sa"),
SU: django.gettext("Su"),
MONDAY: django.gettext("Monday"),
TUESDAY: django.gettext("Tuesday"),
WEDNESDAY: django.gettext("Wednesday"),
THURSDAY: django.gettext("Thursday"),
FRIDAY: django.gettext("Friday"),
SATURDAY: django.gettext("Saturday"),
SUNDAY: django.gettext("Sunday"),
},
months: {
'01': django.gettext('January'),
'02': django.gettext('February'),
'03': django.gettext('March'),
'04': django.gettext('April'),
'05': django.gettext('May'),
'06': django.gettext('June'),
'07': django.gettext('July'),
'08': django.gettext('August'),
'09': django.gettext('September'),
10: django.gettext('October'),
11: django.gettext('November'),
12: django.gettext('December'),
"01": django.gettext("January"),
"02": django.gettext("February"),
"03": django.gettext("March"),
"04": django.gettext("April"),
"05": django.gettext("May"),
"06": django.gettext("June"),
"07": django.gettext("July"),
"08": django.gettext("August"),
"09": django.gettext("September"),
10: django.gettext("October"),
11: django.gettext("November"),
12: django.gettext("December"),
} as Record<string, string>,
} as const
} as const;
export function interpolate (fmt: string, obj: Record<string, unknown> | unknown[], named = false): string {
return django.interpolate(fmt, obj, named)
export function interpolate(
fmt: string,
obj: Record<string, unknown> | unknown[],
named = false,
): string {
return django.interpolate(fmt, obj, named);
}
export function getFormat (formatType: string): string | number {
return django.get_format(formatType)
export function getFormat(formatType: string): string | number {
return django.get_format(formatType);
}
@@ -1,80 +1,96 @@
import { reactive, computed, watch } from 'vue'
import type { WatchCallback, WatchOptions, UnwrapNestedRefs } from 'vue'
import { reactive, computed, watch } from "vue";
import type { WatchCallback, WatchOptions, UnwrapNestedRefs } from "vue";
interface StoreMethods {
$reset: () => void
$watch: <T>(source: () => T, callback: WatchCallback<T>, options?: WatchOptions) => void
$reset: () => void;
$watch: <T>(
source: () => T,
callback: WatchCallback<T>,
options?: WatchOptions,
) => void;
}
type GetterReturnTypes<G> = {
readonly [K in keyof G]: G[K] extends () => infer R ? R : never
}
readonly [K in keyof G]: G[K] extends () => infer R ? R : never;
};
type Store<S, G, A> = UnwrapNestedRefs<S> & GetterReturnTypes<G> & A & StoreMethods
type GettersTree<S> = Record<string, (this: S, state: S) => any> | Record<string, () => any>
type ActionsTree = Record<string, (...args: any[]) => any>
type Store<S, G, A> = UnwrapNestedRefs<S> &
GetterReturnTypes<G> &
A &
StoreMethods;
type GettersTree<S> =
Record<string, (this: S, state: S) => any> | Record<string, () => any>;
type ActionsTree = Record<string, (...args: any[]) => any>;
export function createStore<
S extends object,
G extends GettersTree<S>,
A extends ActionsTree
> (
A extends ActionsTree,
>(
// name: string,
config: {
state: () => S
getters?: G & ThisType<UnwrapNestedRefs<S> & GetterReturnTypes<G> & A & StoreMethods>
actions?: A & ThisType<UnwrapNestedRefs<S> & GetterReturnTypes<G> & A & StoreMethods>
}
state: () => S;
getters?: G &
ThisType<UnwrapNestedRefs<S> & GetterReturnTypes<G> & A & StoreMethods>;
actions?: A &
ThisType<UnwrapNestedRefs<S> & GetterReturnTypes<G> & A & StoreMethods>;
},
): Store<S, G, A> {
type StoreType = Store<S, G, A>
const store = reactive(config.state()) as StoreType
type StoreType = Store<S, G, A>;
const store = reactive(config.state()) as StoreType;
// Add getters as computed properties
if (config.getters) {
for (const key of Object.keys(config.getters) as (keyof G)[]) {
const getter = config.getters[key]
const computedRef = computed(() => (getter as () => unknown).call(store))
const getter = config.getters[key];
const computedRef = computed(() => (getter as () => unknown).call(store));
Object.defineProperty(store, key, {
get: () => computedRef.value,
enumerable: true
})
enumerable: true,
});
}
}
// Add actions bound to the store
if (config.actions) {
for (const key of Object.keys(config.actions) as (keyof A)[]) {
const action = config.actions[key]
;(store as Record<string, unknown>)[key as string] = (action as (...args: unknown[]) => unknown).bind(store)
const action = config.actions[key];
(store as Record<string, unknown>)[key as string] = (
action as (...args: unknown[]) => unknown
).bind(store);
}
}
store.$reset = function () {
const cleanState = config.state()
const cleanState = config.state();
const cleanKeys = new Set([
'$reset',
'$watch',
"$reset",
"$watch",
...Object.keys(cleanState),
...Object.keys(config.getters ?? {}),
...Object.keys(config.actions ?? {})
])
...Object.keys(config.actions ?? {}),
]);
// Delete any keys that aren't in clean state and aren't known non-state keys
for (const key of Object.keys(store)) {
if (!cleanKeys.has(key)) {
delete (store as Record<string, unknown>)[key]
delete (store as Record<string, unknown>)[key];
}
}
// Set all state values from clean state
for (const key of Object.keys(cleanState) as (keyof S)[]) {
;(store as S)[key] = cleanState[key]
(store as S)[key] = cleanState[key];
}
}
};
store.$watch = function <T>(source: () => T, callback: WatchCallback<T>, options?: WatchOptions) {
watch(source.bind(store), callback.bind(store), options)
}
store.$watch = function <T>(
source: () => T,
callback: WatchCallback<T>,
options?: WatchOptions,
) {
watch(source.bind(store), callback.bind(store), options);
};
return store
return store;
}
@@ -1,22 +1,22 @@
import { createApp, nextTick, type App } from 'vue'
import { createWidgetInstance } from '~/widget'
import { createButtonInstance } from '~/button'
import { createWidgetStore, StoreKey } from '~/sharedStore'
import ButtonComponent from '~/components/Button.vue'
import { docReady, makeid } from '~/utils'
import type { WidgetData } from '~/types'
import { createApp, nextTick, type App } from "vue";
import { createWidgetInstance } from "~/widget";
import { createButtonInstance } from "~/button";
import { createWidgetStore, StoreKey } from "~/sharedStore";
import ButtonComponent from "~/components/Button.vue";
import { docReady, makeid } from "~/utils";
import type { WidgetData } from "~/types";
declare global {
interface Window {
PretixWidget: PretixWidgetAPI
pretixWidgetCallback?: () => void
PretixWidget: PretixWidgetAPI;
pretixWidgetCallback?: () => void;
}
}
interface PretixWidgetAPI {
build_widgets: boolean
widget_data: WidgetData
buildWidgets: () => void
build_widgets: boolean;
widget_data: WidgetData;
buildWidgets: () => void;
open: (
targetUrl: string,
voucher?: string | null,
@@ -24,16 +24,16 @@ interface PretixWidgetAPI {
items?: { item: string; count: string }[],
widgetData?: Record<string, string>,
skipSslCheck?: boolean,
disableIframe?: boolean
) => void
addLoadListener: (callback: () => void) => void
addCloseListener: (callback: () => void) => void
_loaded: Array<() => void>
_closed: Array<() => void>
disableIframe?: boolean,
) => void;
addLoadListener: (callback: () => void) => void;
addCloseListener: (callback: () => void) => void;
_loaded: Array<() => void>;
_closed: Array<() => void>;
}
const widgetlist: App[] = []
const buttonlist: App[] = []
const widgetlist: App[] = [];
const buttonlist: App[] = [];
window.PretixWidget = {
build_widgets: true,
@@ -43,43 +43,53 @@ window.PretixWidget = {
_closed: [],
buildWidgets,
open: openWidget,
addLoadListener (f) { this._loaded.push(f) },
addCloseListener (f) { this._closed.push(f) },
}
addLoadListener(f) {
this._loaded.push(f);
},
addCloseListener(f) {
this._closed.push(f);
},
};
async function buildWidgets () {
await docReady()
const widgetElements = document.querySelectorAll('pretix-widget, div.pretix-widget-compat')
async function buildWidgets() {
await docReady();
const widgetElements = document.querySelectorAll(
"pretix-widget, div.pretix-widget-compat",
);
for (const [i, el] of Array.from(widgetElements).entries()) {
widgetlist.push(createWidgetInstance(el, el.id || `pretix-widget-${i}`))
widgetlist.push(createWidgetInstance(el, el.id || `pretix-widget-${i}`));
}
const buttonElements = document.querySelectorAll('pretix-button, div.pretix-button-compat')
const buttonElements = document.querySelectorAll(
"pretix-button, div.pretix-button-compat",
);
for (const [i, el] of Array.from(buttonElements).entries()) {
buttonlist.push(createButtonInstance(el, el.id || `pretix-button-${i}`))
buttonlist.push(createButtonInstance(el, el.id || `pretix-button-${i}`));
}
}
function openWidget (
function openWidget(
targetUrl: string,
voucher?: string | null,
subevent?: string | number | null,
items?: { item: string; count: string }[],
widgetData?: Record<string, string>,
skipSslCheck?: boolean,
disableIframe?: boolean
disableIframe?: boolean,
): void {
if (!targetUrl.match(/\/$/)) {
targetUrl += '/'
targetUrl += "/";
}
const allWidgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data))
const allWidgetData: WidgetData = JSON.parse(
JSON.stringify(window.PretixWidget.widget_data),
);
if (widgetData) {
Object.assign(allWidgetData, widgetData)
Object.assign(allWidgetData, widgetData);
}
const root = document.createElement('div')
document.body.appendChild(root)
root.classList.add('pretix-widget-hidden')
const root = document.createElement("div");
document.body.appendChild(root);
root.classList.add("pretix-widget-hidden");
const store = createWidgetStore({
targetUrl,
@@ -91,37 +101,37 @@ function openWidget (
htmlId: makeid(16),
isButton: true,
buttonItems: items ?? [],
buttonText: '',
keepCart: true
})
buttonText: "",
keepCart: true,
});
const app = createApp(ButtonComponent)
app.provide(StoreKey, store)
const app = createApp(ButtonComponent);
app.provide(StoreKey, store);
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-widget-open]', info, error)
}
app.mount(root)
console.error("[pretix-widget-open]", info, error);
};
app.mount(root);
nextTick(() => {
if (store.useIframe) {
const form = root.querySelector('form') as HTMLFormElement
const form = root.querySelector("form") as HTMLFormElement;
if (form) {
const formData = new FormData(form)
store.buy(formData)
const formData = new FormData(form);
store.buy(formData);
}
} else {
const form = root.querySelector('form') as HTMLFormElement
if (form) form.submit()
const form = root.querySelector("form") as HTMLFormElement;
if (form) form.submit();
}
})
});
}
if (typeof window.pretixWidgetCallback !== 'undefined') {
window.pretixWidgetCallback()
if (typeof window.pretixWidgetCallback !== "undefined") {
window.pretixWidgetCallback();
}
if (window.PretixWidget.build_widgets) {
window.PretixWidget.buildWidgets()
window.PretixWidget.buildWidgets();
}
// TODO debug exposes
@@ -1,64 +1,77 @@
import { nextTick, type InjectionKey } from 'vue'
import { createStore } from '~/lib/store'
import { fetchProductList, submitCart, checkAsyncTask, ApiError, createCart } from '~/api'
import type { CartResponse } from '~/api'
import { STRINGS } from '~/i18n'
import { setCookie, getCookie, makeid, siteIsSecure } from '~/utils'
import type { Category, DayEntry, EventEntry, LightboxState, MetaFilterField, WidgetData } from '~/types'
import { nextTick, type InjectionKey } from "vue";
import { createStore } from "~/lib/store";
import {
fetchProductList,
submitCart,
checkAsyncTask,
ApiError,
createCart,
} from "~/api";
import type { CartResponse } from "~/api";
import { STRINGS } from "~/i18n";
import { setCookie, getCookie, makeid, siteIsSecure } from "~/utils";
import type {
Category,
DayEntry,
EventEntry,
LightboxState,
MetaFilterField,
WidgetData,
} from "~/types";
export const globalWidgetId = makeid(16)
export const globalWidgetId = makeid(16);
export type WidgetStore = ReturnType<typeof createWidgetStore>
export const StoreKey: InjectionKey<WidgetStore> = Symbol('WidgetStore')
export type WidgetStore = ReturnType<typeof createWidgetStore>;
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
export function createWidgetStore (config: {
targetUrl: string
isButton?: boolean
voucher?: string | null
subevent?: string | number | null
listType?: string | null
skipSsl?: boolean
disableIframe?: boolean
disableVouchers?: boolean
disableFilters?: boolean
displayEventInfo?: boolean | null
filter?: string | null
items?: string | null
categories?: string | null
variations?: string | null
widgetData: WidgetData
htmlId: string
keepCart: boolean
export function createWidgetStore(config: {
targetUrl: string;
isButton?: boolean;
voucher?: string | null;
subevent?: string | number | null;
listType?: string | null;
skipSsl?: boolean;
disableIframe?: boolean;
disableVouchers?: boolean;
disableFilters?: boolean;
displayEventInfo?: boolean | null;
filter?: string | null;
items?: string | null;
categories?: string | null;
variations?: string | null;
widgetData: WidgetData;
htmlId: string;
keepCart: boolean;
// Button-specific
buttonItems?: { item: string; count: string }[]
buttonText?: string
buttonItems?: { item: string; count: string }[];
buttonText?: string;
}) {
return createStore({
state: () => ({
// Target/URL state
targetUrl: config.targetUrl,
parentStack: [] as string[],
subevent: config.subevent ?? null as string | number | null,
subevent: config.subevent ?? (null as string | number | null),
// Configuration
voucherCode: config.voucher ?? null as string | null,
voucherCode: config.voucher ?? (null as string | null),
skipSsl: config.skipSsl ?? false,
disableIframe: config.disableIframe ?? false,
disableVouchers: config.disableVouchers ?? false,
disableFilters: config.disableFilters ?? false,
displayEventInfo: config.displayEventInfo ?? null as boolean | null,
filter: config.filter ?? null as string | null,
itemFilter: config.items ?? null as string | null,
categoryFilter: config.categories ?? null as string | null,
variationFilter: config.variations ?? null as string | null,
style: config.listType ?? null as string | null,
displayEventInfo: config.displayEventInfo ?? (null as boolean | null),
filter: config.filter ?? (null as string | null),
itemFilter: config.items ?? (null as string | null),
categoryFilter: config.categories ?? (null as string | null),
variationFilter: config.variations ?? (null as string | null),
style: config.listType ?? (null as string | null),
widgetData: config.widgetData,
widgetId: `pretix-widget-${globalWidgetId}`,
htmlId: config.htmlId,
keepCart: config.keepCart,
// View state
view: null as 'event' | 'events' | 'weeks' | 'days' | null,
view: null as "event" | "events" | "weeks" | "days" | null,
loading: config.isButton ? 0 : 1,
error: null as string | null,
connectionError: false,
@@ -70,7 +83,7 @@ export function createWidgetStore (config: {
location: null as string | null,
frontpageText: null as string | null,
categories: [] as Category[],
currency: '',
currency: "",
currency_places: 2,
displayNetPrices: false,
voucherExplanationText: null as string | null,
@@ -83,7 +96,7 @@ export function createWidgetStore (config: {
hasSeatingPlan: false,
hasSeatingPlanWaitinglist: false,
itemnum: 0,
poweredby: '',
poweredby: "",
// Calendar/list data
events: null as EventEntry[] | null,
@@ -103,11 +116,11 @@ export function createWidgetStore (config: {
// Button-specific
isButton: config.isButton ?? false,
items: (config.buttonItems ?? []) as { item: string; count: string }[],
buttonText: config.buttonText ?? '',
buttonText: config.buttonText ?? "",
// Overlay (always initialized, no null guards)
overlay: {
frameSrc: '',
frameSrc: "",
frameLoading: false,
frameShown: false,
errorMessage: null as string | null,
@@ -125,456 +138,490 @@ export function createWidgetStore (config: {
}),
getters: {
useIframe (): boolean {
if ((window as any).crossOriginIsolated === true) return false
return !this.disableIframe && (this.skipSsl || siteIsSecure())
useIframe(): boolean {
if ((window as any).crossOriginIsolated === true) return false;
return !this.disableIframe && (this.skipSsl || siteIsSecure());
},
cookieName (): string {
return `pretix_widget_${this.targetUrl.replace(/[^a-zA-Z0-9]+/g, '_')}`
cookieName(): string {
return `pretix_widget_${this.targetUrl.replace(/[^a-zA-Z0-9]+/g, "_")}`;
},
cartId (): string | null {
cartId(): string | null {
if (this._cartId) {
return this._cartId
return this._cartId;
}
if (this.keepCart) {
return getCookie(this.cookieName) ?? null
return getCookie(this.cookieName) ?? null;
}
},
widgetDataJson (): string {
const cloned = { ...this.widgetData }
delete cloned.consent
return JSON.stringify(cloned)
widgetDataJson(): string {
const cloned = { ...this.widgetData };
delete cloned.consent;
return JSON.stringify(cloned);
},
consentParameter (): string {
consentParameter(): string {
if (this.widgetData.consent) {
return `&consent=${encodeURIComponent(this.widgetData.consent)}`
return `&consent=${encodeURIComponent(this.widgetData.consent)}`;
}
return ''
return "";
},
additionalURLParams (): string {
if (!window.location.search.includes('utm_')) {
return ''
additionalURLParams(): string {
if (!window.location.search.includes("utm_")) {
return "";
}
const params = new URLSearchParams(window.location.search)
const params = new URLSearchParams(window.location.search);
for (const [key] of params.entries()) {
if (!key.startsWith('utm_')) {
params.delete(key)
if (!key.startsWith("utm_")) {
params.delete(key);
}
}
return params.toString()
return params.toString();
},
newTabTarget (): string {
let url = this.subevent ? `${this.targetUrl}${this.subevent}/` : this.targetUrl
let parameters = this.consentParameter
newTabTarget(): string {
let url = this.subevent
? `${this.targetUrl}${this.subevent}/`
: this.targetUrl;
let parameters = this.consentParameter;
if (this.additionalURLParams) {
parameters += `&${this.additionalURLParams}`
parameters += `&${this.additionalURLParams}`;
}
if (parameters) {
url += '?' + parameters.replace(/^&/, '')
url += "?" + parameters.replace(/^&/, "");
}
return url
return url;
},
formTarget (): string {
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
const isAndroid = navigator.userAgent.toLowerCase().includes('android')
formTarget(): string {
const isFirefox = navigator.userAgent.toLowerCase().includes("firefox");
const isAndroid = navigator.userAgent.toLowerCase().includes("android");
if (isAndroid && isFirefox) {
return '_top'
return "_top";
}
return '_blank'
return "_blank";
},
consentParameterValue (): string {
consentParameterValue(): string {
if (this.widgetData.consent) {
return encodeURIComponent(this.widgetData.consent)
return encodeURIComponent(this.widgetData.consent);
}
return ''
return "";
},
formAction (): string {
formAction(): string {
if (!this.useIframe && this.isButton && this.items.length === 0) {
if (this.voucherCode) return `${this.targetUrl}redeem`
if (this.subevent) return `${this.targetUrl}${this.subevent}/`
return this.targetUrl
if (this.voucherCode) return `${this.targetUrl}redeem`;
if (this.subevent) return `${this.targetUrl}${this.subevent}/`;
return this.targetUrl;
}
let checkoutUrl = `/${this.targetUrl.replace(/^[^/]+:\/\/([^/]+)\//, '')}w/${globalWidgetId}/`
let checkoutUrl = `/${this.targetUrl.replace(/^[^/]+:\/\/([^/]+)\//, "")}w/${globalWidgetId}/`;
if (!this.cartExists) {
checkoutUrl += 'checkout/start'
checkoutUrl += "checkout/start";
}
if (this.additionalURLParams) {
checkoutUrl += `?${this.additionalURLParams}`
checkoutUrl += `?${this.additionalURLParams}`;
}
let formTarget = `${this.targetUrl}w/${globalWidgetId}/cart/add?iframe=1&next=${encodeURIComponent(checkoutUrl)}`
let formTarget = `${this.targetUrl}w/${globalWidgetId}/cart/add?iframe=1&next=${encodeURIComponent(checkoutUrl)}`;
if (this.cartId) {
formTarget += `&take_cart_id=${this.cartId}`
formTarget += `&take_cart_id=${this.cartId}`;
}
formTarget += this.consentParameter
return formTarget
}
formTarget += this.consentParameter;
return formTarget;
},
},
actions: {
triggerLoadCallback () {
triggerLoadCallback() {
nextTick(() => {
for (const callback of (window as any).PretixWidget._loaded || []) {
callback()
callback();
}
})
});
},
async reload (opt: { focus?: string } = {}) {
if (this.isButton) return
async reload(opt: { focus?: string } = {}) {
if (this.isButton) return;
let url: string
let url: string;
if (this.subevent) {
url = `${this.targetUrl}${this.subevent}/widget/product_list?lang=${LANG}`
url = `${this.targetUrl}${this.subevent}/widget/product_list?lang=${LANG}`;
} else {
url = `${this.targetUrl}widget/product_list?lang=${LANG}`
url = `${this.targetUrl}widget/product_list?lang=${LANG}`;
}
if (this.offset) url += `&offset=${this.offset}`
if (this.filter) url += `&${this.filter}`
if (this.itemFilter) url += `&items=${encodeURIComponent(this.itemFilter)}`
if (this.categoryFilter) url += `&categories=${encodeURIComponent(this.categoryFilter)}`
if (this.variationFilter) url += `&variations=${encodeURIComponent(this.variationFilter)}`
if (this.voucherCode) url += `&voucher=${encodeURIComponent(this.voucherCode)}`
if (this.offset) url += `&offset=${this.offset}`;
if (this.filter) url += `&${this.filter}`;
if (this.itemFilter)
url += `&items=${encodeURIComponent(this.itemFilter)}`;
if (this.categoryFilter)
url += `&categories=${encodeURIComponent(this.categoryFilter)}`;
if (this.variationFilter)
url += `&variations=${encodeURIComponent(this.variationFilter)}`;
if (this.voucherCode)
url += `&voucher=${encodeURIComponent(this.voucherCode)}`;
if (this.cartId) url += `&cart_id=${encodeURIComponent(this.cartId)}`
if (this.cartId) url += `&cart_id=${encodeURIComponent(this.cartId)}`;
if (this.date !== null) {
url += `&date=${this.date.substring(0, 7)}`
url += `&date=${this.date.substring(0, 7)}`;
} else if (this.week !== null) {
url += `&date=${this.week[0]}-W${this.week[1]}`
url += `&date=${this.week[0]}-W${this.week[1]}`;
}
if (this.style !== null) url += `&style=${encodeURIComponent(this.style)}`
if (this.style !== null)
url += `&style=${encodeURIComponent(this.style)}`;
try {
const { data, responseUrl } = await fetchProductList(url)
const { data, responseUrl } = await fetchProductList(url);
// Check for redirect
const newUrl = responseUrl.substring(0, responseUrl.indexOf('/widget/product_list?') + 1)
const oldUrl = url.substring(0, url.indexOf('/widget/product_list?') + 1)
const newUrl = responseUrl.substring(
0,
responseUrl.indexOf("/widget/product_list?") + 1,
);
const oldUrl = url.substring(
0,
url.indexOf("/widget/product_list?") + 1,
);
if (newUrl !== oldUrl) {
let adjustedUrl = newUrl
let adjustedUrl = newUrl;
if (this.subevent) {
adjustedUrl = adjustedUrl.substring(0, adjustedUrl.lastIndexOf('/', adjustedUrl.length - 1) + 1)
adjustedUrl = adjustedUrl.substring(
0,
adjustedUrl.lastIndexOf("/", adjustedUrl.length - 1) + 1,
);
}
this.targetUrl = adjustedUrl
this.reload()
return
this.targetUrl = adjustedUrl;
this.reload();
return;
}
this.connectionError = false
this.connectionError = false;
if (data.weeks !== undefined) {
this.weeks = data.weeks
this.date = data.date ?? null
this.week = null
this.events = null
this.view = 'weeks'
this.name = data.name ?? null
this.frontpageText = data.frontpage_text ?? null
this.metaFilterFields = data.meta_filter_fields ?? []
this.emptyText = data.empty_text ?? null
this.weeks = data.weeks;
this.date = data.date ?? null;
this.week = null;
this.events = null;
this.view = "weeks";
this.name = data.name ?? null;
this.frontpageText = data.frontpage_text ?? null;
this.metaFilterFields = data.meta_filter_fields ?? [];
this.emptyText = data.empty_text ?? null;
} else if (data.days !== undefined) {
this.days = data.days
this.date = null
this.week = data.week ?? null
this.events = null
this.view = 'days'
this.name = data.name ?? null
this.frontpageText = data.frontpage_text ?? null
this.metaFilterFields = data.meta_filter_fields ?? []
this.emptyText = data.empty_text ?? null
this.days = data.days;
this.date = null;
this.week = data.week ?? null;
this.events = null;
this.view = "days";
this.name = data.name ?? null;
this.frontpageText = data.frontpage_text ?? null;
this.metaFilterFields = data.meta_filter_fields ?? [];
this.emptyText = data.empty_text ?? null;
} else if (data.events !== undefined) {
this.events = this.appendEvents && this.events
? this.events.concat(data.events)
: data.events
this.appendEvents = false
this.weeks = null
this.view = 'events'
this.name = data.name ?? null
this.frontpageText = data.frontpage_text ?? null
this.hasMoreEvents = data.has_more_events ?? false
this.metaFilterFields = data.meta_filter_fields ?? []
this.emptyText = data.empty_text ?? null
this.events =
this.appendEvents && this.events
? this.events.concat(data.events)
: data.events;
this.appendEvents = false;
this.weeks = null;
this.view = "events";
this.name = data.name ?? null;
this.frontpageText = data.frontpage_text ?? null;
this.hasMoreEvents = data.has_more_events ?? false;
this.metaFilterFields = data.meta_filter_fields ?? [];
this.emptyText = data.empty_text ?? null;
} else {
this.view = 'event'
this.targetUrl = data.target_url ?? this.targetUrl
this.subevent = data.subevent ?? null
this.name = data.name ?? null
this.frontpageText = data.frontpage_text ?? null
this.dateRange = data.date_range ?? null
this.location = data.location ?? null
this.categories = data.items_by_category ?? []
this.currency = data.currency ?? ''
this.currency_places = data.currency_places ?? 2
this.displayNetPrices = data.display_net_prices ?? false
this.voucherExplanationText = data.voucher_explanation_text ?? null
this.error = data.error ?? null
this.displayAddToCart = data.display_add_to_cart ?? false
this.waitingListEnabled = data.waiting_list_enabled ?? false
this.showVariationsExpanded = data.show_variations_expanded || !!this.variationFilter
this.cartExists = data.cart_exists ?? false
this.vouchersExist = data.vouchers_exist ?? false
this.hasSeatingPlan = data.has_seating_plan ?? false
this.hasSeatingPlanWaitinglist = data.has_seating_plan_waitinglist ?? false
this.itemnum = data.itemnum ?? 0
this.view = "event";
this.targetUrl = data.target_url ?? this.targetUrl;
this.subevent = data.subevent ?? null;
this.name = data.name ?? null;
this.frontpageText = data.frontpage_text ?? null;
this.dateRange = data.date_range ?? null;
this.location = data.location ?? null;
this.categories = data.items_by_category ?? [];
this.currency = data.currency ?? "";
this.currency_places = data.currency_places ?? 2;
this.displayNetPrices = data.display_net_prices ?? false;
this.voucherExplanationText = data.voucher_explanation_text ?? null;
this.error = data.error ?? null;
this.displayAddToCart = data.display_add_to_cart ?? false;
this.waitingListEnabled = data.waiting_list_enabled ?? false;
this.showVariationsExpanded =
data.show_variations_expanded || !!this.variationFilter;
this.cartExists = data.cart_exists ?? false;
this.vouchersExist = data.vouchers_exist ?? false;
this.hasSeatingPlan = data.has_seating_plan ?? false;
this.hasSeatingPlanWaitinglist =
data.has_seating_plan_waitinglist ?? false;
this.itemnum = data.itemnum ?? 0;
}
this.poweredby = data.poweredby ?? ''
this.poweredby = data.poweredby ?? "";
if (this.loading > 0) {
this.loading--
this.triggerLoadCallback()
this.loading--;
this.triggerLoadCallback();
}
// Auto-open seating plan if applicable
if (
this.parentStack.length > 0
&& this.hasSeatingPlan
&& this.categories.length === 0
&& !this.frameDismissed
&& this.useIframe
&& !this.error
&& !this.hasSeatingPlanWaitinglist
this.parentStack.length > 0 &&
this.hasSeatingPlan &&
this.categories.length === 0 &&
!this.frameDismissed &&
this.useIframe &&
!this.error &&
!this.hasSeatingPlanWaitinglist
) {
this.startseating()
this.startseating();
} else if (opt.focus) {
nextTick(() => {
document.querySelector<HTMLElement>(opt.focus!)?.focus()
})
document.querySelector<HTMLElement>(opt.focus!)?.focus();
});
}
} catch (e) {
this.categories = []
this.currency = ''
this.currency_places = 2
this.categories = [];
this.currency = "";
this.currency_places = 2;
if (e instanceof ApiError && e.status === 429) {
this.error = STRINGS.loading_error_429
this.error = STRINGS.loading_error_429;
} else {
this.error = STRINGS.loading_error
this.error = STRINGS.loading_error;
}
this.connectionError = true
this.connectionError = true;
if (this.loading > 0) {
this.loading--
this.triggerLoadCallback()
this.loading--;
this.triggerLoadCallback();
}
throw e
throw e;
}
},
getVoucherFormTarget (): string {
let formTarget = `${this.targetUrl}w/${globalWidgetId}/redeem?iframe=1&locale=${LANG}`
getVoucherFormTarget(): string {
let formTarget = `${this.targetUrl}w/${globalWidgetId}/redeem?iframe=1&locale=${LANG}`;
if (this.cartId) {
formTarget += `&take_cart_id=${this.cartId}`
formTarget += `&take_cart_id=${this.cartId}`;
}
if (this.subevent) {
formTarget += `&subevent=${this.subevent}`
formTarget += `&subevent=${this.subevent}`;
}
if (this.widgetData) {
formTarget += `&widget_data=${encodeURIComponent(this.widgetDataJson)}`
formTarget += `&widget_data=${encodeURIComponent(this.widgetDataJson)}`;
}
formTarget += this.consentParameter
formTarget += this.consentParameter;
if (this.additionalURLParams) {
formTarget += `&${this.additionalURLParams}`
formTarget += `&${this.additionalURLParams}`;
}
return formTarget
return formTarget;
},
handleCartResponse (data: CartResponse) {
handleCartResponse(data: CartResponse) {
if (data.redirect) {
if (data.cart_id) {
this.setCartId(data.cart_id)
this.setCartId(data.cart_id);
}
let url = data.redirect
if (url.substring(0, 1) === '/') {
url = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, '$1')}${url}`
let url = data.redirect;
if (url.substring(0, 1) === "/") {
url = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, "$1")}${url}`;
}
if (url.includes('?')) {
url = `${url}&iframe=1&locale=${LANG}&take_cart_id=${this.cartId}`
if (url.includes("?")) {
url = `${url}&iframe=1&locale=${LANG}&take_cart_id=${this.cartId}`;
} else {
url = `${url}?iframe=1&locale=${LANG}&take_cart_id=${this.cartId}`
url = `${url}?iframe=1&locale=${LANG}&take_cart_id=${this.cartId}`;
}
url += this.consentParameter
url += this.consentParameter;
if (this.additionalURLParams) {
url += `&${this.additionalURLParams}`
url += `&${this.additionalURLParams}`;
}
if (data.success === false) {
url = url.replace(/checkout\/start/g, '')
this.overlay.errorMessage = data.message ?? null
url = url.replace(/checkout\/start/g, "");
this.overlay.errorMessage = data.message ?? null;
if (data.has_cart) {
this.overlay.errorUrlAfter = url
this.overlay.errorUrlAfter = url;
}
this.overlay.frameLoading = false
this.overlay.frameLoading = false;
} else {
this.overlay.frameSrc = url
this.overlay.frameSrc = url;
}
} else {
this.asyncTaskId = data.async_id
this.asyncTaskId = data.async_id;
if (data.check_url) {
this.asyncTaskCheckUrl = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, '$1')}${data.check_url}`
this.asyncTaskCheckUrl = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, "$1")}${data.check_url}`;
}
this.asyncTaskTimeout = window.setTimeout(() => this.pollAsyncTask(), this.asyncTaskInterval)
this.asyncTaskInterval = 250
this.asyncTaskTimeout = window.setTimeout(
() => this.pollAsyncTask(),
this.asyncTaskInterval,
);
this.asyncTaskInterval = 250;
}
},
async pollAsyncTask () {
if (!this.asyncTaskCheckUrl) return
async pollAsyncTask() {
if (!this.asyncTaskCheckUrl) return;
try {
const data = await checkAsyncTask(this.asyncTaskCheckUrl)
this.handleCartResponse(data)
const data = await checkAsyncTask(this.asyncTaskCheckUrl);
this.handleCartResponse(data);
} catch (e) {
if (e instanceof ApiError && (e.status === 200 || (e.status >= 400 && e.status < 500))) {
this.overlay.errorMessage = STRINGS.cart_error
this.overlay.frameLoading = false
if (
e instanceof ApiError &&
(e.status === 200 || (e.status >= 400 && e.status < 500))
) {
this.overlay.errorMessage = STRINGS.cart_error;
this.overlay.frameLoading = false;
} else {
this.asyncTaskTimeout = window.setTimeout(() => this.pollAsyncTask(), 1000)
this.asyncTaskTimeout = window.setTimeout(
() => this.pollAsyncTask(),
1000,
);
}
}
},
async buy (formData: FormData, event?: Event) {
if (!this.useIframe) return
if (event) event.preventDefault()
async buy(formData: FormData, event?: Event) {
if (!this.useIframe) return;
if (event) event.preventDefault();
if (this.isButton && this.items.length === 0) {
if (this.voucherCode) {
this.voucherOpen(this.voucherCode)
this.voucherOpen(this.voucherCode);
} else {
this.resume()
this.resume();
}
return
return;
}
const url = `${this.formAction}&locale=${LANG}&ajax=1`
this.overlay.frameLoading = true
this.asyncTaskInterval = 100
const url = `${this.formAction}&locale=${LANG}&ajax=1`;
this.overlay.frameLoading = true;
this.asyncTaskInterval = 100;
try {
const data = await submitCart(url, formData)
this.handleCartResponse(data)
const data = await submitCart(url, formData);
this.handleCartResponse(data);
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 429) {
this.overlay.errorMessage = STRINGS.cart_error_429
this.overlay.frameLoading = false
this.overlay.errorUrlAfter = this.newTabTarget
this.overlay.errorUrlAfterNewTab = true
return
this.overlay.errorMessage = STRINGS.cart_error_429;
this.overlay.frameLoading = false;
this.overlay.errorUrlAfter = this.newTabTarget;
this.overlay.errorUrlAfterNewTab = true;
return;
} else if (e.status === 405) {
// Likely a redirect!
this.targetUrl = e.responseUrl.substring(0, e.responseUrl.indexOf('/cart/add') - 18)
this.overlay.frameLoading = false
this.buy(formData)
return
this.targetUrl = e.responseUrl.substring(
0,
e.responseUrl.indexOf("/cart/add") - 18,
);
this.overlay.frameLoading = false;
this.buy(formData);
return;
}
}
this.overlay.errorMessage = STRINGS.cart_error
this.overlay.frameLoading = false
this.overlay.errorMessage = STRINGS.cart_error;
this.overlay.frameLoading = false;
}
},
async createCart () {
const url = `${this.targetUrl}w/${globalWidgetId}/cart/create?ajax=1`
async createCart() {
const url = `${this.targetUrl}w/${globalWidgetId}/cart/create?ajax=1`;
try {
this.overlay.frameLoading = true
const data = await createCart(url)
this.setCartId(data.cart_id)
return true
this.overlay.frameLoading = true;
const data = await createCart(url);
this.setCartId(data.cart_id);
return true;
} catch (e) {
if (e instanceof ApiError && e.status === 429) {
this.overlay.errorMessage = STRINGS.cart_error_429
this.overlay.frameLoading = false
this.overlay.errorUrlAfter = this.newTabTarget
this.overlay.errorUrlAfterNewTab = true
} else if (e instanceof ApiError && (e.status === 200 || (e.status >= 400 && e.status < 500))) {
this.overlay.errorMessage = STRINGS.cart_error
this.overlay.frameLoading = false
this.overlay.errorMessage = STRINGS.cart_error_429;
this.overlay.frameLoading = false;
this.overlay.errorUrlAfter = this.newTabTarget;
this.overlay.errorUrlAfterNewTab = true;
} else if (
e instanceof ApiError &&
(e.status === 200 || (e.status >= 400 && e.status < 500))
) {
this.overlay.errorMessage = STRINGS.cart_error;
this.overlay.frameLoading = false;
}
return false
return false;
}
},
redeem (voucherCode: string, event?: Event) {
if (!this.useIframe) return
if (event) event.preventDefault()
this.voucherOpen(voucherCode)
redeem(voucherCode: string, event?: Event) {
if (!this.useIframe) return;
if (event) event.preventDefault();
this.voucherOpen(voucherCode);
},
voucherOpen (voucherCode: string) {
voucherOpen(voucherCode: string) {
// TODO just use https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
const redirectUrl = `${this.getVoucherFormTarget()}&voucher=${encodeURIComponent(voucherCode)}`
const redirectUrl = `${this.getVoucherFormTarget()}&voucher=${encodeURIComponent(voucherCode)}`;
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
this.overlay.frameSrc = redirectUrl;
} else {
window.open(redirectUrl)
window.open(redirectUrl);
}
},
async resume () {
async resume() {
if (!this.cartId && this.keepCart) {
// create an empty cart whose id we can persist
if (!await this.createCart()) return
if (!(await this.createCart())) return;
}
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/`
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/`;
if (this.subevent && this.isButton && this.items.length === 0) {
// button with subevent but no items
redirectUrl += `${this.subevent}/`
redirectUrl += `${this.subevent}/`;
}
redirectUrl += `?iframe=1&locale=${LANG}`
redirectUrl += `?iframe=1&locale=${LANG}`;
if (this.cartId) {
// ajax to make sure the cart-id is used, even if the cart is currently empty
redirectUrl += `&take_cart_id=${this.cartId}&ajax=1`
redirectUrl += `&take_cart_id=${this.cartId}&ajax=1`;
}
if (this.widgetData) {
redirectUrl += `&widget_data=${encodeURIComponent(this.widgetDataJson)}`
redirectUrl += `&widget_data=${encodeURIComponent(this.widgetDataJson)}`;
}
redirectUrl += this.consentParameter
redirectUrl += this.consentParameter;
if (this.additionalURLParams) {
redirectUrl += `&${this.additionalURLParams}`
redirectUrl += `&${this.additionalURLParams}`;
}
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
this.overlay.frameSrc = redirectUrl;
} else {
window.open(redirectUrl)
window.open(redirectUrl);
}
},
startwaiting () {
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/waitinglist/?iframe=1&locale=${LANG}`
startwaiting() {
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/waitinglist/?iframe=1&locale=${LANG}`;
if (this.subevent) {
redirectUrl += `&subevent=${this.subevent}`
redirectUrl += `&subevent=${this.subevent}`;
}
if (this.additionalURLParams) {
redirectUrl += `&${this.additionalURLParams}`
redirectUrl += `&${this.additionalURLParams}`;
}
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
this.overlay.frameSrc = redirectUrl;
} else {
window.open(redirectUrl)
window.open(redirectUrl);
}
},
startseating () {
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}`
startseating() {
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}`;
if (this.subevent) {
redirectUrl += `/${this.subevent}`
redirectUrl += `/${this.subevent}`;
}
redirectUrl += `/seatingframe/?iframe=1&locale=${LANG}`
redirectUrl += `/seatingframe/?iframe=1&locale=${LANG}`;
if (this.voucherCode) {
redirectUrl += `&voucher=${encodeURIComponent(this.voucherCode)}`
redirectUrl += `&voucher=${encodeURIComponent(this.voucherCode)}`;
}
if (this.cartId) {
redirectUrl += `&take_cart_id=${this.cartId}`
redirectUrl += `&take_cart_id=${this.cartId}`;
}
if (this.widgetData) {
redirectUrl += `&widget_data=${encodeURIComponent(this.widgetDataJson)}`
redirectUrl += `&widget_data=${encodeURIComponent(this.widgetDataJson)}`;
}
redirectUrl += this.consentParameter
redirectUrl += this.consentParameter;
if (this.additionalURLParams) {
redirectUrl += `&${this.additionalURLParams}`
redirectUrl += `&${this.additionalURLParams}`;
}
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
this.overlay.frameSrc = redirectUrl;
} else {
window.open(redirectUrl)
window.open(redirectUrl);
}
},
setCartId (cartId: string) {
this._cartId = cartId
setCookie(this.cookieName, cartId, 30)
}
}
})
setCartId(cartId: string) {
this._cartId = cartId;
setCookie(this.cookieName, cartId, 30);
},
},
});
}
@@ -1,92 +1,92 @@
// Domain model types for the pretix widget
export interface Price {
gross: string
net: string
rate: string
name: string
includes_mixed_tax_rate?: boolean
gross: string;
net: string;
rate: string;
name: string;
includes_mixed_tax_rate?: boolean;
}
export interface Availability {
color: 'green' | 'orange' | 'red'
text?: string
reason?: string
color: "green" | "orange" | "red";
text?: string;
reason?: string;
}
export interface Variation {
id: number
value: string
description?: string
price: Price
suggested_price?: Price
original_price?: string
avail: [number, number | null]
order_max: number
current_unavailability_reason?: string
allow_waitinglist?: boolean
id: number;
value: string;
description?: string;
price: Price;
suggested_price?: Price;
original_price?: string;
avail: [number, number | null];
order_max: number;
current_unavailability_reason?: string;
allow_waitinglist?: boolean;
}
export interface Item {
id: number
name: string
description?: string
picture?: string
picture_fullsize?: string
price: Price
suggested_price?: Price
original_price?: string
avail: [number, number | null]
order_min?: number
order_max: number
has_variations: boolean
variations: Variation[]
free_price: boolean
min_price?: string
max_price?: string
mandatory_priced_addons?: boolean
current_unavailability_reason?: string
allow_waitinglist?: boolean
id: number;
name: string;
description?: string;
picture?: string;
picture_fullsize?: string;
price: Price;
suggested_price?: Price;
original_price?: string;
avail: [number, number | null];
order_min?: number;
order_max: number;
has_variations: boolean;
variations: Variation[];
free_price: boolean;
min_price?: string;
max_price?: string;
mandatory_priced_addons?: boolean;
current_unavailability_reason?: string;
allow_waitinglist?: boolean;
}
export interface Category {
id: number
name?: string
description?: string
items: Item[]
id: number;
name?: string;
description?: string;
items: Item[];
}
export interface EventEntry {
name: string
event_url: string
subevent?: number | string
date_range: string
location: string
time?: string
continued?: boolean
availability: Availability
name: string;
event_url: string;
subevent?: number | string;
date_range: string;
location: string;
time?: string;
continued?: boolean;
availability: Availability;
}
export interface DayEntry {
date: string
day_formatted: string
events: EventEntry[]
date: string;
day_formatted: string;
events: EventEntry[];
}
export interface MetaFilterField {
key: string
label: string
choices: [string, string][]
key: string;
label: string;
choices: [string, string][];
}
export interface LightboxState {
image: string
description: string
loading?: boolean
image: string;
description: string;
loading?: boolean;
}
export interface WidgetData {
referer: string
consent?: string
[key: string]: string | undefined
referer: string;
consent?: string;
[key: string]: string | undefined;
}
@@ -1,100 +1,106 @@
// Utility functions for the pretix widget
import { getFormat } from '~/i18n'
import { getFormat } from "~/i18n";
// Cookie utilities
export function setCookie (cname: string, cvalue: string, exdays: number): void {
const d = new Date()
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000)
const expires = `expires=${d.toUTCString()}`
document.cookie = `${cname}=${cvalue};${expires};${siteIsSecure() ? 'SameSite=None;Secure;' : ''}path=/`
export function setCookie(cname: string, cvalue: string, exdays: number): void {
const d = new Date();
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
const expires = `expires=${d.toUTCString()}`;
document.cookie = `${cname}=${cvalue};${expires};${siteIsSecure() ? "SameSite=None;Secure;" : ""}path=/`;
}
export function getCookie (name: string): string | null {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
export function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(';').shift() || null
return parts.pop()?.split(";").shift() || null;
}
return null
return null;
}
// Number formatting
export function roundTo (n: number, digits = 0): number {
const multiplicator = Math.pow(10, digits)
n = parseFloat((n * multiplicator).toFixed(11))
return Math.round(n) / multiplicator
export function roundTo(n: number, digits = 0): number {
const multiplicator = Math.pow(10, digits);
n = parseFloat((n * multiplicator).toFixed(11));
return Math.round(n) / multiplicator;
}
// TODO use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat instead?
export function floatformat (val: number | string, places = 2): string {
if (typeof val === 'string') {
val = parseFloat(val)
export function floatformat(val: number | string, places = 2): string {
if (typeof val === "string") {
val = parseFloat(val);
}
const parts = roundTo(val, places).toFixed(places).split('.')
const parts = roundTo(val, places).toFixed(places).split(".");
if (places === 0) {
return parts[0]
return parts[0];
}
const grouping = getFormat('NUMBER_GROUPING') as number
const thousandSep = getFormat('THOUSAND_SEPARATOR') as string
const decimalSep = getFormat('DECIMAL_SEPARATOR') as string
const grouping = getFormat("NUMBER_GROUPING") as number;
const thousandSep = getFormat("THOUSAND_SEPARATOR") as string;
const decimalSep = getFormat("DECIMAL_SEPARATOR") as string;
parts[0] = parts[0].replace(
new RegExp(`\\B(?=(\\d{${grouping}})+(?!\\d))`, 'g'),
thousandSep
)
return `${parts[0]}${decimalSep}${parts[1]}`
new RegExp(`\\B(?=(\\d{${grouping}})+(?!\\d))`, "g"),
thousandSep,
);
return `${parts[0]}${decimalSep}${parts[1]}`;
}
export function autofloatformat (val: number | string, places = 2): string {
const numVal = typeof val === 'string' ? parseFloat(val) : val
export function autofloatformat(val: number | string, places = 2): string {
const numVal = typeof val === "string" ? parseFloat(val) : val;
if (numVal === roundTo(numVal, 0)) {
places = 0
places = 0;
}
return floatformat(numVal, places)
return floatformat(numVal, places);
}
// String/number utilities
export function padNumber (number: number, size = 2): string {
let s = String(number)
export function padNumber(number: number, size = 2): string {
let s = String(number);
while (s.length < size) {
s = `0${s}`
s = `0${s}`;
}
return s
return s;
}
export function getISOWeeks (year: number): number {
const d = new Date(year, 0, 1)
const isLeap = new Date(year, 1, 29).getMonth() === 1
export function getISOWeeks(year: number): number {
const d = new Date(year, 0, 1);
const isLeap = new Date(year, 1, 29).getMonth() === 1;
// Check for a Jan 1 that's a Thursday or a leap year that has a Wednesday Jan 1
return d.getDay() === 4 || (isLeap && d.getDay() === 3) ? 53 : 52
return d.getDay() === 4 || (isLeap && d.getDay() === 3) ? 53 : 52;
}
export function makeid (length: number): string {
let text = ''
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
export function makeid(length: number): string {
let text = "";
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length))
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text
return text;
}
export function siteIsSecure (): boolean {
return /https.*/.test(document.location.protocol)
export function siteIsSecure(): boolean {
return /https.*/.test(document.location.protocol);
}
// HTML utility
export function stripHTML (s: string): string {
const div = document.createElement('div')
div.innerHTML = s
return div.textContent || div.innerText || ''
export function stripHTML(s: string): string {
const div = document.createElement("div");
div.innerHTML = s;
return div.textContent || div.innerText || "";
}
// docReady - DOM ready detection (returns a Promise)
export function docReady (): Promise<void> {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
return Promise.resolve()
export function docReady(): Promise<void> {
if (
document.readyState === "complete" ||
document.readyState === "interactive"
) {
return Promise.resolve();
}
return new Promise(resolve => {
document.addEventListener('DOMContentLoaded', () => resolve(), { once: true })
})
return new Promise((resolve) => {
document.addEventListener("DOMContentLoaded", () => resolve(), {
once: true,
});
});
}
+1 -1
View File
@@ -1 +1 @@
declare const LANG: string
declare const LANG: string;
@@ -1,24 +1,29 @@
import { createApp, type App } from 'vue'
import WidgetComponent from '~/components/Widget.vue'
import { createWidgetStore, StoreKey } from '~/sharedStore'
import { makeid } from '~/utils'
import type { WidgetData } from '~/types'
import { createApp, type App } from "vue";
import WidgetComponent from "~/components/Widget.vue";
import { createWidgetStore, StoreKey } from "~/sharedStore";
import { makeid } from "~/utils";
import type { WidgetData } from "~/types";
export function createWidgetInstance (element: Element, htmlId?: string): App {
let targetUrl = element.attributes.event.value
export function createWidgetInstance(element: Element, htmlId?: string): App {
let targetUrl = element.attributes.event.value;
if (!targetUrl.match(/\/$/)) {
targetUrl += '/'
targetUrl += "/";
}
const displayEventInfoAttr = element.attributes['display-event-info']?.value
const displayEventInfoAttr = element.attributes["display-event-info"]?.value;
// null means "auto" (as before), everything other than "false" is true
const displayEventInfo: boolean | null
= 'display-event-info' in element.attributes && displayEventInfoAttr !== 'auto' ? displayEventInfoAttr !== 'false' : null
const displayEventInfo: boolean | null =
"display-event-info" in element.attributes &&
displayEventInfoAttr !== "auto"
? displayEventInfoAttr !== "false"
: null;
const widgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data))
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
widgetData[attr.name.replace(/^data-/, "")] = attr.value;
}
}
@@ -26,11 +31,14 @@ export function createWidgetInstance (element: Element, htmlId?: string): App {
targetUrl,
voucher: element.attributes.voucher?.value || null,
subevent: element.attributes.subevent?.value || null,
listType: element.attributes['list-type']?.value || element.attributes.style?.value || null,
skipSsl: 'skip-ssl-check' in element.attributes,
disableIframe: 'disable-iframe' in element.attributes,
disableVouchers: 'disable-vouchers' in element.attributes,
disableFilters: 'disable-filters' in element.attributes,
listType:
element.attributes["list-type"]?.value ||
element.attributes.style?.value ||
null,
skipSsl: "skip-ssl-check" in element.attributes,
disableIframe: "disable-iframe" in element.attributes,
disableVouchers: "disable-vouchers" in element.attributes,
disableFilters: "disable-filters" in element.attributes,
displayEventInfo,
filter: element.attributes.filter?.value || null,
items: element.attributes.items?.value || null,
@@ -38,36 +46,41 @@ export function createWidgetInstance (element: Element, htmlId?: string): App {
variations: element.attributes.variations?.value || null,
widgetData,
htmlId: htmlId || element.id || makeid(16),
keepCart: true
})
keepCart: true,
});
const observer = new MutationObserver((mutationList) => {
for (const mutation of mutationList) {
if (mutation.type === 'attributes' && mutation.attributeName?.startsWith('data-')) {
const attrName = mutation.attributeName.substring(5)
const attrValue = (mutation.target as Element).getAttribute(mutation.attributeName)
store.widgetData[attrName] = attrValue
if (
mutation.type === "attributes" &&
mutation.attributeName?.startsWith("data-")
) {
const attrName = mutation.attributeName.substring(5);
const attrValue = (mutation.target as Element).getAttribute(
mutation.attributeName,
);
store.widgetData[attrName] = attrValue;
}
}
})
});
const app = createApp(WidgetComponent)
app.provide(StoreKey, store)
const app = createApp(WidgetComponent);
app.provide(StoreKey, store);
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-widget]', info, error)
}
console.error("[pretix-widget]", info, error);
};
// Instead of mounting to the element directly, replicate vue2.7 behaviour, where
// the HTML-element gets replaced instead of vue3’s behaviour to replace innerHTML.
// The latter can cause issues with custom CSS as HTML structure would change.
// app.mount(element)
// observer.observe(element, { attributes: true })
const fragment = document.createDocumentFragment()
app.mount(fragment)
const fragment = document.createDocumentFragment();
app.mount(fragment);
for (const attr of element.attributes) {
fragment.firstChild.setAttribute(attr.name, attr.value)
fragment.firstChild.setAttribute(attr.name, attr.value);
}
observer.observe(fragment.firstChild, { attributes: true })
element.parentNode.replaceChild(fragment, element)
observer.observe(fragment.firstChild, { attributes: true });
element.parentNode.replaceChild(fragment, element);
return app
return app;
}
@@ -1,38 +1,36 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig(({ mode }) => ({
server: {
port: 5180
port: 5180,
},
plugins: [
vue()
],
plugins: [vue()],
resolve: {
alias: {
'~': import.meta.dirname + '/src',
"~": import.meta.dirname + "/src",
},
},
build: {
minify: false, // django will do minification
outDir: import.meta.dirname + '/../../../static.dist/vite/widget',
outDir: import.meta.dirname + "/../../../static.dist/vite/widget",
rollupOptions: {
input: {
main: import.meta.dirname + '/src/main.ts',
main: import.meta.dirname + "/src/main.ts",
},
output: {
format: 'iife',
entryFileNames: 'widget.js',
assetFileNames: 'widget.[ext]',
format: "iife",
entryFileNames: "widget.js",
assetFileNames: "widget.[ext]",
},
},
},
optimizeDeps: {
exclude: ['moment', 'jquery']
exclude: ["moment", "jquery"],
},
define: {
...(mode === 'development' && {
LANG: JSON.stringify(process.env.PRETIX_WIDGET_LANG || 'en'),
...(mode === "development" && {
LANG: JSON.stringify(process.env.PRETIX_WIDGET_LANG || "en"),
}),
},
}))
}));
-1
View File
@@ -333,7 +333,6 @@ def test_order_update_transmission_validation(token_client, organizer, event, or
assert resp.status_code == 400
assert resp.data == {"invoice_address": {"transmission_info": {
"transmission_it_sdi_pec": ["Enter a valid email address.", "Enter a valid email address."],
"transmission_it_sdi_recipient_code": ["This field is required."]
}}}
resp = token_client.patch(
+12 -1
View File
@@ -696,7 +696,7 @@ def test_medium_autocreate_giftcard(token_client, organizer, team):
@pytest.mark.django_db
def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2_event, medium2, giftcard2):
def test_medium_lookup_cross_organizer(token_client, organizer, customer, organizer2, org2_event, medium2, giftcard2):
with scopes_disabled():
o = Order.objects.create(
code='FOO', event=org2_event, email='dummy@dummy.test',
@@ -708,7 +708,12 @@ def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2
personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14"))
medium2.linked_orderpositions.add(op)
medium2.customer = customer
medium2.linked_giftcard = giftcard2
medium2.claim_token = "1234"
medium2.info = {"foo": "bar"}
medium2.notes = "Internal notes"
medium2.label = "Max Muster"
medium2.save()
resp = token_client.post(
@@ -755,3 +760,9 @@ def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2
assert resp.data["result"]["organizer"] == "partner"
assert resp.data["result"]["linked_giftcard"] is not None
assert resp.data["result"]["linked_orderposition"] is None
assert resp.data["result"]["linked_orderpositions"] == []
assert resp.data["result"]["customer"] is None
assert resp.data["result"]["info"] == {}
assert resp.data["result"]["notes"] is None
assert resp.data["result"]["claim_token"] is None
assert resp.data["result"]["label"] is None
+6 -4
View File
@@ -3,11 +3,13 @@
"src/pretix/static/**/*",
"src/pretix/static/**/*.vue",
"src/pretix/plugins/webcheckin/**/*",
"src/pretix/plugins/webcheckin/**/*.vue"
"src/pretix/plugins/webcheckin/**/*.vue",
],
"exclude": ["**/*.js"],
"compilerOptions": {
"baseUrl": ".",
"paths": {},
"paths": {
"@/*": ["./src/*"],
},
"strict": false,
"allowJs": true,
"checkJs": true,
@@ -18,7 +20,7 @@
"noErrorTruncation": true,
"noImplicitThis": true,
"isolatedModules": true,
"types": ["node", "events"]
"noEmit": true,
},
"vueCompilerOptions": {
"plugins": ["@vue/language-plugin-pug"]