migrate webcheckin plugin to vite+vue3

- migrate vue sfcs to script setup and pug
- move fetch calls into a api.ts module
- move common formatting and i18n strings into module
This commit is contained in:
rash
2026-02-05 15:29:00 +01:00
parent ee44d4c968
commit 0d522c0eed
12 changed files with 1345 additions and 1012 deletions

View File

@@ -0,0 +1,271 @@
import type { I18nString, SubEvent } from './i18n'
const settingsEl = document.getElementById('api-settings')
const { urls } = JSON.parse(settingsEl.textContent || '{}') as { urls: {
lists: string
questions: string
} }
// interfaces generated from api docs
export interface PaginatedResponse<T> {
count: number
next: string | null
previous: string | null
results: T[]
}
export interface CheckinList {
id: number
name: string
all_products: boolean
limit_products: number[]
subevent: SubEvent | null
position_count?: number
checkin_count?: number
include_pending: boolean
allow_multiple_entries: boolean
allow_entry_after_exit: boolean
rules: Record<string, unknown>
exit_all_at: string | null
addon_match: boolean
ignore_in_statistics?: boolean
consider_tickets_used?: boolean
}
export interface Checkin {
id: number
list: number
datetime: string
type: 'entry' | 'exit'
gate: number | null
device: number | null
device_id: number | null
auto_checked_in: boolean
}
export interface Seat {
id: number
name: string
zone_name: string
row_name: string
row_label: string | null
seat_number: string
seat_label: string | null
seat_guid: string
}
export interface Position {
id: number
order: string
positionid: number
canceled?: boolean
item: { id?: number; name: I18nString; internal_name?: string; admission?: boolean }
variation: { id?: number; value: I18nString } | null
price: string
attendee_name: string
attendee_name_parts: Record<string, string>
attendee_email: string | null
company?: string | null
street?: string | null
zipcode?: string | null
city?: string | null
country?: string | null
state?: string | null
voucher?: number | null
voucher_budget_use?: string | null
tax_rate: string
tax_value: string
tax_code?: string | null
tax_rule: number | null
secret: string
addon_to: number | null
subevent: SubEvent | null
discount?: number | null
blocked: string[] | null
valid_from: string | null
valid_until: string | null
pseudonymization_id: string
seat: Seat | null
checkins: Checkin[]
downloads?: { output: string; url: string }[]
answers: Answer[]
pdf_data?: Record<string, unknown>
plugin_data?: Record<string, unknown>
// Additional fields from checkin list positions endpoint
order__status?: string
order__valid_if_pending?: boolean
order__require_approval?: boolean
order__locale?: string
require_attention?: boolean
addons?: Addon[]
}
export interface Answer {
question: number | AnswerQuestion
answer: string
question_identifier: string
options: number[]
option_identifiers: string[]
}
export interface AnswerQuestion {
id: number
question: I18nString
help_text?: I18nString
type: string
required: boolean
position: number
items: number[]
identifier: string
ask_during_checkin: boolean
show_during_checkin: boolean
hidden?: boolean
print_on_invoice?: boolean
options: QuestionOption[]
valid_number_min?: string | null
valid_number_max?: string | null
valid_date_min?: string | null
valid_date_max?: string | null
valid_datetime_min?: string | null
valid_datetime_max?: string | null
valid_file_portrait?: boolean
valid_string_length_max?: number | null
dependency_question?: number | null
dependency_values?: string[]
}
export interface QuestionOption {
id: number
identifier: string
position: number
answer: I18nString
}
export interface Addon {
item: { name: I18nString; internal_name?: string }
variation: { value: I18nString } | null
}
export interface CheckinStatusVariation {
id: number
value: string
checkin_count: number
position_count: number
}
export interface CheckinStatusItem {
id: number
name: string
checkin_count: number
admission: boolean
position_count: number
variations: CheckinStatusVariation[]
}
export interface CheckinStatus {
checkin_count: number
position_count: number
inside_count: number
event?: { name: string }
items?: CheckinStatusItem[]
}
export interface RedeemRequest {
questions_supported: boolean
canceled_supported: boolean
ignore_unpaid: boolean
type: 'entry' | 'exit'
answers: Record<string, string>
datetime?: string | null
force?: boolean
nonce?: string
}
export interface RedeemResponseList {
id: number
name: string
event: string
subevent: number | null
include_pending: boolean
}
export interface RedeemResponse {
status: 'ok' | 'error' | 'incomplete'
reason?: 'invalid' | 'unpaid' | 'blocked' | 'invalid_time' | 'canceled' | 'already_redeemed' | 'product' | 'rules' | 'ambiguous' | 'revoked' | 'unapproved' | 'error'
reason_explanation?: string | null
position?: Position
questions?: AnswerQuestion[]
checkin_texts?: string[]
require_attention?: boolean
list?: RedeemResponseList
}
const CSRF_TOKEN = document.querySelector<HTMLInputElement>('input[name=csrfmiddlewaretoken]')?.value ?? ''
function handleAuthError (response: Response): void {
if ([401, 403].includes(response.status)) {
window.location.href = '/control/login?next=' + encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash
)
}
}
export const api = {
// generic fetch wrapper, not sure if this should be exposed
async fetch <T> (url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options)
handleAuthError(response)
if (!response.ok && response.status !== 400 && response.status !== 404) {
throw new Error('HTTP status ' + response.status)
}
return response.json()
},
async fetchCheckinLists (endsAfter?: string): Promise<PaginatedResponse<CheckinList>> {
const cutoff = endsAfter ?? moment().subtract(8, 'hours').toISOString()
const url = `${urls.lists}?exclude=checkin_count&exclude=position_count&expand=subevent&ends_after=${cutoff}`
return api.fetch(url)
},
async fetchCheckinList (listId: string): Promise<CheckinList> {
return api.fetch(`${urls.lists}${listId}/?expand=subevent`)
},
async fetchNextPage<T> (nextUrl: string): Promise<PaginatedResponse<T>> {
return api.fetch(nextUrl)
},
async fetchStatus (listId: number): Promise<CheckinStatus> {
return api.fetch(`${urls.lists}${listId}/status/`)
},
async searchPositions (listId: number, query: string): Promise<PaginatedResponse<Position>> {
const url = `${urls.lists}${listId}/positions/?ignore_status=true&expand=subevent&expand=item&expand=variation&check_rules=true&search=${encodeURIComponent(query)}`
return api.fetch(url)
},
async redeemPosition (
listId: number,
positionId: string,
data: RedeemRequest,
untrusted: boolean = false
): Promise<RedeemResponse> {
let url = `${urls.lists}${listId}/positions/${encodeURIComponent(positionId)}/redeem/?expand=item&expand=subevent&expand=variation&expand=answers.question&expand=addons`
if (untrusted) url += '&untrusted_input=true'
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
handleAuthError(response)
if (response.status === 404) {
return { status: 'error', reason: 'invalid' }
}
if (!response.ok && response.status !== 400) {
throw new Error('HTTP status ' + response.status)
}
return response.json()
}
}

