This commit is contained in:
Kara Engelhardt committed 2026-10-27 17:52:47 +01:00
1 parent ff10d9471a
commit c35e73d821
19 files changed
+559 -783

No files matched your search

@@ -3,56 +3,62 @@ import { computed, inject, ref, watchEffect } from "vue";
import StyleSettings from "./style-settings.vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
import PassPreview from "./preview/pass-preview.vue";
import { StoreKey } from "../walletStore";
import Google from "./platform-preview/google.vue";
import GoogleEventDefault from "./platform-preview/google-event-default.vue";
import GoogleEventCustom from "./platform-preview/google-event-custom.vue";
import Apple from "./platform-preview/apple.vue";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
function openForm(url: string, data: Record<string, string|object>) {
let form = document.createElement("form");
form.target = "_blank";
form.method = "POST";
form.action = url;
form.style.display = "none";
function openForm(url: string, data: Record<string, string | object>) {
let form = document.createElement("form");
form.target = "_blank";
form.method = "POST";
form.action = url;
form.style.display = "none";
for (var key in data) {
var input = document.createElement("input");
input.type = "hidden";
input.name = key;
let value = data[key];
if (value instanceof Object) {
value = JSON.stringify(data[key]);
}
input.value = value
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
for (var key in data) {
var input = document.createElement("input");
input.type = "hidden";
input.name = key;
let value = data[key];
if (value instanceof Object) {
value = JSON.stringify(data[key]);
}
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
async function openPreview(e: Event) {
e.preventDefault();
openForm("../../preview/", {
csrfmiddlewaretoken: store.csrfToken,
id: store.layoutId,
...(await store.serializeCurrentPlatformLayout())
});
e.preventDefault();
openForm("../../preview/", {
csrfmiddlewaretoken: store.csrfToken,
id: store.layoutId,
...(await store.serializeCurrentPlatformLayout()),
});
}
const platformChoices = computed(() => {
return [
[null, "Do not generate pass"],
...Object.values(store.platform.styles).map((x) => [x.identifier, x.name]),
];
return [
[null, "Do not generate pass"],
...Object.values(store.platform.styles).map((x) => [x.identifier, x.name]),
];
});
const previewRenderers = {
google_eventticket_default: GoogleEventDefault,
google_eventticket_custom: GoogleEventCustom,
apple_eventticket: Apple,
};
</script>
<template lang="pug">
// TODO: add :key for all `v-for`s
// TODO: i18n textfields
// TODO: proper spinner
template(v-if="!store.loaded") {{ gettext("Loading...") }}
@@ -69,20 +75,14 @@ const platformChoices = computed(() => {
Select.form-group(label="Style" :modelValue="store.style?.identifier || null" @update:modelValue="store.setStyle" :choices="platformChoices")
button(type="button" @click="store.setStyle(store.style.identifier)") Reset style
StyleSettings(v-if="!!store.style")
.col-md-6.col-lg-4
.col-md-6.col-lg-4(style="position: sticky; top: 10px;")
.panel.panel-default
.panel-heading Preview
.panel-body
div(v-if="!!store.style?.preview_layout")
span.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
//- div(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px));")
//- PassPreview(v-for="layout in store.style.preview_layout" :layout="layout")
Google(v-if="store.activePlatform == 'google'" :data="store.style?.preview_layout")
div(v-if="!!store.style?.preview_renderer && store.style?.preview_renderer in previewRenderers")
p.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
component(:is="previewRenderers[store.style?.preview_renderer]" :data="store.style?.preview_data")
div(v-else) Preview not supported
//- pre(v-if="store.currentPlatformLayout.style")
//- code {{ store.currentPlatformStyles[store.currentPlatformLayout.style] }}
//- pre
//- code {{ store.walletLayout }}
.form-group.submit-group
button.btn.btn-lg.btn-default(type="button" @click="openPreview") Preview
button.btn.btn-primary.btn-save(type="submit") Submit
@@ -91,25 +91,94 @@ const platformChoices = computed(() => {
<style lang="css">
.walletsettings-panel .panel-heading {
.checkbox {
padding: 0;
margin: 0;
display: inline-block;
input[type="checkbox"] {
margin-top: 0;
margin-right: 5px;
margin-left: 0px;
position: relative;
top: 1px;
}
label {
padding-left: 0;
}
}
.checkbox {
padding: 0;
margin: 0;
display: inline-block;
input[type="checkbox"] {
margin-top: 0;
margin-right: 5px;
margin-left: 0px;
position: relative;
top: 1px;
}
label {
padding-left: 0;
}
}
}
.walletsettings-panel .panel-body {
> :is(h1, h2, h3, h4, h5, h6):first-child {
margin-top: 0;
}
> :is(h1, h2, h3, h4, h5, h6):first-child {
margin-top: 0;
}
}
.pass-container {
min-width: 0;
width: 100%;
min-height: 0;
border: solid 1px #949494;
border-radius: 1em;
padding: 1em;
overflow: hidden;
position: relative;
background-image: var(--background-image);
background-color: var(--background-color);
color: var(--bg-color);
background-size: cover;
background-position: center;
}
.blur-bg::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(10px);
pointer-events: none;
}
.f-row {
display: flex;
flex-direction: row;
gap: inherit;
> div {
min-width: 0;
}
}
.f-column {
display: flex;
flex-direction: column;
gap: inherit;
}
.pass-inner {
position: relative;
gap: 1em;
display: flex;
flex-direction: column;
}
.qrcode-inner {
font-weight: bold;
background-color: lightgray;
color: black;
width: 50%;
aspect-ratio: 1;
align-self: center;
/* center content */
display: flex;
align-items: center;
text-align: center;
justify-content: center;
padding: 1em;
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore";
const store = inject(StoreKey)!;
const props = defineProps<{ data: any }>();
const theme = computed(() => {
const theme = {
bg_color: store.getSettingValue("bg_color"),
fg_color: store.getSettingValue("fg_color"),
label_color: store.getSettingValue("label_color"),
};
const bg_image = store.getSettingValue("background");
if (bg_image?.url) {
theme.bg_image = `url(${encodeURI(store.getSettingValue("background").url)})`;
theme.bg_color = "#000";
}
return theme;
});
</script>
<template lang="pug">
div(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px)); align-items: start;")
.pass-container.blur-bg.apple-pass-front
.pass-inner.f-column
.f-row.head-row
img.logo(:src="store.getSettingValue('logo')?.url")
div.logo-text {{ store.renderedFieldGroups['logo_text']?.[0]?.content }}
div.header(v-for="entry in store.renderedFieldGroups['header']")
div.pass-front-label {{ entry.label }}
div.pass-content {{ entry.content }}
.f-column
div.primary(v-for="entry in store.renderedFieldGroups['primary']")
div.pass-front-label {{ entry.label }}
div.pass-content {{ entry.content }}
.f-row
div(v-for="entry in store.renderedFieldGroups['secondary']")
div.pass-front-label {{ entry.label }}
div.pass-content {{ entry.content }}
.f-row
div(v-for="entry in store.renderedFieldGroups['auxiliary']")
div.pass-front-label {{ entry.label }}
div.pass-content {{ entry.content }}
.qrcode-inner(v-for="qrcode in store.renderedFieldGroups['code']") {{ qrcode.label }}
.pass-container
.pass-inner.f-column
div(v-for="entry in store.renderedFieldGroups['back']")
div.pass-back-label {{ entry.label }}
div.pass-content {{ entry.content }}
</template>
<style lang="css" scoped>
.apple-pass-front {
background-image: v-bind(theme.bg_image);
background-color: v-bind(theme.bg_color);
color: v-bind(theme.fg_color);
background-size: cover;
background-position: center;
padding-top: 0.25em;
}
.pass-content {
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
.head-row {
align-items: center;
gap: 0.5rem;
}
.logo {
max-height: 3lh;
object-fit: contain;
flex-grow: 0;
}
.logo-text {
font-size: 1.5em;
font-weight: bold;
min-height: 2lh;
flex: 1 1 3;
align-content: center;
text-wrap: nowrap;
}
.header {
flex: 1 0 fit-content;
text-align: end;
line-height: 1.25;
gap: 0.25rem;
.pass-content {
font-size: 1.25em;
}
}
.primary {
.pass-content {
font-size: 1.5em;
}
}
.hero {
align-self: center;
max-width: 100%;
}
.pass-front-label {
font-weight: bold;
font-size: 0.8em;
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
color: v-bind(theme.label_color);
}
.pass-back-label {
font-weight: bold;
}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore";
import { dateTimeFormat, i18nstringLocalize } from "../../helpers";
import Google from "./google.vue";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
const props = defineProps<{ data: any }>();
</script>
<template lang="pug">
Google(:data="data")
template(#card-rows)
div.card-row
div(v-for="entry in store.renderedFieldGroups['second_row']")
div.card-rows-label {{ entry.label }}
div.card-rows-content {{ entry.content }}
div.card-row
div(v-for="entry in store.renderedFieldGroups['third_row']")
div.card-rows-label {{ entry.label }}
div.card-rows-content {{ entry.content }}
template(#details)
div(v-for="entry in store.renderedFieldGroups['details']")
div.pass-details-label {{ entry.label }}
div.pass-details-content {{ entry.content }}
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { inject } from "vue";
import { StoreKey } from "../../walletStore";
import { dateTimeFormat, i18nstringLocalize } from "../../helpers";
import Google from "./google.vue";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
const props = defineProps<{ data: any }>();
function isActive(fieldgroup: string) {
return store.layout.fieldgroups[fieldgroup]?.active;
}
</script>
<template lang="pug">
Google(:data="data")
template(#card-rows)
div.card-row(v-if="isActive('date')")
div
div.card-rows-label {{ gettext("Date") }}
div.card-rows-content {{ dateTimeFormat(data.date_from, {"dateStyle": "medium"}) }}
div
div.card-rows-label {{ gettext("Time") }}
div.card-rows-content {{ dateTimeFormat(data.date_from, {"timeStyle": "short"}) }}
div.card-row(v-if="isActive('seating')")
div
div.card-rows-label {{ gettext("Section") }}
div.card-rows-content {{ gettext("Ground floor") }}
div
div.card-rows-label {{ gettext("Row") }} / {{ gettext("Seat") }}
div.card-rows-content 5 / 2
template(#details)
div.pass-details-row(v-if="isActive('ticket_holder')")
div.pass-details-label {{ gettext("Ticket Holder") }}
div.pass-details-content {{ gettext("John Doe") }}
div.pass-details-row(v-if="isActive('venue')")
div.pass-details-label {{ i18nstringLocalize(data.venue?.[0]) }}
div.pass-details-content {{ i18nstringLocalize(data.venue?.[1]) }}
div.pass-details-row(v-if="isActive('date')")
div.pass-details-label {{ gettext("Event Start Time") }}
div.pass-details-content {{ i18nstringLocalize(data.venue?.[1]) }}
div.pass-details-row
div.pass-details-label {{ gettext("Ticket Type") }}
div.pass-details-content {{ gettext("Sample product") }}
div.pass-details-row
div.pass-details-label {{ gettext("Ticket Number") }}
div.pass-details-content ABCDE-1
div.pass-details-row
div.pass-details-label {{ gettext("Order Number") }}
div.pass-details-content ABCDE
a.pass-details-link.f-row(:href="data.event_page")
div
i.fa.fa-globe
div.pass-details-label
| {{ " " + gettext("Website") }}
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore";
import { i18nstringLocalize } from "../../helpers";
import { dateTimeFormat, i18nstringLocalize } from "../../helpers";
const store = inject(StoreKey)!;
@@ -15,102 +15,110 @@ const qrcode = computed(() => store.renderedFieldGroups["code"]?.[0]);
</script>
<template lang="pug">
div(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px));")
.google-pass-container.f-row
img.logo(:src="store.getSettingValue('logo')?.url")
div
.bold {{ data.event }}
div(v-if="isActive('date')") {{ data.date_from }}
div(v-else) {{ data.organizer }}
.google-pass-container(:style="{'--background-color': store.getSettingValue('bg_color')}")
.f-row
div.pass-preview-google(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px)); align-items: start;")
div.f-column
.pass-container.f-row
img.logo(:src="store.getSettingValue('logo')?.url")
div.logo-text {{ data.organizer }}
div
div(v-if="isActive('venue')") {{ i18nstringLocalize(data.venue?.[0]) }}
div.large {{ data.event }}
// TODO: front card
.qrcode-inner(v-if="qrcode") {{ qrcode.label }}
.f-column
img.hero(:src="store.getSettingValue('hero')?.url")
div
.bold {{ data.event }}
div(v-if="isActive('date')") {{ dateTimeFormat(data.date_from, {"dateStyle": "medium"}) }}
div(v-else) {{ data.organizer }}
.pass-container.google-main(:data-background-color="store.getSettingValue('bg_color')")
.pass-inner
.f-row
img.logo(:src="store.getSettingValue('logo')?.url")
div.logo-text {{ data.organizer }}
.top-row
div(v-if="isActive('venue')") {{ i18nstringLocalize(data.venue?.[0]) }}
.event-name {{ data.event }}
.f-column
slot(name="card-rows")
.qrcode-inner(v-for="qrcode in store.renderedFieldGroups['code']") {{ qrcode.label }}
.f-column
img.hero(:src="store.getSettingValue('hero')?.url")
.pass-container
.pass-inner
slot(name="details")
.google-pass-container
div(v-for="entry in store.renderedFieldGroups['details']")
div.pass-details-label {{ entry.label }}
div.pass-details-content {{ entry.content }}
</template>
<style lang="css" scoped>
.google-pass-container {
min-width: 0;
width: 100%;
vertical-align: top;
display: flex;
flex-direction: column;
border: solid 1px #949494;
border-radius: 1em;
padding: 1em;
<style lang="css">
.pass-preview-google {
.google-main {
--background-color: attr(data-background-color type(<color>));
color: contrast-color(var(--background-color));
}
background-image: var(--background-image);
background-color: var(--background-color);
color: contrast-color(var(--background-color));
background-size: cover;
background-position: center;
.logo {
height: 2lh;
aspect-ratio: 1;
border-radius: 50%;
background-color: white;
object-fit: fill;
}
gap: 1em;
}
.logo-text {
align-self: center;
font-size: 1.5em;
}
.f-row {
display: flex;
flex-direction: row;
gap: inherit;
}
.f-column {
display: flex;
flex-direction: column;
gap: inherit;
}
.bold {
font-weight: bold;
}
.logo {
height: 2lh;
aspect-ratio: 1;
border-radius: 50%;
background-color: white;
object-fit: fill;
}
.logo-text {
align-self: center;
font-size: 1.5em;
}
.large {
font-size: 2em;
}
.qrcode-inner {
font-weight: bold;
background-color: lightgray;
color: black;
width: 50%;
aspect-ratio: 1;
align-self: center;
.top-row > .event-name {
font-size: 1.75em;
}
/* center content */
display: flex;
align-items: center;
text-align: center;
justify-content: center;
padding: 1em;
overflow-wrap: anywhere;
}
.hero {
align-self: center;
}
.pass-details-label {
font-weight: bold;
}
.pass-details-content {
font-size: 0.9em;
.card-row {
display: flex;
flex-direction: row;
gap: inherit;
> div {
flex: 0 1 100%;
}
&:has(> :first-child:nth-last-child(2)) {
/* twoItems */
> :nth-child(2) {
text-align: end;
}
}
&:has(> :first-child:nth-last-child(3)) {
/* threeItems */
> :nth-child(2) {
text-align: center;
}
> :nth-child(3) {
text-align: end;
}
}
}
.card-rows-label {
font-size: 0.9em;
}
.pass-details-row {
border-bottom: 2px lightblue;
}
.hero {
align-self: center;
max-width: 100%;
}
.pass-details-label {
font-weight: bold;
}
.pass-details-content {
font-size: 0.9em;
white-space: pre-wrap;
}
.pass-details-link {
color: black;
}
}
</style>
@@ -1,25 +0,0 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import { i18nstringLocalize } from "../../helpers";
const store = inject(StoreKey)!;
const props = defineProps<{
label?: LocalizableString;
content: LocalizableString;
content_type: FieldContentType;
display: FieldGroupDisplay;
display_class?: string | string[];
}>();
</script>
<template lang="pug">
div.fieldgroup-item( :class="display_class")
div.fieldgroup-item-qrcode(v-if="display == 'code'") {{ label }}
template(v-else)
div.fieldgroup-label.nowrap(v-if="display == 'with_label'") {{ i18nstringLocalize(label) }}
div.nowrap.content(v-if="content_type == 'text'") {{ i18nstringLocalize(content) }}
img.fieldgroup-item-image(v-else-if="content_type == 'image' && content" :src="i18nstringLocalize(content)" :class="display_class")
</template>
@@ -1,31 +0,0 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import PlaceholderFieldgroupPreview from "./placeholder-fieldgroup-preview.vue";
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
const store = inject(StoreKey)!;
const props = defineProps<{
config: PreviewFieldgroup;
}>();
const style_def = computed(() => {
return Object.fromEntries(
store.styles[
store.layout.style
].fieldgroups.map((x) => [x.identifier, x]),
)[props.config.fieldgroup];
});
function focusGroup() {
// TODO: highlight group? or change ui concept completely
const elem = document.getElementById('fieldgroup-' + props.config.fieldgroup)
elem && elem.scrollIntoView()
}
</script>
<template lang="pug">
PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def" @click="focusGroup")
PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def" @click="focusGroup")
</template>
@@ -1,230 +0,0 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore.js";
import RowPreview from "./row-preview.vue";
const store = inject(StoreKey)!;
const { layout } = defineProps<{ layout: PreviewLayout }>();
function isPredefined(
row: PreviewFieldgroup,
): row is PredefinedFieldgroupPreview {
const fieldgroup = store.fieldgroups[row.fieldgroup];
return fieldgroup.type == "predefined";
}
function getSettingPreview(setting: string) {
const settingDef = store.styles[store.layout.style].settings.find(
(x) => x.identifier == setting,
);
const val = store.settings[setting];
if (settingDef.type == "image") {
if (val && "url" in val && val.url) {
return { content: val.url, content_type: "image" };
} else if (settingDef.default) {
return { content: settingDef.default, content_type: "image" };
}
} else if (val) {
return { content: val, content_type: "text" };
} else if (settingDef.default) {
return { content: settingDef.default, content_type: "text" };
}
}
function evaluateStyle(style: StyleConfig) {
return Object.fromEntries(
Object.entries(style)
.map(([k, v]) => {
if (typeof v !== "string" && v.setting) {
const settingsVal = getSettingPreview(v.setting);
if (!settingsVal) {
v = null;
} else if (settingsVal.content_type == "image") {
v = `url(${encodeURI(settingsVal.content)})`;
} else {
v = settingsVal.content;
}
}
return [k, v];
})
.filter(([_k, v]) => !!v),
);
}
type EvaluatedPreviewItem = {
label?: LocalizableString;
content: LocalizableString;
content_type?: "text" | "image";
};
type EvaluatedPreviewRow = (
| EvaluatedPreviewItem
| { children: EvaluatedPreviewRow[] }
) & {
display?: string;
display_class?: string | string[];
style?: Record<string, string | number>;
};
function hasEntries(row) {
if ("content" in row) {
return true;
} else if ("children" in row) {
return row.children.some(hasEntries);
}
}
function evaluateRow(row: PreviewRow): EvaluatedPreviewRow {
if ("children" in row) {
const style = {
"flex-direction": row.direction || "row",
};
const children = row.children.map(evaluateRow).filter((x) => !!x);
if (row.max_entries) {
children.splice(row.max_entries);
}
return { children, display_class: row.display_class, style };
} else if ("fieldgroup" in row) {
// TODO: click
const style = {
"flex-grow": row.relSize,
"flex-direction": row.direction || "row",
};
if (isPredefined(row)) {
if (store.layout.fieldgroups[row.fieldgroup]?.active) {
return { style: style, children: row.sample, display_class: row.display_class };
}
} else {
return {
style: style,
children: store.renderedFieldGroups[row.fieldgroup],
display_class: row.display_class
};
}
} else if ("content" in row) {
return { label: row.label, content: row.content, display_class: row.display_class };
} else if ("setting" in row) {
return { ...getSettingPreview(row.setting), display_class: row.display_class };
}
}
const renderedLayout = computed(() => {
return {
style: evaluateStyle(layout.style || {}),
rows: layout.rows.map(evaluateRow),
};
});
</script>
<template lang="pug">
div.pass-container(:style="renderedLayout.style")
div.pass-content
RowPreview(v-for="row of renderedLayout.rows" :config="row")
</template>
<style lang="css" scoped>
.pass-container {
width: 100%;
max-width: 360px;
vertical-align: top;
display: inline-block;
border: solid 1px #949494;
border-radius: 1em;
padding: 1em;
overflow: hidden;
position: relative;
background-image: var(--background-image);
background-color: var(--background-color);
background-size: cover;
background-position: center;
}
.pass-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(10px);
pointer-events: none;
}
.pass-content {
overflow: scroll;
display: flex;
flex-direction: column;
gap: 1em;
position: relative;
}
</style>
<style lang="css">
.pass-container {
.fieldgroup-container {
min-width: 0;
display: flex;
gap: 0.5em;
min-height: 2lh;
}
.fieldgroup-label {
font-weight: bold;
font-size: 0.8em;
color: var(--label-color);
}
.fieldgroup-item {
flex: 0 1 100%;
min-width: 0;
display: flex;
flex-direction: column;
white-space: pre-wrap;
}
.fieldgroup-item-image {
object-fit: contain;
}
.fieldgroup-item-qrcode {
font-weight: bold;
background-color: lightgray;
color: black;
width: 50%;
aspect-ratio: 1;
align-self: center;
/* center content */
display: flex;
align-items: center;
text-align: center;
justify-content: center;
padding: 1em;
overflow-wrap: anywhere;
}
.nowrap {
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
.bold {
font-weight: bold;
}
.large {
font-size: 1.5em;
}
.tight {
line-height: 1;
gap: 0.25rem;
}
.img-inline {
max-height: 3lh;
}
.img-circle-sm {
max-height: 2lh;
aspect-ratio: 1;
border-radius: 50%;
background-color: white;
object-fit: fill;
}
}
</style>
@@ -1,17 +0,0 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
const store = inject(StoreKey)!;
const props = defineProps<{
config: PreviewFieldgroup;
style_def: PlaceholderFieldGroupDefinition;
}>();
</script>
<template lang="pug">
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
FieldgroupItemPreview(v-for="{ label, content } of store.renderedFieldGroups[config.fieldgroup]" :label="label" :content="content" :content_type="style_def.content_type" :display_class="config.display" :display="style_def.display")
</template>
@@ -1,22 +0,0 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
const store = inject(StoreKey)!;
const props = defineProps<{
style_def: PredefinedFieldGroupDefinition;
config: PredefinedFieldgroupPreview;
}>();
const isActive = computed(
() =>
store.layout.fieldgroups[props.style_def.identifier]?.active,
);
</script>
<template lang="pug">
div.fieldgroup-container(v-if="isActive" :style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
FieldgroupItemPreview(v-for="{ label, content } of config.sample" :label="label" :content="content" content_type="text" :display="!!label ? 'with_label' : 'plain'")
</template>
@@ -1,29 +0,0 @@
<script setup lang="ts">
import FieldgroupItemPreview from './fieldgroup-item-preview.vue';
import FieldgroupPreview from './fieldgroup-preview.vue';
import SettingPreview from './setting-preview.vue';
const props = defineProps<{
config: PreviewRow;
}>();
</script>
<template lang="pug">
div.preview-row(v-if="'children' in config" :style="{ flexDirection: config.direction || 'row' }" :class="config.display")
RowPreview(v-for="child of config.children" :config="child" )
FieldgroupPreview(v-else-if="'fieldgroup' in config" :config="config")
FieldgroupItemPreview(v-else-if="'value' in config" :label="config.label" :content="config.value" content_type="text" :display="!!config.label ? 'with_label' : 'plain'" :display_class="config.display")
SettingPreview(v-else-if="'setting' in config" :config="config")
</template>
<style lang="css" scoped>
.preview-row {
width: 100%;
display: flex;
gap: 1em;
display: flex
}
.tight {
gap: 0;
}
</style>
@@ -1,23 +0,0 @@
<script setup lang="ts">
import FieldgroupItemPreview from './fieldgroup-item-preview.vue';
import FieldgroupPreview from './fieldgroup-preview.vue';
import SettingPreview from './setting-preview.vue';
const props = defineProps<{
config: PreviewRow;
}>();
</script>
<template lang="pug">
div.preview-row(v-if="'children' in config" :style="config.style" :class="config.display_class")
RowPreview(v-for="child of config.children" :config="child" )
FieldgroupItemPreview(v-else-if="'content' in config" :label="config.label" :content="config.content" :content_type="config.content_type || 'text'" :display="config.display || (!!config.label ? 'with_label' : 'plain')" :display_class="config.display_class")
</template>
<style lang="css" scoped>
.preview-row {
width: 100%;
display: flex;
gap: 1em;
}
</style>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import { computed, inject, reactive, toRaw, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
const store = inject(StoreKey)!;
const { config } = defineProps<{
config: SettingPreview;
}>();
const settingDef = computed(() => {
return Object.fromEntries(
store.styles[store.layout.style].settings.map((x) => [x.identifier, x]),
)[config.setting];
});
const settingValue = computed(() => {
const val = store.settings[config.setting];
if (settingDef.value.type == "image") {
if (val && "url" in val && val.url) {
return val.url;
} else if (settingDef.value.type == "image" && settingDef.value.default) {
return settingDef.value.default;
}
} else {
return val;
}
});
</script>
<template lang="pug">
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
FieldgroupItemPreview(:content="settingValue" :content_type="settingDef.type" :display_class="config.display" display="plain")
</template>
@@ -31,3 +31,8 @@ export function i18nstringLocalize(s: LocalizableString, locale?: string): strin
}
}
export function dateTimeFormat(d: string, options?: Record<string, string>): string {
const locale = document.body.attributes['data-pretixlocale'].value
return new Intl.DateTimeFormat(locale, options).format(new Date(d))
}
@@ -35,7 +35,7 @@ type FieldGroupDefinition =
| PlaceholderFieldGroupDefinition
| PredefinedFieldGroupDefinition;
type FieldGroupDisplay = "plain" | "with_label" | "code";
type FieldGroupDisplay = "plain" | "with_label";
type FieldGroupType = "placeholder" | "predefined";
type FieldContentType = "text" | "image";
@@ -43,7 +43,6 @@ type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
type: "placeholder";
content_type: FieldContentType;
default_entries: FieldEntry[];
display: FieldGroupDisplay;
min_entries: number | null;
max_entries: number | null;
context_args: string[];
@@ -115,6 +115,9 @@ export function createWalletStore(config: {
fieldgroup.identifier
] as any as PlaceholderFieldGroupConfig;
for (const entry of layout_group.entries) {
if (entry.type === "placeholder" && !entry.content) {
continue
}
const placeholder =
entry.type === "placeholder"
? this.variables[fieldgroup.content_type][entry.content]
@@ -134,10 +137,8 @@ export function createWalletStore(config: {
`(unknown placeholder: ${entry.content})`;
}
content[fieldgroup.identifier].push({
// entry,
label,
content: value,
display: fieldgroup.display,
});
}
}
+3 -36
View File
@@ -411,7 +411,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
identifier="code",
name=_("QR-Code"),
max_entries=1,
display=FieldGroupDisplay.CODE,
display=FieldGroupDisplay.PLAIN,
default_entries=[
PlaceholderFieldEntry(
content="secret",
@@ -456,41 +456,8 @@ class AppleWalletEventTicket(AppleWalletStyle):
]
@property
def preview_layout(self):
return [
{
"style": {
"--background-color": {"setting": "bg_color"},
"color": {"setting": "fg_color"},
"--label-color": {"setting": "label_color"},
"--background-image": {"setting": "background"},
},
"rows": [
{
"children": [
{"setting": "logo", "display_class": ["img-inline"]},
{
"fieldgroup": "logo_text",
"relSize": 3,
"display_class": ["bold", "large", "centered"],
},
{
"fieldgroup": "header",
"relSize": 2,
"display_class": ["large", "tight"],
},
]
},
{"fieldgroup": "primary", "display_class": "large"},
{"fieldgroup": "secondary"},
{"fieldgroup": "auxiliary"},
{"fieldgroup": "code", "direction": "column"},
],
},
{
"rows": [{"fieldgroup": "back", "direction": "column"}],
},
]
def preview_renderer(self) -> str | None:
return "apple_eventticket"
def convert_fields(self, strings, fields, prefix):
# TODO: detect if a string-value/string-label is the same as a generated field name
+75 -61
View File
@@ -1,4 +1,5 @@
import enum
from functools import cached_property
from typing import Any, Literal, TypedDict
import jsonschema
@@ -37,7 +38,6 @@ class FieldGroupType(enum.Enum):
class FieldGroupDisplay(enum.Enum):
PLAIN = "plain"
WITH_LABEL = "with_label"
CODE = "code"
class FieldGroup:
@@ -478,33 +478,40 @@ class PassStyle:
def settings(self) -> list[SettingsField]:
return []
@cached_property
def _settings_dict(self):
return {x.identifier: x for x in self.settings}
@property
def preview_layout(self) -> list | None:
def preview_renderer(self) -> str | None:
return None
@property
def cleaned_settings(self):
if not self.layout:
return {}
settings = {}
for setting in self.settings:
if isinstance(setting, ImageSettingsField):
settings[setting.identifier] = (
self.file_settings[setting.identifier].url
if setting.identifier in self.file_settings
else None
)
if not settings[setting.identifier] and setting.default:
settings[setting.identifier] = setting.default
else:
settings[setting.identifier] = self.layout.get("settings", {}).get(
setting.identifier, setting.default
)
if not settings[setting.identifier] and setting.default:
settings[setting.identifier] = setting.default
return settings
def preview_data(self) -> dict:
return {}
def asdict(self): # -> dict[str, Any]:
def get_cleaned_setting(self, setting_identifier: str):
if not self.layout:
raise ValueError("`layout` needs to be set")
setting = self._settings_dict.get(setting_identifier)
if not setting:
return None
if isinstance(setting, ImageSettingsField):
if setting.identifier in self.file_settings:
return self.file_settings[setting.identifier].url
else:
if val := self.layout.get("settings", {}).get(
setting.identifier, setting.default
):
return val
if setting.default:
return setting.default
def asdict(self):
context = LayoutContext(
placeholders=self.placeholders,
placeholder_renderer=get_wallet_placeholder_renderer(),
@@ -515,7 +522,8 @@ class PassStyle:
"fieldgroups": [
x.asdict(self.event.settings.locales, context) for x in self.fieldgroups
],
"preview_layout": self.preview_layout,
"preview_renderer": self.preview_renderer,
"preview_data": self.preview_data,
"settings": [x.asdict(self.event.settings.locales) for x in self.settings],
}
@@ -611,6 +619,33 @@ class PassStyle:
return res
def _render_placeholder_fieldgroup_entries(self, group, context):
group_fields = []
if group.identifier not in self.layout["fieldgroups"]:
return group_fields
for field in self.layout["fieldgroups"][group.identifier]["entries"]:
field_entry: dict[str, MaybeTranslatedString | None] = {}
if group.display == FieldGroupDisplay.WITH_LABEL:
field_entry["label"] = LazyI18nString(field["label"])
if field["type"] == FieldEntryType.PLACEHOLDER.value:
label, field_entry["value"] = self.render_placeholder(
context, group.content_type.value, field["content"]
)
if (
group.display == FieldGroupDisplay.WITH_LABEL
and not str(field_entry["label"])
and label
):
field_entry["label"] = label
elif field["type"] == FieldEntryType.CUSTOM.value:
field_entry["value"] = LazyI18nString(field["content"])
if "value" in field_entry and field_entry["value"]:
group_fields.append(field_entry)
return group_fields
def get_pass_fields(self, op: OrderPosition):
if not self.layout:
raise ValueError("`layout` needs to be set")
@@ -619,46 +654,25 @@ class PassStyle:
fields = {}
for group in self.fieldgroups:
if isinstance(group, PredefinedFieldGroup):
pass
if not isinstance(group, PlaceholderFieldGroup):
continue
elif isinstance(group, PlaceholderFieldGroup):
group_fields = fields.get(group.identifier, [])
if group.identifier in self.layout["fieldgroups"]:
for field in self.layout["fieldgroups"][group.identifier][
"entries"
]:
field_entry: dict[str, MaybeTranslatedString | None] = {}
if group.display == FieldGroupDisplay.WITH_LABEL:
field_entry["label"] = LazyI18nString(field["label"])
if field["type"] == FieldEntryType.PLACEHOLDER.value:
label, field_entry["value"] = self.render_placeholder(
context, group.content_type.value, field["content"]
)
if (
group.display == FieldGroupDisplay.WITH_LABEL
and not str(field_entry["label"])
and label
):
field_entry["label"] = label
fields.setdefault(group.identifier, [])
fields[group.identifier] += self._render_placeholder_fieldgroup_entries(
group, context
)
if group.min_entries and len(fields[group.identifier]) < group.min_entries:
raise ValueError(
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(fields[group.identifier])} were provided"
)
elif field["type"] == FieldEntryType.CUSTOM.value:
field_entry["value"] = LazyI18nString(field["content"])
if "value" in field_entry and field_entry["value"]:
group_fields.append(field_entry)
if group.min_entries and len(group_fields) < group.min_entries:
raise ValueError(
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
)
fields[group.identifier] = group_fields[: group.max_entries]
if overflow_group := self.layout["fieldgroups"][group.identifier][
"overflow"
]:
fields.setdefault(overflow_group, [])
fields[overflow_group] += group_fields[group.max_entries :]
if overflow_group := self.layout["fieldgroups"][group.identifier][
"overflow"
]:
fields.setdefault(overflow_group, [])
fields[overflow_group] += fields[group.identifier][group.max_entries :]
fields[group.identifier] = fields[group.identifier][: group.max_entries]
else:
raise ValueError("Unknown field group")
return fields
def group_is_active(self, identifier: str):
+29 -118
View File
@@ -186,7 +186,7 @@ class GoogleWalletEventTicketStyle(PassStyle):
identifier="code",
name=_("QR-Code"),
max_entries=1,
display=FieldGroupDisplay.CODE,
display=FieldGroupDisplay.PLAIN,
default_entries=[
PlaceholderFieldEntry(
content="secret",
@@ -197,106 +197,17 @@ class GoogleWalletEventTicketStyle(PassStyle):
]
@property
def preview_layout(self):
def preview_data(self):
return {
"renderer": "google_eventticket",
"event": str(self.event),
"organizer": str(self.event.organizer),
"date_from": date_format(
self.event.date_from.astimezone(self.event.timezone),
"SHORT_DATE_FORMAT",
),
"venue": self.venue()
"date_from": self.event.date_from.astimezone(
self.event.timezone
).isoformat(),
"venue": self.venue(),
"event_page": eventreverse_absolute(self.event, "presale:event.index"),
}
# @property
# def preview_layout(self):
# return [
# {
# "rows": [
# {
# "children": [
# {"setting": "logo", "display_class": ["img-circle-sm"]},
# {
# "direction": "column",
# "display_class": ["tight"],
# "children": [
# {
# "content": str(self.event.name),
# "display_class": ["bold"],
# },
# {
# "children": [
# {
# "fieldgroup": "date",
# "sample": [
# {
# "content": date_format(
# self.event.date_from.astimezone(
# self.event.timezone
# ),
# "SHORT_DATE_FORMAT",
# ),
# },
# ],
# },
# {
# "content": str(self.event.organizer.name),
# },
# ],
# "max_entries": 1,
# },
# ],
# },
# ]
# },
# ],
# },
# {
# "style": {
# "--background-color": {"setting": "bg_color"},
# "color": "contrast-color(var(--background-color))",
# },
# "rows": [
# {
# "children": [
# {"setting": "logo", "display_class": ["img-circle-sm"]},
# {
# "content": str(self.event.organizer.name),
# "relSize": 3,
# "display_class": ["large", "centered"],
# },
# ]
# },
# {
# "children": [
# {
# "fieldgroup": "venue",
# "sample": [
# {"content": self.venue()[0], "label": ""},
# ],
# },
# {"content": str(self.event.name), "display_class": "large"},
# ],
# "direction": "column",
# "display_class": ["tight"],
# },
# *self._preview_layout_front_card,
# {"fieldgroup": "code", "direction": "column"},
# {"setting": "hero"},
# ],
# },
# {"rows": [*self._preview_layout_back_details]},
# ]
@property
def _preview_layout_front_card(self):
return []
@property
def _preview_layout_back_details(self):
return []
def venue(self):
if self.event.location:
name = {}
@@ -330,8 +241,8 @@ class GoogleWalletEventTicketStyle(PassStyle):
localizable_to_dict("Website", self.event.settings.locales),
)
if (lat := self.cleaned_settings["lat"]) and (
long := self.cleaned_settings["long"]
if (lat := self.get_cleaned_setting("lat")) and (
long := self.get_cleaned_setting("long")
):
output_class.locations(lat, long)
elif self.event.geo_lat and self.event.geo_lon:
@@ -339,36 +250,43 @@ class GoogleWalletEventTicketStyle(PassStyle):
output_class.country_code(self.event.settings.locale)
if hero := self.cleaned_settings["hero"]:
if hero := self.get_cleaned_setting("hero"):
output_class.hero_image(
urljoin(eventreverse_absolute(self.event, "presale:event.index"), hero),
self.event.name,
)
if logo := self.cleaned_settings["logo"]:
if logo := self.get_cleaned_setting("logo"):
output_class.logo(
urljoin(eventreverse_absolute(self.event, "presale:event.index"), logo),
self.event.name,
)
output_class.hex_background_color(self.cleaned_settings["bg_color"])
output_class.hex_background_color(self.get_cleaned_setting("bg_color"))
output_class.confirmation_code_label(ConfirmationCode.orderNumber)
if self.group_is_active("venue") and all(self.venue()):
output_class.venue(*self.venue())
if self.cleaned_settings.get("shimmer", False):
if self.get_cleaned_setting("shimmer"):
output_class.securityAnimation(AnimationType.FOIL_SHIMMER)
if self.group_is_active("date"):
output_class.start(self.event.date_from.isoformat())
output_class.start(
self.event.date_from.astimezone(self.event.timezone).isoformat()
)
if self.event.settings.show_date_to and self.event.date_to:
output_class.end(self.event.date_to.isoformat())
output_class.end(
self.event.date_to.astimezone(self.event.timezone).isoformat()
)
if self.event.date_admission:
output_class.doors_open(
DoorsOpen.doorsOpen, self.event.date_admission.isoformat()
DoorsOpen.doorsOpen,
self.event.date_admission.astimezone(
self.event.timezone
).isoformat(),
)
# TODO: callback url
@@ -404,6 +322,10 @@ class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle):
identifier = "event"
name = "Default Event Ticket"
@property
def preview_renderer(self):
return "google_eventticket_default"
@property
def fieldgroups(self):
return [
@@ -529,19 +451,8 @@ class GoogleWalletCustomEventTicket(GoogleWalletEventTicketStyle):
name = "Custom Event Ticket"
@property
def _preview_layout_front_card(self):
return [
{
"fieldgroup": "second_row",
},
{
"fieldgroup": "third_row",
},
]
@property
def _preview_layout_back_details(self):
return [{"fieldgroup": "details", "direction": "column"}]
def preview_renderer(self):
return "google_eventticket_custom"
@property
def fieldgroups(self):