first draft migrating widget to vue3/vite

This commit is contained in:
rash
2026-02-11 15:12:43 +01:00
parent 2898f06f56
commit 333dc56ef7
38 changed files with 4171 additions and 4 deletions

View File

@@ -283,7 +283,7 @@ class SecurityMiddleware(MiddlewareMixin):
'script-src': ["{static}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []),
'object-src': ["'none'"],
'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"],
'style-src': ["{static}", "{media}"]+ (["unsafe-inline"] if settings.VITE_DEV_MODE else []),
'connect-src': ["{dynamic}", "{media}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []),
'img-src': ["{static}", "{media}", "data:"] + img_src,
'font-src': ["{static}"] + list(font_src),

View File

@@ -0,0 +1,2 @@
- modernize the sometimes native form submitting?
- destructure props?

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pretix Widget</title>
<link rel="stylesheet" type="text/css" href="http://localhost:8000/testorg/testevent/widget/v2.css" crossorigin>
</head>
<body>
<pretix-widget event="http://localhost:8000/testorg/testevent/"></pretix-widget>
<!-- <script type="text/javascript" src="http://localhost:8000/widget/v2.en.js" async crossorigin></script> -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed, inject, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { StoreKey } from '~/sharedStore'
import { STRINGS } from '~/i18n'
import Category from './Category.vue'
const store = inject(StoreKey)!
const form = ref<HTMLFormElement>()
const voucherinput = ref<HTMLInputElement>()
const isItemsSelected = ref(false)
const localVoucher = ref('')
const displayEventInfo = computed(() => store.displayEventInfo || (store.displayEventInfo === null && (store.events || store.weeks || store.days)))
const idVoucherInput = computed(() => `${store.htmlId}-voucher-input`)
const ariaLabelledby = computed(() => `${store.htmlId}-voucher-headline`)
const idCartExistsMsg = computed(() => `${store.htmlId}-cart-exists`)
const buyLabel = computed(() => {
let allFree = true
for (const cat of store.categories) {
for (const item of cat.items) {
for (const v of item.variations) {
if (v.price.gross !== '0.00') {
allFree = false
break
}
}
if ((item.variations.length === 0 && item.price.gross !== '0.00') || item.mandatory_priced_addons) {
allFree = false
break
}
}
if (!allFree) break
}
return allFree ? STRINGS.register : STRINGS.buy
})
const hiddenParams = computed(() => {
const params = new URL(store.getVoucherFormTarget()).searchParams
params.delete('iframe')
params.delete('take_cart_id')
return Array.from(params.entries())
})
const showVoucherForm = computed(() => store.vouchersExist && !store.disableVouchers && !store.voucherCode)
const consentParameterValue = computed(() => {
if (store.widgetData.consent) {
return encodeURIComponent(store.widgetData.consent)
}
return ''
})
const widgetDataJson = computed(() => {
const clonedData = { ...store.widgetData }
if (clonedData.consent) {
delete clonedData.consent
}
return JSON.stringify(clonedData)
})
const formAction = computed(() => {
const additionalParams = getAdditionalURLParams()
let checkoutUrl = `/${store.targetUrl.replace(/^[^\/]+:\/\/([^\/]+)\//, '')}w/${store.widgetId.replace('pretix-widget-', '')}/`
if (!store.cartExists) {
checkoutUrl += 'checkout/start'
}
if (additionalParams) {
checkoutUrl += `?${additionalParams}`
}
const cookieName = `pretix_widget_${store.targetUrl.replace(/[^a-zA-Z0-9]+/g, '_')}`
const cartIdCookie = document.cookie
.split('; ')
.find((row) => row.startsWith(`${cookieName}=`))
?.split('=')[1] || null
let formTarget = `${store.targetUrl}w/${store.widgetId.replace('pretix-widget-', '')}/cart/add?iframe=1&next=${encodeURIComponent(checkoutUrl)}`
if (cartIdCookie) {
formTarget += `&take_cart_id=${cartIdCookie}`
}
if (store.widgetData.consent) {
formTarget += `&consent=${encodeURIComponent(store.widgetData.consent)}`
}
return formTarget
})
const formTarget = computed(() => {
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
const isAndroid = navigator.userAgent.toLowerCase().includes('android')
if (isAndroid && isFirefox) {
return '_top'
}
return '_blank'
})
function getAdditionalURLParams (): string {
if (!window.location.search.includes('utm_')) {
return ''
}
const params = new URLSearchParams(window.location.search)
for (const [key] of params.entries()) {
if (!key.startsWith('utm_')) {
params.delete(key)
}
}
return params.toString()
}
function backToList () {
store.targetUrl = store.parentStack.pop() || store.targetUrl
store.error = null
if (!store.subevent) {
store.name = null
store.frontpageText = null
}
store.subevent = null
store.offset = 0
store.appendEvents = false
store.triggerLoadCallback()
if (store.events !== null) {
store.view = 'events'
} else if (store.days !== null) {
store.view = 'days'
} else {
store.view = 'weeks'
}
}
function calcItemsSelected () {
if (!form.value) return
const checkboxes = form.value.querySelectorAll<HTMLInputElement>('input[type=checkbox], input[type=radio]')
const hasChecked = Array.from(checkboxes).some((el) => el.checked)
const numberInputs = form.value.querySelectorAll<HTMLInputElement>('.pretix-widget-item-count-group input')
const hasQuantity = Array.from(numberInputs).some((el) => parseInt(el.value || '0') > 0)
isItemsSelected.value = hasChecked || hasQuantity
}
function focusVoucherField () {
voucherinput.value?.focus()
}
function handleBuy (event: Event) {
if (form.value) {
const formData = new FormData(form.value)
store.buy(formData, event)
}
}
function handleRedeem (event: Event) {
store.redeem(localVoucher.value, event)
}
onMounted(() => {
if (form.value) {
form.value.addEventListener('change', calcItemsSelected)
}
})
onBeforeUnmount(() => {
if (form.value) {
form.value.removeEventListener('change', calcItemsSelected)
}
})
watch(() => store.overlay?.frameShown, (newValue) => {
if (!newValue && form.value) {
form.value.reset()
calcItemsSelected()
}
})
</script>
<template lang="pug">
.pretix-widget-event-form
//- Back navigation
.pretix-widget-event-list-back(v-if="store.events || store.weeks || store.days")
a(v-if="!store.subevent", href="#", rel="back", @click.prevent.stop="backToList")
| &lsaquo; {{ STRINGS.back_to_list }}
a(v-if="store.subevent", href="#", rel="back", @click.prevent.stop="backToList")
| &lsaquo; {{ STRINGS.back_to_dates }}
//- Event name
.pretix-widget-event-header(v-if="displayEventInfo")
strong(role="heading", aria-level="2") {{ store.name }}
//- Date range
.pretix-widget-event-details(v-if="displayEventInfo && store.dateRange") {{ store.dateRange }}
//- Location
.pretix-widget-event-location(
v-if="displayEventInfo && store.location",
v-html="store.location"
)
//- Description
.pretix-widget-event-description(
v-if="displayEventInfo && store.frontpageText",
v-html="store.frontpageText"
)
//- Form start
form(
ref="form",
method="post",
:action="formAction",
:target="formTarget",
@submit="handleBuy"
)
input(v-if="store.voucherCode", type="hidden", name="_voucher_code", :value="store.voucherCode")
input(type="hidden", name="subevent", :value="store.subevent")
input(type="hidden", name="widget_data", :value="widgetDataJson")
input(v-if="consentParameterValue", type="hidden", name="consent", :value="consentParameterValue")
//- Error message
.pretix-widget-error-message(v-if="store.error") {{ store.error }}
//- Resume cart
.pretix-widget-info-message.pretix-widget-clickable(v-if="store.cartExists")
span(:id="idCartExistsMsg") {{ STRINGS.cart_exists }}
button.pretix-widget-resume-button(
type="button",
:aria-describedby="idCartExistsMsg",
@click.prevent.stop="store.resume()"
) {{ STRINGS.resume_checkout }}
//- Seating plan
.pretix-widget-seating-link-wrapper(v-if="store.hasSeatingPlan")
button.pretix-widget-seating-link(type="button", @click.prevent.stop="store.startseating()")
| {{ STRINGS.show_seating }}
//- Waiting list for seating plan
.pretix-widget-seating-waitinglist(v-if="store.hasSeatingPlan && store.hasSeatingPlanWaitinglist")
.pretix-widget-seating-waitinglist-text {{ STRINGS.seating_plan_waiting_list }}
.pretix-widget-seating-waitinglist-button-wrap
button.pretix-widget-seating-waitinglist-button(@click.prevent.stop="store.startwaiting()")
| {{ STRINGS.waiting_list }}
.pretix-widget-clear
//- Product list
Category(v-for="category in store.categories", :key="category.id", :category="category")
//- Buy button
.pretix-widget-action(v-if="store.displayAddToCart")
button(
v-if="!store.cartExists || isItemsSelected",
type="submit",
:aria-describedby="idCartExistsMsg"
) {{ buyLabel }}
button(
v-else,
type="button",
:aria-describedby="idCartExistsMsg",
@click.prevent.stop="store.resume()"
) {{ STRINGS.resume_checkout }}
//- Voucher form
form(
v-if="showVoucherForm",
method="get",
:action="store.getVoucherFormTarget()",
target="_blank"
)
.pretix-widget-voucher
h3.pretix-widget-voucher-headline(:id="ariaLabelledby") {{ STRINGS.redeem_voucher }}
.pretix-widget-voucher-text(
v-if="store.voucherExplanationText",
v-html="store.voucherExplanationText"
)
.pretix-widget-voucher-input-wrap
input.pretix-widget-voucher-input(
:id="idVoucherInput",
ref="voucherinput",
v-model="localVoucher",
type="text",
name="voucher",
:placeholder="STRINGS.voucher_code",
:aria-labelledby="ariaLabelledby"
)
input(
v-for="p in hiddenParams",
:key="p[0]",
type="hidden",
:name="p[0]",
:value="p[1]"
)
.pretix-widget-voucher-button-wrap
button(@click="handleRedeem") {{ STRINGS.redeem }}
.pretix-widget-clear
</template>
<style lang="sass">
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +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'
const props = defineProps<{
price: Price
freePrice: boolean
fieldName: string
suggestedPrice?: Price | null
originalPrice?: string | null
mandatoryPricedAddons?: boolean
itemId: number
}>()
const store = inject(StoreKey)!
const priceBoxId = computed(() => `${store.htmlId}-item-pricebox-${props.itemId}`)
const priceDescId = computed(() => `${store.htmlId}-item-pricedesc-${props.itemId}`)
const ariaLabelledby = computed(() => `${store.htmlId}-item-label-${props.itemId} ${priceBoxId.value}`)
const displayPrice = computed(() => {
if (store.displayNetPrices) {
return floatformat(parseFloat(props.price.net), 2)
}
return floatformat(parseFloat(props.price.gross), 2)
})
const displayPriceNonlocalized = computed(() => {
if (store.displayNetPrices) {
return parseFloat(props.price.net).toFixed(2)
}
return parseFloat(props.price.gross).toFixed(2)
})
const suggestedPriceNonlocalized = computed(() => {
const price = props.suggestedPrice ?? props.price
if (store.displayNetPrices) {
return parseFloat(price.net).toFixed(2)
}
return parseFloat(price.gross).toFixed(2)
})
// TODO BAD
const originalLine = computed(() => {
if (!props.originalPrice) return ''
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(parseFloat(props.originalPrice), 2)}`
})
// TODO BAD
const priceline = computed(() => {
if (props.price.gross === '0.00') {
if (props.mandatoryPricedAddons && !props.originalPrice) {
return '\u00A0' // nbsp
}
return STRINGS.free
}
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${displayPrice.value}`
})
const originalPriceAriaLabel = computed(() => interpolate(STRINGS.original_price, [stripHTML(originalLine.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 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 interpolate(STRINGS.tax_incl, {
rate: autofloatformat(props.price.rate, 2),
taxname: props.price.name,
}, true)
}
})
const showTaxline = computed(() => props.price.rate !== '0.00' && props.price.gross !== '0.00')
</script>
<template lang="pug">
.pretix-widget-pricebox
span(v-if="!freePrice && !originalPrice", v-html="priceline")
span(v-if="!freePrice && originalPrice")
del.pretix-widget-pricebox-original-price(:aria-label="originalPriceAriaLabel", v-html="originalLine")
|
ins.pretix-widget-pricebox-new-price(:aria-label="newPriceAriaLabel", v-html="priceline")
div(v-if="freePrice")
span.pretix-widget-pricebox-currency(:id="priceBoxId") {{ store.currency }}
|
input.pretix-widget-pricebox-price-input(
type="number",
placeholder="0",
:min="displayPriceNonlocalized",
:value="suggestedPriceNonlocalized",
:name="fieldName",
step="any",
:aria-labelledby="ariaLabelledby",
:aria-describedby="priceDescId"
)
small.pretix-widget-pricebox-tax(v-if="showTaxline", :id="priceDescId") {{ taxline }}
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,97 @@
<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'
const props = defineProps<{
variation: Variation
item: Item
category: Category
}>()
const store = inject(StoreKey)!
const origPrice = computed(() => props.variation.original_price || props.item.original_price)
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 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 ariaLabelledby = computed(() => `${variationLabelId.value} ${variationPriceId.value}`)
const headingLevel = computed(() => props.category.name ? '5' : '4')
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
for (const cat of store.categories) {
for (const item of cat.items) {
if (item.has_variations) {
cntItems += item.variations.length
hasPriced = true
} else {
cntItems++
hasPriced = hasPriced || item.price.gross !== '0.00' || item.free_price
}
}
}
return hasPriced || cntItems > 1
})
</script>
<template lang="pug">
.pretix-widget-variation(
:data-id="variation.id",
role="group",
:aria-labelledby="ariaLabelledby",
:aria-describedby="variationDescId"
)
.pretix-widget-item-row
//- Variation description
.pretix-widget-item-info-col
.pretix-widget-item-title-and-description
strong.pretix-widget-item-title(
:id="variationLabelId",
role="heading",
:aria-level="headingLevel"
) {{ variation.value }}
.pretix-widget-item-description(
v-if="variation.description",
:id="variationDescId",
v-html="variation.description"
)
p.pretix-widget-item-meta(v-if="showQuotaLeft")
small {{ quotaLeftStr }}
//- Price
.pretix-widget-item-price-col(:id="variationPriceId")
PriceBox(
v-if="showPrices",
:price="variation.price",
:freePrice="item.free_price",
:originalPrice="origPrice",
:mandatoryPricedAddons="item.mandatory_priced_addons",
:suggestedPrice="variation.suggested_price",
:fieldName="`price_${item.id}_${variation.id}`",
:itemId="item.id"
)
span(v-if="!showPrices") &nbsp;
//- Availability
.pretix-widget-item-availability-col
AvailBox(:item="item", :variation="variation")
.pretix-widget-clear
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,74 @@
<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'
const emit = defineEmits<{
mounted: []
}>()
const store = inject(StoreKey)!
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,
}))
watch(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)
}
store.reload()
emit('mounted')
})
watch(() => store.view, (newValue, oldValue) => {
if (oldValue && wrapper.value) {
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")
div(:class="classObject")
.pretix-widget-loading(v-show="store.loading > 0")
svg(width="128", height="128", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
path.pretix-widget-primary-color(d="M1152 896q0-106-75-181t-181-75-181 75-75 181 75 181 181 75 181-75 75-181zm512-109v222q0 12-8 23t-20 13l-185 28q-19 54-39 91 35 50 107 138 10 12 10 25t-9 23q-27 37-99 108t-94 71q-12 0-26-9l-138-108q-44 23-91 38-16 136-29 186-7 28-36 28h-222q-14 0-24.5-8.5t-11.5-21.5l-28-184q-49-16-90-37l-141 107q-10 9-25 9-14 0-25-11-126-114-165-168-7-10-7-23 0-12 8-23 15-21 51-66.5t54-70.5q-27-50-41-99l-183-27q-13-2-21-12.5t-8-23.5v-222q0-12 8-23t19-13l186-28q14-46 39-92-40-57-107-138-10-12-10-24 0-10 9-23 26-36 98.5-107.5t94.5-71.5q13 0 26 10l138 107q44-23 91-38 16-136 29-186 7-28 36-28h222q14 0 24.5 8.5t11.5 21.5l28 184q49 16 90 37l142-107q9-9 24-9 13 0 25 10 129 119 165 170 7 8 7 22 0 12-8 23-15 21-51 66.5t-54 70.5q26 50 41 98l183 28q13 2 21 12.5t8 23.5z")
.pretix-widget-error-message(v-if="store.error && store.view !== 'event'") {{ store.error }}
.pretix-widget-error-action(v-if="store.error && store.connectionError")
a.pretix-widget-button(:href="store.newTabTarget", target="_blank") {{ STRINGS.open_new_tab }}
EventForm(v-if="store.view === 'event'", ref="formcomp")
EventList(v-if="store.view === 'events'")
EventCalendar(v-if="store.view === 'weeks'", :mobile="mobile")
EventWeekCalendar(v-if="store.view === 'days'", :mobile="mobile")
.pretix-widget-clear
.pretix-widget-attribution(v-if="store.poweredby", v-html="store.poweredby")
Overlay
</template>
<style lang="sass">
</style>

View File

@@ -0,0 +1,3 @@
interface NamedNodeMap {
[key: string]: Attr | undefined;
}

View File

@@ -0,0 +1,117 @@
// Internationalization strings for the pretix widget
// Django's i18n file expects `this` to be the global object, but ES modules
// have `this` as undefined. Import as raw text and execute with a local context.
// TODO hack
import djangoI18nScript from '../../../jsi18n/en/djangojs.js?raw'
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
}
// Create a local context object to capture django without polluting window
const context: { django?: Django } = {}
new Function(djangoI18nScript).call(context)
const django = context.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'),
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'),
},
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'),
} as Record<string, string>,
} as const
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)
}