View File

@@ -1,28 +1,21 @@
<template>
<a class="list-group-item" href="#" @click.prevent="$emit('selected', list)">
<div class="row">
<div class="col-md-6">
{{ list.name }}
</div>
<div class="col-md-6 text-muted">
{{ subevent }}
</div>
</div>
</a>
</template>
<script>
export default {
components: {},
props: {
list: Object
},
computed: {
subevent () {
if (!this.list.subevent) return '';
const name = i18nstring_localize(this.list.subevent.name)
const date = moment.utc(this.list.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
}
},
}
<script setup lang="ts">
import { computed } from 'vue'
import type { CheckinList } from '../api'
import { formatSubevent } from '../i18n'
const props = defineProps<{
list: CheckinList
}>()
defineEmits<{
selected: [list: CheckinList]
}>()
const subevent = computed(() => formatSubevent(props.list.subevent))
</script>
<template lang="pug">
a.list-group-item(href="#", @click.prevent="$emit('selected', list)")
.row
.col-md-6 {{ list.name }}
.col-md-6.text-muted {{ subevent }}
</template>

View File

@@ -1,101 +1,99 @@
<template>
<div class="panel panel-primary checkinlist-select">
<div class="panel-heading">
<h3 class="panel-title">
{{ $root.strings['checkinlist.select'] }}
</h3>
</div>
<ul class="list-group">
<checkinlist-item v-if="lists" v-for="l in lists" :list="l" :key="l.id" @selected="$emit('selected', l)"></checkinlist-item>
<li v-if="loading" class="list-group-item text-center">
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
</li>
<li v-else-if="error" class="list-group-item text-center">
{{ error }}
</li>
<a v-else-if="next_url" class="list-group-item text-center" href="#" @click.prevent="loadNext">
{{ $root.strings['pagination.next'] }}
</a>
</ul>
</div>
</template>
<script>
export default {
components: {
CheckinlistItem: CheckinlistItem.default,
},
data() {
return {
loading: false,
error: null,
lists: null,
next_url: null,
}
},
// TODO: pagination
mounted() {
this.load()
},
methods: {
load() {
this.loading = true
const cutoff = moment().subtract(8, 'hours').toISOString()
if (location.hash) {
fetch(this.$root.api.lists + location.hash.substr(1) + '/' + '?expand=subevent')
.then(response => response.json())
.then(data => {
this.loading = false
if (data.id) {
this.$emit('selected', data)
} else {
location.hash = ''
this.load()
}
})
.catch(reason => {
location.hash = ''
this.load()
})
return
}
fetch(this.$root.api.lists + '?exclude=checkin_count&exclude=position_count&expand=subevent&ends_after=' + cutoff)
.then(response => response.json())
.then(data => {
this.loading = false
if (data.results) {
this.lists = data.results
this.next_url = data.next
} else if (data.results === 0) {
this.error = this.$root.strings['checkinlist.none']
} else {
this.error = data
}
})
.catch(reason => {
this.loading = false
this.error = reason
})
},
loadNext() {
this.loading = true
fetch(this.next_url)
.then(response => response.json())
.then(data => {
this.loading = false
if (data.results) {
this.lists.push(...data.results)
this.next_url = data.next
} else if (data.results === 0) {
this.error = this.$root.strings['checkinlist.none']
} else {
this.error = data
}
})
.catch(reason => {
this.loading = false
this.error = reason
})
},
},
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import type { CheckinList } from '../api'
import { STRINGS } from '../i18n'
import CheckinlistItem from './checkinlist-item.vue'
const emit = defineEmits<{
selected: [list: CheckinList]
}>()
const loading = ref(false)
const error = ref<unknown>(null)
const lists = ref<CheckinList[] | null>(null)
const nextUrl = ref<string | null>(null)
async function load () {
loading.value = true
error.value = null
try {
if (location.hash) {
const listId = location.hash.substring(1)
try {
const data = await api.fetchCheckinList(listId)
loading.value = false
if (data.id) {
emit('selected', data)
load()
}
} catch {
location.hash = ''
load()
}
return
}
const data = await api.fetchCheckinLists()
loading.value = false
if (data.results) {
lists.value = data.results
nextUrl.value = data.next
} else if (data.results === 0) {
error.value = STRINGS['checkinlist.none']
} else {
error.value = data
}
} catch (e) {
loading.value = false
error.value = e
}
}
async function loadNext () {
if (!nextUrl.value) return
loading.value = true
error.value = null
try {
const data = await api.fetchNextPage<CheckinList>(nextUrl.value)
loading.value = false
if (data.results) {
lists.value.push(...data.results)
nextUrl.value = data.next
} else if (data.results === 0) {
error.value = STRINGS['checkinlist.none']
} else {
error.value = data
}
} catch (e) {
loading.value = false
error.value = e
}
}
onMounted(() => {
load()
})
</script>
<template lang="pug">
.panel.panel-primary.checkinlist-select
.panel-heading
h3.panel-title {{ STRINGS['checkinlist.select'] }}
ul.list-group
CheckinlistItem(
v-for="l in lists",
:key="l.id",
:list="l",
@selected="emit('selected', $event)"
)
li.list-group-item.text-center(v-if="loading")
span.fa.fa-4x.fa-cog.fa-spin.loading-icon
li.list-group-item.text-center(v-else-if="error") {{ error }}
a.list-group-item.text-center(v-else-if="nextUrl", href="#", @click.prevent="loadNext")
| {{ STRINGS['pagination.next'] }}
</template>

View File

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

View File

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

View File

@@ -1,48 +1,48 @@
<template>
<a class="list-group-item searchresult" href="#" @click.prevent="$emit('selected', position)" ref="a">
<div class="details">
<h4>{{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}</h4>
<span>{{ itemvar }}<br></span>
<span v-if="subevent">{{ subevent }}<br></span>
<div class="secret">{{ position.secret }}</div>
</div>
<div :class="`status status-${status}`">
<span v-if="position.require_attention"><span class="fa fa-warning"></span><br></span>
{{ $root.strings[`status.${status}`] }}
</div>
</a>
</template>
<script>
export default {
components: {},
props: {
position: Object
},
computed: {
status() {
if (this.position.checkins.length) return 'redeemed';
if (this.position.order__status === 'n' && this.position.order__valid_if_pending) return 'pending_valid';
if (this.position.order__status === 'n' && this.position.order__require_approval) return 'require_approval';
return this.position.order__status
},
itemvar() {
if (this.position.variation) {
return `${i18nstring_localize(this.position.item.name)} ${i18nstring_localize(this.position.variation.value)}`
}
return i18nstring_localize(this.position.item.name)
},
subevent() {
if (!this.position.subevent) return ''
const name = i18nstring_localize(this.position.subevent.name)
const date = moment.utc(this.position.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
},
},
}
// secret
// status
// order code
// name
// seat
// require attention
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Position } from '../api'
import { STRINGS, i18nstringLocalize, formatSubevent } from '../i18n'
const props = defineProps<{
position: Position
}>()
defineEmits<{
selected: [position: Position]
}>()
const rootEl = ref<HTMLAnchorElement>()
const status = computed(() => {
if (props.position.checkins.length) return 'redeemed'
if (props.position.order__status === 'n' && props.position.order__valid_if_pending) return 'pending_valid'
if (props.position.order__status === 'n' && props.position.order__require_approval) return 'require_approval'
return props.position.order__status
})
const itemvar = computed(() => {
if (props.position.variation) {
return `${i18nstringLocalize(props.position.item.name)} ${i18nstringLocalize(props.position.variation.value)}`
}
return i18nstringLocalize(props.position.item.name)
})
const subevent = computed(() => formatSubevent(props.position.subevent))
defineExpose({ el: rootEl })
</script>
<template lang="pug">
a.list-group-item.searchresult(ref="rootEl", href="#", @click.prevent="$emit('selected', position)")
.details
h4 {{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}
span {{ itemvar }}
br
span(v-if="subevent") {{ subevent }}
br
.secret {{ position.secret }}
.status(:class="`status-${status}`")
span(v-if="position.require_attention")
span.fa.fa-warning
br
| {{ STRINGS[`status.${status}`] }}
</template>

View File

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

View File

@@ -0,0 +1,106 @@
const body = document.body
export const timezone = body.dataset.timezone ?? 'UTC'
export const datetimeFormat = body.dataset.datetimeformat ?? 'L LT'
export const dateFormat = body.dataset.dateformat ?? 'L'
export const timeFormat = body.dataset.timeformat ?? 'LT'
export const datetimeLocale = body.dataset.datetimelocale ?? 'en'
export const pretixLocale = body.dataset.pretixlocale ?? 'en'
moment.locale(datetimeLocale)
export function gettext (msgid: string): string {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid)
}
return msgid
}
export function ngettext (singular: string, plural: string, count: number): string {
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
return django.ngettext(singular, plural, count)
}
return plural
}
export type I18nString = string | Record<string, string> | null | undefined
export function i18nstringLocalize (obj: I18nString): string {
// external
return i18nstring_localize(obj)
}
export const STRINGS: Record<string, string> = {
'checkinlist.select': gettext('Select a check-in list'),
'checkinlist.none': gettext('No active check-in lists found.'),
'checkinlist.switch': gettext('Switch check-in list'),
'results.headline': gettext('Search results'),
'results.none': gettext('No tickets found'),
'check.headline': gettext('Result'),
'check.attention': gettext('This ticket requires special attention'),
'scantype.switch': gettext('Switch direction'),
'scantype.entry': gettext('Entry'),
'scantype.exit': gettext('Exit'),
'input.placeholder': gettext('Scan a ticket or search and press return…'),
'pagination.next': gettext('Load more'),
'status.p': gettext('Valid'),
'status.n': gettext('Unpaid'),
'status.c': gettext('Canceled'),
'status.e': gettext('Canceled'),
'status.pending_valid': gettext('Confirmed'),
'status.require_approval': gettext('Approval pending'),
'status.redeemed': gettext('Redeemed'),
'modal.cancel': gettext('Cancel'),
'modal.continue': gettext('Continue'),
'modal.unpaid.head': gettext('Ticket not paid'),
'modal.unpaid.text': gettext('This ticket is not yet paid. Do you want to continue anyways?'),
'modal.questions': gettext('Additional information required'),
'result.ok': gettext('Valid ticket'),
'result.exit': gettext('Exit recorded'),
'result.already_redeemed': gettext('Ticket already used'),
'result.questions': gettext('Information required'),
'result.invalid': gettext('Unknown ticket'),
'result.product': gettext('Ticket type not allowed here'),
'result.unpaid': gettext('Ticket not paid'),
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.blocked': gettext('Ticket blocked'),
'result.invalid_time': gettext('Ticket not valid at this time'),
'result.canceled': gettext('Order canceled'),
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
'result.unapproved': gettext('Order not approved'),
'status.checkin': gettext('Checked-in Tickets'),
'status.position': gettext('Valid Tickets'),
'status.inside': gettext('Currently inside'),
yes: gettext('Yes'),
no: gettext('No'),
}
export interface SubEvent {
name: Record<string, string>
date_from: string
}
export function formatSubevent (subevent: SubEvent | null | undefined): string {
if (!subevent) return ''
const name = i18nstringLocalize(subevent.name)
const date = moment.utc(subevent.date_from).tz(timezone).format(datetimeFormat)
return `${name} · ${date}`
}
export interface Question {
type: string
}
export function formatAnswer (value: string, question: Question): string {
if (question.type === 'B' && value === 'True') {
return STRINGS['yes']
} else if (question.type === 'B' && value === 'False') {
return STRINGS['no']
} else if (question.type === 'W' && value) {
return moment(value).tz(timezone).format('L LT')
} else if (question.type === 'D' && value) {
return moment(value).format('L')
}
return value
}

View File

@@ -1,79 +0,0 @@
/*global gettext, Vue, App*/
function gettext(msgid) {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid);
}
return msgid;
}
function ngettext(singular, plural, count) {
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
return django.ngettext(singular, plural, count);
}
return plural;
}
moment.locale(document.body.attributes['data-datetimelocale'].value)
window.vapp = new Vue({
components: {
App: App.default
},
render: function (h) {
return h('App')
},
data: {
api: {
lists: document.querySelector('#app').attributes['data-api-lists'].value,
},
strings: {
'checkinlist.select': gettext('Select a check-in list'),
'checkinlist.none': gettext('No active check-in lists found.'),
'checkinlist.switch': gettext('Switch check-in list'),
'results.headline': gettext('Search results'),
'results.none': gettext('No tickets found'),
'check.headline': gettext('Result'),
'check.attention': gettext('This ticket requires special attention'),
'scantype.switch': gettext('Switch direction'),
'scantype.entry': gettext('Entry'),
'scantype.exit': gettext('Exit'),
'input.placeholder': gettext('Scan a ticket or search and press return…'),
'pagination.next': gettext('Load more'),
'status.p': gettext('Valid'),
'status.n': gettext('Unpaid'),
'status.c': gettext('Canceled'),
'status.e': gettext('Canceled'),
'status.pending_valid': gettext('Confirmed'),
'status.require_approval': gettext('Approval pending'),
'status.redeemed': gettext('Redeemed'),
'modal.cancel': gettext('Cancel'),
'modal.continue': gettext('Continue'),
'modal.unpaid.head': gettext('Ticket not paid'),
'modal.unpaid.text': gettext('This ticket is not yet paid. Do you want to continue anyways?'),
'modal.questions': gettext('Additional information required'),
'result.ok': gettext('Valid ticket'),
'result.exit': gettext('Exit recorded'),
'result.already_redeemed': gettext('Ticket already used'),
'result.questions': gettext('Information required'),
'result.invalid': gettext('Unknown ticket'),
'result.product': gettext('Ticket type not allowed here'),
'result.unpaid': gettext('Ticket not paid'),
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.blocked': gettext('Ticket blocked'),
'result.invalid_time': gettext('Ticket not valid at this time'),
'result.canceled': gettext('Order canceled'),
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
'result.unapproved': gettext('Order not approved'),
'status.checkin': gettext('Checked-in Tickets'),
'status.position': gettext('Valid Tickets'),
'status.inside': gettext('Currently inside'),
'yes': gettext('Yes'),
'no': gettext('No'),
},
event_name: document.querySelector('#app').attributes['data-event-name'].value,
timezone: document.body.attributes['data-timezone'].value,
datetime_format: document.body.attributes['data-datetimeformat'].value,
},
el: '#app'
})

