This commit is contained in:
Kara Engelhardt
2026-04-07 19:26:14 +02:00
parent 2e7d54174d
commit 66882eb115
10 changed files with 318 additions and 27 deletions

View File

@@ -23,7 +23,7 @@
"build": "npm run build:control -s && npm run build:widget -s",
"build:control": "vite build",
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin src/pretix/plugins/wallet",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref } from 'vue'
import StyleSettings from './style-settings.vue'
const STYLES = JSON.parse(document.querySelector('#styles')?.textContent ?? '{}')
const VARIABLES = JSON.parse(document.querySelector('#variables')?.textContent ?? '{}')
const FORM_ERRORS = JSON.parse(document.querySelector('#form_errors')?.textContent ?? '{}')
const FORM_DATA = JSON.parse(document.querySelector('#form_data')?.textContent ?? '{}')
const style = ref<string | null>(FORM_DATA.style ?? null)
const name = ref<string>(FORM_DATA.name ?? '')
const layout = ref(JSON.parse(FORM_DATA.layout ?? '{}') ?? {})
</script>
<template lang="pug">
// TODO: add :key for all `v-for`s
//- pre
//- code {{ STYLES }}
.row
.col-md-8
.form-group(:class='"name" in FORM_ERRORS ? "has-error" : ""')
label.control-label(for="layout-info-name") Name
input#layout-info-name.form-control(v-model="name" name="name")
.form-group(:class='"style" in FORM_ERRORS ? "has-error" : ""')
label.control-label(for="layout-info-style") Style
select#layout-info-style.form-control(v-model="style" name="style")
option(v-for="styleconfig in STYLES" :key="styleconfig.identifier" :value="styleconfig.identifier") {{ styleconfig.name }}
StyleSettings(v-model="layout" :style="style" :styles="STYLES" :variables="VARIABLES")
.col-md-4
.panel.panel-default
.panel-heading Preview
// TODO: i18n
.panel-body
// TODO: Preview
pre
code {{ layout }}
input(type="hidden" name="layout" :value="JSON.stringify(layout)")
.form-group.submit-group
button.btn.btn-primary.btn-save(type="submit") Submit
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import OptionalSelect from './optional-select.vue'
const props = defineProps<{
field: any // TODO
overflows: [string, string][],
variables: any // TODO
}>()
const fieldConfig = defineModel<any>({ required: true })
const overflowOptions = computed(() => {
if (props.overflows.length) {
return [...props.overflows.map(x => [x.identifier, x.name]), [null, "Do not overflow"]]
} else {
return []
}
})
function addVariable() {
fieldConfig.value.entries.push({"label": null, "value": null})
}
</script>
<template lang="pug">
//- pre
//- code {{ props }}
.panel.panel-default
.panel-heading
h3.panel-title {{ field.name }}
.panel-body
.form-group(v-if="props.variables[field.entry_type]")
span.text-muted These fields appear somewhere and are visible too.
// TODO: for="..." / labeledby?
h4 Fields
.row.form-group(v-for="n in fieldConfig.entries.length")
.col-md-5
// TODO: i18n
label.control-label Label
input.form-control(v-model="fieldConfig.entries[n-1].label")
.col-md-6
label.control-label Value
select.form-control(v-model="fieldConfig.entries[n-1].value")
option(v-for="(config, id) in props.variables[field.entry_type]" :key="id" :value="id") {{ config.label }}
.col-md-1
label.control-label &nbsp;
span.sr-only "Delete"
button.btn.btn-danger(type="button" @click="fieldConfig.entries.splice(n-1, 1)")
i.fa.fa-trash
span.sr-only "Delete"
button.btn.btn-default(type="button" @click="addVariable")
i.fa.fa-plus
span.sr-only Add field
OptionalSelect(label="Overflow to ..." :choices="overflowOptions" v-model="fieldConfig.overflow")
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useId } from 'vue'
const props = defineProps<{
label: string
choices: [string, string][]
}>()
const modelValue = defineModel<string|null>();
const id = useId()
</script>
<template lang="pug">
template(v-if="choices.length >= 1")
label(:for="id") {{ props.label }}
select.form-control(:id="id" v-model="modelValue")
// TODO: persist/v-model
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, watchEffect } from 'vue'
import OptionalSelect from './optional-select.vue'
import FieldSettings from './field-settings.vue'
const props = defineProps<{
styles: any, // TODO
variables: any, // TODO
style?: string,
}>()
const layout = defineModel()
const styleData = computed(() => {
if (!props.style || !(props.style in props.styles)) {
return null
}
return props.styles[props.style]
})
watchEffect(() => {
// TODO: this seems wrooong
if (!('fields' in layout.value)) {
layout.value.fields = {}
}
if (props.style) {
for (const field of props.styles[props.style].fields) {
if (!(field.identifier in layout.value.fields)) {
layout.value.fields[field.identifier] = {entries: JSON.parse(JSON.stringify(field.default_entries)), overflow: null}
}
}
}
})
</script>
<template lang="pug">
h2.h3 Form Fields
FieldSettings(v-if="styleData"
v-for="(field, fieldId) in styleData.fields"
v-model="layout.fields[field.identifier]"
:field="field"
:overflows="styleData.fields.slice(fieldId+1).filter(x => x.entry_type === field.entry_type)"
:variables="variables"
)
</template>