View File

@@ -0,0 +1,80 @@
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
}
type GetterReturnTypes<G> = {
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>
export function createStore<
S extends object,
G extends GettersTree<S>,
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>
}
): Store<S, G, A> {
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))
Object.defineProperty(store, key, {
get: () => computedRef.value,
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)
}
}
store.$reset = function () {
const cleanState = config.state()
const cleanKeys = new Set([
'$reset',
'$watch',
...Object.keys(cleanState),
...Object.keys(config.getters ?? {}),
...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]
}
}
// Set all state values from clean state
for (const key of Object.keys(cleanState) as (keyof S)[]) {
;(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)
}
return store
}

View File

@@ -0,0 +1,130 @@
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
}
}
interface PretixWidgetAPI {
build_widgets: boolean
widget_data: WidgetData
buildWidgets: () => void
open: (
targetUrl: string,
voucher?: string | null,
subevent?: string | number | null,
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>
}
const widgetlist: App[] = []
const buttonlist: App[] = []
window.PretixWidget = {
build_widgets: true,
widget_data: { referer: location.href },
// TODO move somewhere else and rename?
_loaded: [],
_closed: [],
buildWidgets,
open: openWidget,
addLoadListener (f) { this._loaded.push(f) },
addCloseListener (f) { this._closed.push(f) },
}
async function buildWidgets () {
// TODO what does this do?
document.createElement('pretix-widget')
document.createElement('pretix-button')
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}`))
}
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}`))
}
}
function openWidget (
targetUrl: string,
voucher?: string | null,
subevent?: string | number | null,
items?: { item: string; count: string }[],
widgetData?: Record<string, string>,
skipSslCheck?: boolean,
disableIframe?: boolean
): void {
if (!targetUrl.match(/\/$/)) {
targetUrl += '/'
}
const allWidgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data))
if (widgetData) {
Object.assign(allWidgetData, widgetData)
}
const root = document.createElement('div')
document.body.appendChild(root)
root.classList.add('pretix-widget-hidden')
const store = createWidgetStore({
targetUrl,
voucher: voucher ?? null,
subevent: subevent ?? null,
skipSsl: skipSslCheck ?? false,
disableIframe: disableIframe ?? false,
widgetData: allWidgetData,
htmlId: makeid(16),
isButton: true,
buttonItems: items ?? [],
buttonText: '',
})
const app = createApp(ButtonComponent)
app.provide(StoreKey, store)
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-widget-open]', info, error)
}
app.mount(root)
nextTick(() => {
if (store.useIframe) {
const form = root.querySelector('form') as HTMLFormElement
if (form) {
const formData = new FormData(form)
store.buy(formData)
}
} else {
const form = root.querySelector('form') as HTMLFormElement
if (form) form.submit()
}
})
}
if (typeof window.pretixWidgetCallback !== 'undefined') {
window.pretixWidgetCallback()
}
if (window.PretixWidget.build_widgets) {
window.PretixWidget.buildWidgets()
}
// TODO debug exposes