View File

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

View File

@@ -4,6 +4,7 @@
{% load statici18n %}
{% load eventurl %}
{% load escapejson %}
{% load vite %}
<!DOCTYPE html>
<html>
<head>
@@ -23,11 +24,7 @@
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}"
data-pretixlocale="{{ request.LANGUAGE_CODE }}" data-timezone="{{ request.event.settings.timezone }}">
<div
data-api-lists="{% url "api-v1:checkinlist-list" event=request.event.slug organizer=request.organizer.slug %}"
data-api-questions="{% url "api-v1:question-list" event=request.event.slug organizer=request.organizer.slug %}"
data-event-name="{{ request.event.name }}"
id="app"></div>
<div id="app" data-event-name="{{ request.event.name }}"></div>
{% compress js %}
<script type="text/javascript" src="{% static "pretixbase/js/i18nstring.js" %}"></script>
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
@@ -35,22 +32,17 @@
<script type="text/javascript" src="{% static "jquery/js/jquery-3.6.4.min.js" %}"></script>
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
{% endcompress %}
{% if DEBUG %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
{% else %}
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
{% endif %}
{% compress js %}
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-item.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-select.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/searchresult-item.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/app.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixplugins/webcheckin/main.js" %}"></script>
{% endcompress %}
<script type="application/json" id="countries">{{ countries|escapejson_dumps }}</script>
<script type="application/json" id="api-settings">
{
"urls": {
"lists": "{% url "api-v1:checkinlist-list" event=request.event.slug organizer=request.organizer.slug %}",
"questions": "{% url "api-v1:question-list" event=request.event.slug organizer=request.organizer.slug %}"
}
}
</script>
{% vite_hmr %}
{% vite_asset "src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.ts" %}
{% csrf_token %}
</body>
</html>