View File

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

View File

@@ -6,18 +6,22 @@ from django.core.exceptions import ValidationError
from i18nfield.strings import LazyI18nString
from .models import WalletLayout
class WalletPlatform:
identifier: str
name: str
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class GooglePlatform(WalletPlatform):
identifier = "google"
name = _("Google")
class PlaceholderFieldType(enum.Enum):
TEXT = "text"
CODE = "qr"
@@ -30,10 +34,11 @@ class PlaceholderFieldType(enum.Enum):
class PlaceholderField:
type: PlaceholderFieldType
label: LazyI18nString
value: LazyI18nString
value: str
def asdict(self):
return {'type': self.type.value, 'label': self.label, 'value': self.value}
return {"type": self.type.value, "label": self.label.data, "value": self.value}
@dataclass
class FieldGroupDefinition:
@@ -44,7 +49,13 @@ class FieldGroupDefinition:
max_entries: int | None = None
def asdict(self):
return {"identifier": self.identifier, "name": self.name, "min_entries": self.min_entries, "max_entries": self.max_entries}
return {
"identifier": self.identifier,
"name": self.name,
"entry_type": self.entry_type.value,
"min_entries": self.min_entries,
"max_entries": self.max_entries,
}
@dataclass
@@ -54,7 +65,7 @@ class PlaceholderFieldGroup(FieldGroupDefinition):
def asdict(self):
asdict = super().asdict()
asdict['default_entries'] = [x.asdict() for x in self.default_entries]
asdict["default_entries"] = [x.asdict() for x in self.default_entries]
return asdict
@@ -67,7 +78,7 @@ class PredefinedFieldGroup(FieldGroupDefinition):
class PassStyle:
identifier: str # unique within platform
name: str
name: str
platform: Literal["apple"] | Literal["google"]
fields: list[FieldGroupDefinition]
# preview_image: str # TODO: preview
@@ -94,18 +105,28 @@ class AppleWalletEventTicket(PassStyle):
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderField(PlaceholderFieldType.IMAGE, "logo", "event:image")
PlaceholderField(PlaceholderFieldType.IMAGE, LazyI18nString("logo"), "event:image")
],
entry_type=PlaceholderFieldType.IMAGE,
),
PlaceholderFieldGroup(identifier="primary", name=_("Primary"), min_entries=1, max_entries=1),
PlaceholderFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderField(PlaceholderFieldType.TEXT, LazyI18nString("Ticket type"), "item")
],
),
PlaceholderFieldGroup(
identifier="secondary", name=_("Secondary"), max_entries=4
), # TODO: validation of max field count if combined "Coupons, store cards, and generic passes with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
PlaceholderFieldGroup(
identifier="headers", name=_("Header"), max_entries=3
), # TODO: header image
PlaceholderFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4),
PlaceholderFieldGroup(
identifier="auxillary", name=_("Auxillary"), max_entries=4
),
PlaceholderFieldGroup(identifier="back", name=_("Back")),
]
# preview_image = "apple/event_ticket.svg"
@@ -117,13 +138,16 @@ class GoogleWalletEventTicket(PassStyle):
platform = "google"
fields = [
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
PlaceholderFieldGroup(identifier="qrcode", name=_("QR-Code"), entry_type=PlaceholderFieldType.CODE),
PlaceholderFieldGroup(
identifier="qrcode", name=_("QR-Code"), entry_type=PlaceholderFieldType.CODE
),
]
AVAILABLE_PLATFORMS = {"apple": ApplePlatform, "google": GooglePlatform}
AVAILABLE_STYLES = [AppleWalletEventTicket(), GoogleWalletEventTicket()]
def get_platforms_with_styles():
platforms_with_styles = {}
for style in AVAILABLE_STYLES:
@@ -133,6 +157,7 @@ def get_platforms_with_styles():
platforms_with_styles[platform][style.identifier] = style
return platforms_with_styles
def get_platform_styles(platform):
platform_styles = {}
for style in AVAILABLE_STYLES:
@@ -140,9 +165,11 @@ def get_platform_styles(platform):
platform_styles[style.identifier] = style
return platform_styles
def get_platforms():
return AVAILABLE_PLATFORMS
class PassLayout:
style: PassStyle
layout: dict
@@ -156,15 +183,19 @@ class PassLayout:
def validate_fields(self):
style_fields = self.style.fields
if 'fields' not in self.layout:
if "fields" not in self.layout:
raise ValidationError(_("Layout did not contain any fields"))
layout_fields = self.layout['fields']
layout_fields = self.layout["fields"]
if not isinstance(layout_fields, dict):
raise ValidationError(_("'fields' must be dict"))
for fieldgroup in style_fields:
layout_field_data = layout_fields.get(fieldgroup.identifier, [])
if fieldgroup.min_entries and fieldgroup.min_entries < len(layout_field_data):
raise ValidationError(_("At least {min_entries} must be specified for {name}").format(min_entries=fieldgroup.min_entries, name=fieldgroup.name))
layout_field_data = layout_fields.get(fieldgroup.identifier, {})
if fieldgroup.min_entries and fieldgroup.min_entries < len(
layout_field_data.get('entries')
):
raise ValidationError(
_("At least {min_entries} must be specified for {name}").format(
min_entries=fieldgroup.min_entries, name=fieldgroup.name
)
)