View File

@@ -0,0 +1,519 @@
import { nextTick, type InjectionKey } from 'vue'
import { createStore } from '~/lib/store'
import { fetchProductList, submitCart, checkAsyncTask, ApiError } from '~/api'
import type { CartResponse } from '~/api'
import { STRINGS } from '~/i18n'
import { setCookie, getCookie, makeid } from '~/utils'
import type { Category, DayEntry, EventEntry, LightboxState, MetaFilterField, WidgetData } from '~/types'
export const globalWidgetId = makeid(16)
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
// Button-specific
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,
// Configuration
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,
widgetData: config.widgetData,
widgetId: `pretix-widget-${globalWidgetId}`,
htmlId: config.htmlId,
// View state
view: null as 'event' | 'events' | 'weeks' | 'days' | null,
loading: config.isButton ? 0 : 1,
error: null as string | null,
connectionError: false,
frameDismissed: false,
// Event data
name: null as string | null,
dateRange: null as string | null,
location: null as string | null,
frontpageText: null as string | null,
categories: [] as Category[],
currency: '',
displayNetPrices: false,
voucherExplanationText: null as string | null,
displayAddToCart: false,
waitingListEnabled: false,
showVariationsExpanded: !!config.variations,
cartId: null as string | null,
cartExists: false,
vouchersExist: false,
hasSeatingPlan: false,
hasSeatingPlanWaitinglist: false,
itemnum: 0,
poweredby: '',
// Calendar/list data
events: null as EventEntry[] | null,
weeks: null as DayEntry[][] | null,
days: null as DayEntry[] | null,
date: null as string | null,
week: null as [number, number] | null,
hasMoreEvents: false,
offset: 0,
appendEvents: false,
metaFilterFields: [] as MetaFilterField[],
// UI state
mobile: false,
// Button-specific
isButton: config.isButton ?? false,
items: (config.buttonItems ?? []) as { item: string; count: string }[],
buttonText: config.buttonText ?? '',
// Overlay (always initialized, no null guards)
overlay: {
frameSrc: '',
frameLoading: false,
frameShown: false,
errorMessage: null as string | null,
errorUrlAfter: null as string | null,
errorUrlAfterNewTab: false,
lightbox: null as LightboxState | null,
},
// Async task state
asyncTaskId: null as string | null,
asyncTaskCheckUrl: null as string | null,
asyncTaskTimeout: null as ReturnType<typeof setTimeout> | null,
asyncTaskInterval: 100,
voucher: null as string | null,
}),
getters: {
useIframe (): boolean {
if ((window as any).crossOriginIsolated === true) return false
return !this.disableIframe && (this.skipSsl || /https.*/.test(document.location.protocol))
},
cookieName (): string {
return `pretix_widget_${this.targetUrl.replace(/[^a-zA-Z0-9]+/g, '_')}`
},
cartIdFromCookie (): string | null {
return getCookie(this.cookieName) ?? null
},
consentParameter (): string {
if (this.widgetData.consent) {
return `&consent=${encodeURIComponent(this.widgetData.consent)}`
}
return ''
},
additionalURLParams (): string {
if (!window.location.search.includes('utm_')) {
return ''
}
const params = new URLSearchParams(window.location.search)
for (const [key] of params.entries()) {
if (!key.startsWith('utm_')) {
params.delete(key)
}
}
return params.toString()
},
newTabTarget (): string {
return this.subevent ? `${this.targetUrl}${this.subevent}/` : this.targetUrl
},
},
actions: {
triggerLoadCallback () {
nextTick(() => {
for (const callback of (window as any).PretixWidget._loaded || []) {
callback()
}
})
},
async reload (opt: { focus?: string } = {}) {
if (this.isButton) return
let url: string
if (this.subevent) {
url = `${this.targetUrl}${this.subevent}/widget/product_list?lang=${LANG}`
} else {
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)}`
const cartIdCookie = this.cartIdFromCookie
if (cartIdCookie) url += `&cart_id=${encodeURIComponent(cartIdCookie)}`
if (this.date !== null) {
url += `&date=${this.date.substring(0, 7)}`
} else if (this.week !== null) {
url += `&date=${this.week[0]}-W${this.week[1]}`
}
if (this.style !== null) url += `&style=${encodeURIComponent(this.style)}`
try {
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)
if (newUrl !== oldUrl) {
let adjustedUrl = newUrl
if (this.subevent) {
adjustedUrl = adjustedUrl.substring(0, adjustedUrl.lastIndexOf('/', adjustedUrl.length - 1) + 1)
}
this.targetUrl = adjustedUrl
this.reload()
return
}
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 ?? []
} 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 ?? []
} 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 ?? []
} 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.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.cartId = cartIdCookie
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 ?? ''
if (this.loading > 0) {
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.startseating()
} else if (opt.focus) {
nextTick(() => {
document.querySelector<HTMLElement>(opt.focus!)?.focus()
})
}
} catch (e) {
this.categories = []
this.currency = ''
if (e instanceof ApiError && e.status === 429) {
this.error = STRINGS.loading_error_429
} else {
this.error = STRINGS.loading_error
}
this.connectionError = true
if (this.loading > 0) {
this.loading--
this.triggerLoadCallback()
}
}
},
getFormAction (): 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
}
let checkoutUrl = `/${this.targetUrl.replace(/^[^/]+:\/\/([^/]+)\//, '')}w/${globalWidgetId}/`
if (!this.cartExists) {
checkoutUrl += 'checkout/start'
}
if (this.additionalURLParams) {
checkoutUrl += `?${this.additionalURLParams}`
}
let formTarget = `${this.targetUrl}w/${globalWidgetId}/cart/add?iframe=1&next=${encodeURIComponent(checkoutUrl)}`
if (this.cartIdFromCookie) {
formTarget += `&take_cart_id=${this.cartIdFromCookie}`
}
formTarget += this.consentParameter
return formTarget
},
getVoucherFormTarget (): string {
let formTarget = `${this.targetUrl}w/${globalWidgetId}/redeem?iframe=1&locale=${LANG}`
if (this.cartIdFromCookie) {
formTarget += `&take_cart_id=${this.cartIdFromCookie}`
}
if (this.subevent) {
formTarget += `&subevent=${this.subevent}`
}
if (this.widgetData) {
formTarget += `&widget_data=${encodeURIComponent(JSON.stringify(this.widgetData))}`
}
formTarget += this.consentParameter
if (this.additionalURLParams) {
formTarget += `&${this.additionalURLParams}`
}
return formTarget
},
handleCartResponse (data: CartResponse) {
if (data.redirect) {
if (data.cart_id) {
this.cartId = data.cart_id
setCookie(this.cookieName, data.cart_id, 30)
}
let redirectUrl = data.redirect
if (redirectUrl.substring(0, 1) === '/') {
redirectUrl = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, '$1')}${redirectUrl}`
}
let url = redirectUrl
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 += this.consentParameter
if (this.additionalURLParams) {
url += `&${this.additionalURLParams}`
}
if (data.success === false) {
url = url.replace(/checkout\/start/g, '')
this.overlay.errorMessage = data.message ?? null
if (data.has_cart) {
this.overlay.errorUrlAfter = url
}
this.overlay.frameLoading = false
} else {
this.overlay.frameSrc = url
}
} else if (data.async_id && data.check_url) {
this.asyncTaskId = data.async_id
this.asyncTaskCheckUrl = `${this.targetUrl.replace(/^([^/]+:\/\/[^/]+)\/.*$/, '$1')}${data.check_url}`
this.asyncTaskTimeout = window.setTimeout(() => this.pollAsyncTask(), this.asyncTaskInterval)
this.asyncTaskInterval = 250
}
},
async pollAsyncTask () {
if (!this.asyncTaskCheckUrl) return
try {
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
} else {
this.asyncTaskTimeout = window.setTimeout(() => this.pollAsyncTask(), 1000)
}
}
},
async buy (formData: FormData, event?: Event) {
if (this.useIframe) {
if (event) event.preventDefault()
} else {
return
}
if (this.isButton && this.items.length === 0) {
if (this.voucherCode) {
this.voucherOpen(this.voucherCode)
} else {
this.resume()
}
return
}
const url = `${this.getFormAction()}&locale=${LANG}&ajax=1`
this.overlay.frameLoading = true
this.asyncTaskInterval = 100
try {
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
} else if (e.status === 405) {
this.targetUrl = e.responseUrl.substring(0, e.responseUrl.indexOf('/cart/add') - 18)
this.overlay.frameLoading = false
} else {
this.overlay.errorMessage = STRINGS.cart_error
this.overlay.frameLoading = false
}
} else {
this.overlay.errorMessage = STRINGS.cart_error
this.overlay.frameLoading = false
}
}
},
redeem (voucherCode: string, event?: Event) {
if (this.useIframe) {
if (event) event.preventDefault()
this.voucherOpen(voucherCode)
}
},
voucherOpen (voucherCode: string) {
const redirectUrl = `${this.getVoucherFormTarget()}&voucher=${encodeURIComponent(voucherCode)}`
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
} else {
window.open(redirectUrl)
}
},
resume () {
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/`
if (this.subevent && !this.cartId) {
redirectUrl += `${this.subevent}/`
}
redirectUrl += `?iframe=1&locale=${LANG}`
if (this.cartId) {
redirectUrl += `&take_cart_id=${this.cartId}`
}
if (this.widgetData) {
redirectUrl += `&widget_data=${encodeURIComponent(JSON.stringify(this.widgetData))}`
}
redirectUrl += this.consentParameter
if (this.additionalURLParams) {
redirectUrl += `&${this.additionalURLParams}`
}
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
} else {
window.open(redirectUrl)
}
},
startwaiting () {
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/waitinglist/?iframe=1&locale=${LANG}`
if (this.subevent) {
redirectUrl += `&subevent=${this.subevent}`
}
if (this.additionalURLParams) {
redirectUrl += `&${this.additionalURLParams}`
}
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
} else {
window.open(redirectUrl)
}
},
startseating () {
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}`
if (this.subevent) {
redirectUrl += `/${this.subevent}`
}
redirectUrl += `/seatingframe/?iframe=1&locale=${LANG}`
if (this.voucherCode) {
redirectUrl += `&voucher=${encodeURIComponent(this.voucherCode)}`
}
if (this.cartId) {
redirectUrl += `&take_cart_id=${this.cartId}`
}
if (this.widgetData) {
redirectUrl += `&widget_data=${encodeURIComponent(JSON.stringify(this.widgetData))}`
}
redirectUrl += this.consentParameter
if (this.additionalURLParams) {
redirectUrl += `&${this.additionalURLParams}`
}
if (this.useIframe) {
this.overlay.frameSrc = redirectUrl
} else {
window.open(redirectUrl)
}
}
}
})
}