View File

@@ -2,18 +2,23 @@
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% load vite %}
{% load static %}
{% load compress %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<pre><code>{{ styles }}</code></pre>
<pre><code>{{ variables }}</code></pre>
<h1>{% trans "Edit layout" %}</h1>
{{ styles|json_script:"styles" }}
{{ variables|json_script:"variables" }}
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-default">
{% trans "Submit " %}
</button>
</form>
{{ form.errors|json_script:"form_errors" }}
<form method="post">
{% csrf_token %}
<div id="editor"></div>
</form>
{% vite_hmr %}
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}
{% csrf_token %}
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% load i18n %}
{% load bootstrap3 %}
{% load escapejson %}
{% load formset_tags %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="row formset-row" data-formset-form>
{% bootstrap_form_errors form %}
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-md-10">
{% bootstrap_field form.entry layout="inline" %}
</div>
<div class="col-md-2 text-right flip">
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
<i class="fa fa-edit"></i></button>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
{% with form=formset.empty_form %}
<div class="row formset-row" data-formset-form>
{% bootstrap_form_errors form %}
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-md-10">
{% bootstrap_field form.entry layout="inline" %}
</div>
<div class="col-md-2 text-right flip">
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
<i class="fa fa-edit"></i></button>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endwith %}
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add field" %}</button>
</p>
</div>
{% if external_fields %}
{{ external_fields|json_script:external_fields_id }}
{% endif %}

View File

@@ -0,0 +1,10 @@
from django import template
from ..models import WalletLayout
register = template.Library()
@register.filter
def platform_layouts(platform, event):
return WalletLayout.objects.filter(event=event, platform=platform.identifier)