View File

@@ -0,0 +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
}
export interface Availability {
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
}
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
}
export interface Category {
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
}
export interface DayEntry {
date: string
day_formatted: string
events: EventEntry[]
}
export interface MetaFilterField {
key: string
label: string
choices: [string, string][]
}
export interface LightboxState {
image: string
description: string
loading?: boolean
}
export interface WidgetData {
referer: string
consent?: string
[key: string]: string | undefined
}

View File

@@ -0,0 +1,100 @@
// Utility functions for the pretix widget
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};path=/`
}
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 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
}
// 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)
}
const parts = roundTo(val, places).toFixed(places).split('.')
if (places === 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
parts[0] = parts[0].replace(
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
if (numVal === roundTo(numVal, 0)) {
places = 0
}
return floatformat(numVal, places)
}
// String/number utilities
export function padNumber (number: number, size = 2): string {
let s = String(number)
while (s.length < size) {
s = `0${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
// 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
}
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))
}
return text
}
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 || ''
}
// docReady - DOM ready detection (returns a Promise)
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 })
})
}

View File

@@ -0,0 +1 @@
declare const LANG: string

View File

@@ -0,0 +1,69 @@
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
if (!targetUrl.match(/\/$/)) {
targetUrl += '/'
}
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 widgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data))
for (const attr of Array.from(element.attributes)) {
if (attr.name.match(/^data-.*$/)) {
widgetData[attr.name.replace(/^data-/, '')] = attr.value
}
}
const store = createWidgetStore({
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,
displayEventInfo,
filter: element.attributes.filter?.value || null,
items: element.attributes.items?.value || null,
categories: element.attributes.categories?.value || null,
variations: element.attributes.variations?.value || null,
widgetData,
htmlId: htmlId || element.id || makeid(16),
})
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
}
}
})
// TODO I don't think we need this anymore in vue3
// if (element.tagName !== 'pretix-widget') {
// element.innerHTML = '<pretix-widget></pretix-widget>'
// // we need to watch the container as well as the replaced root-node (see mounted())
// observer.observe(element, observerOptions)
// }
const app = createApp(WidgetComponent)
app.provide(StoreKey, store)
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-widget]', info, error)
}
app.mount(element)
observer.observe(element, { attributes: true })
return app
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../../../tsconfig.json",
"include": ["src/**/*"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
},
"types": ["node", "vite/client"]
}
}

View File

@@ -0,0 +1,33 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue()
],
resolve: {
alias: {
'~': import.meta.dirname + '/src',
},
},
build: {
manifest: true,
outDir: import.meta.dirname + '/../../../../../static.dist/vite/widget',
rollupOptions: {
input: {
main: import.meta.dirname + '/src/main.ts',
},
output: {
format: 'iife',
entryFileNames: 'widget.js',
assetFileNames: 'widget.[ext]',
},
},
},
optimizeDeps: {
exclude: ['moment', 'jquery']
},
define: {
LANG: JSON.stringify(process.env.PRETIX_WIDGET_LANG || 'en')
}
})