Compare commits
8 commits
67583b7074
...
77b0bc557c
| Author | SHA1 | Date | |
|---|---|---|---|
| 77b0bc557c | |||
| 967233be40 | |||
| 9c0abfae1f | |||
| 4faa57f87a | |||
| b080d802c5 | |||
| 6e4e2f3d0c | |||
| 82f7e5659a | |||
| 9eb75a2017 |
8 changed files with 209 additions and 25 deletions
|
|
@ -96,6 +96,10 @@
|
||||||
/* Default palette: Catppuccin (Latte for light, Mocha for dark).
|
/* Default palette: Catppuccin (Latte for light, Mocha for dark).
|
||||||
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
|
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
|
||||||
:root {
|
:root {
|
||||||
|
/* Rendered height of the fixed BottomNav (h-14 + safe-area + border), kept
|
||||||
|
current by BottomNav.vue. Pin anything that must sit flush above the nav
|
||||||
|
with `bottom: var(--bottom-nav-height)`; 0 when no nav is mounted. */
|
||||||
|
--bottom-nav-height: 0px;
|
||||||
--background: oklch(0.9578 0.0058 264.5321);
|
--background: oklch(0.9578 0.0058 264.5321);
|
||||||
--foreground: oklch(0.4355 0.0430 279.3250);
|
--foreground: oklch(0.4355 0.0430 279.3250);
|
||||||
--card: oklch(1.0000 0 0);
|
--card: oklch(1.0000 0 0);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
import { useResizeObserver } from '@vueuse/core'
|
||||||
|
|
||||||
export interface BottomTab {
|
export interface BottomTab {
|
||||||
/** Translated label shown under the icon. */
|
/** Translated label shown under the icon. */
|
||||||
|
|
@ -33,6 +35,22 @@ interface Props {
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Publish the nav's rendered height (h-14 + safe-area padding + border,
|
||||||
|
// whatever the device makes of it) as `--bottom-nav-height` on <html>, so
|
||||||
|
// anything that needs to sit flush above the nav — sticky action bars,
|
||||||
|
// toasts — can use `bottom: var(--bottom-nav-height)` instead of
|
||||||
|
// re-deriving the CSS and drifting when insets or font scaling differ.
|
||||||
|
const navRef = ref<HTMLElement | null>(null)
|
||||||
|
function publishHeight() {
|
||||||
|
const h = navRef.value?.getBoundingClientRect().height ?? 0
|
||||||
|
document.documentElement.style.setProperty('--bottom-nav-height', `${Math.round(h)}px`)
|
||||||
|
}
|
||||||
|
useResizeObserver(navRef, publishHeight)
|
||||||
|
onMounted(publishHeight)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.documentElement.style.setProperty('--bottom-nav-height', '0px')
|
||||||
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
function onTabClick(tab: BottomTab) {
|
function onTabClick(tab: BottomTab) {
|
||||||
|
|
@ -51,6 +69,7 @@ function isTabActive(tab: BottomTab): boolean {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav
|
<nav
|
||||||
|
ref="navRef"
|
||||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ interface Props {
|
||||||
autoApprove?: boolean
|
autoApprove?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instance name used in the From display name of ticket emails
|
||||||
|
// (the LNbits site title on the backend; the standalone's brand here).
|
||||||
|
const siteName = (import.meta.env.VITE_APP_NAME as string) || 'Events'
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:open': [value: boolean]
|
'update:open': [value: boolean]
|
||||||
|
|
@ -142,8 +146,15 @@ const formSchema = toTypedSchema(
|
||||||
price_per_ticket: z.number().min(0).default(0),
|
price_per_ticket: z.number().min(0).default(0),
|
||||||
email_notifications: z.boolean().default(false),
|
email_notifications: z.boolean().default(false),
|
||||||
nostr_notifications: z.boolean().default(false),
|
nostr_notifications: z.boolean().default(false),
|
||||||
|
copy_to_organizer: z.boolean().default(false),
|
||||||
notification_subject: z.string().max(200).default(''),
|
notification_subject: z.string().max(200).default(''),
|
||||||
notification_body: z.string().max(2000).default(''),
|
notification_body: z.string().max(2000).default(''),
|
||||||
|
organizer_name: z.string().trim().max(100).default(''),
|
||||||
|
reply_to_email: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.default('')
|
||||||
|
.refine((v) => v === '' || z.string().email().safeParse(v).success, 'Invalid email address'),
|
||||||
})
|
})
|
||||||
.superRefine((v, ctx) => {
|
.superRefine((v, ctx) => {
|
||||||
// End must not precede start. Compare on the folded date+time
|
// End must not precede start. Compare on the folded date+time
|
||||||
|
|
@ -191,8 +202,11 @@ const form = useForm({
|
||||||
price_per_ticket: 0,
|
price_per_ticket: 0,
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
|
copy_to_organizer: false,
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: '',
|
notification_body: '',
|
||||||
|
organizer_name: '',
|
||||||
|
reply_to_email: '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -297,8 +311,11 @@ async function populateFromEvent(event: TicketedEvent) {
|
||||||
price_per_ticket: event.price_per_ticket ?? 0,
|
price_per_ticket: event.price_per_ticket ?? 0,
|
||||||
email_notifications: event.extra?.email_notifications ?? false,
|
email_notifications: event.extra?.email_notifications ?? false,
|
||||||
nostr_notifications: event.extra?.nostr_notifications ?? false,
|
nostr_notifications: event.extra?.nostr_notifications ?? false,
|
||||||
|
copy_to_organizer: event.extra?.copy_to_organizer ?? false,
|
||||||
notification_subject: event.extra?.notification_subject ?? '',
|
notification_subject: event.extra?.notification_subject ?? '',
|
||||||
notification_body: event.extra?.notification_body ?? '',
|
notification_body: event.extra?.notification_body ?? '',
|
||||||
|
organizer_name: event.extra?.organizer_name ?? '',
|
||||||
|
reply_to_email: event.extra?.reply_to_email ?? '',
|
||||||
})
|
})
|
||||||
selectedCategories.value = [...(event.categories ?? [])]
|
selectedCategories.value = [...(event.categories ?? [])]
|
||||||
if (event.banner) {
|
if (event.banner) {
|
||||||
|
|
@ -423,8 +440,11 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
payment_methods: formValues.payment_methods,
|
payment_methods: formValues.payment_methods,
|
||||||
email_notifications: formValues.email_notifications,
|
email_notifications: formValues.email_notifications,
|
||||||
nostr_notifications: formValues.nostr_notifications,
|
nostr_notifications: formValues.nostr_notifications,
|
||||||
|
copy_to_organizer: formValues.copy_to_organizer,
|
||||||
notification_subject: formValues.notification_subject,
|
notification_subject: formValues.notification_subject,
|
||||||
notification_body: formValues.notification_body,
|
notification_body: formValues.notification_body,
|
||||||
|
organizer_name: formValues.organizer_name,
|
||||||
|
reply_to_email: formValues.reply_to_email || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
|
|
@ -826,6 +846,18 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="copy_to_organizer">
|
||||||
|
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3 sm:col-span-2">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<FormLabel class="text-sm">Send me a copy of each ticket email</FormLabel>
|
||||||
|
<FormDescription class="text-xs">Goes to the reply-to address below, or your account email.</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="notification_subject">
|
<FormField v-slot="{ componentField }" name="notification_subject">
|
||||||
|
|
@ -851,6 +883,35 @@ const handleOpenChange = (open: boolean) => {
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Sender identity: the From address stays the instance
|
||||||
|
mailbox (that is what DKIM signs); the organizer only
|
||||||
|
controls the display name and where replies land. -->
|
||||||
|
<FormField v-slot="{ componentField }" name="organizer_name">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel class="text-sm">Organizer name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Château du Faune" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription class="text-xs">
|
||||||
|
Ticket emails arrive from "Organizer name via {{ siteName }}".
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="reply_to_email">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel class="text-sm">Reply-to email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" inputmode="email" autocapitalize="none" placeholder="you@example.com" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription class="text-xs">
|
||||||
|
Where attendee replies go. Leave blank to use your account email.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,14 @@ const isNonApproved = computed(
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- `min-w-0`: the card is a grid item, and a grid item's default
|
||||||
|
`min-width: auto` resolves to its min-content width. The nowrap
|
||||||
|
`truncate` rows below (location, organizer) contribute their
|
||||||
|
full text width to that, so a long address would widen the
|
||||||
|
whole column past the viewport on phones. Letting the item
|
||||||
|
shrink is what makes `truncate` actually truncate. -->
|
||||||
<Card
|
<Card
|
||||||
class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
class="relative min-w-0 cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||||
@click="emit('click', event)"
|
@click="emit('click', event)"
|
||||||
>
|
>
|
||||||
<!-- Wash-out wrapper. The pending/rejected status badge below sits
|
<!-- Wash-out wrapper. The pending/rejected status badge below sits
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import brandLogo from '@brand/logo.png'
|
import brandLogo from '@brand-app-logo?url'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the scannable `ticket://<id>` QR every ticket surface uses
|
* Render the scannable `ticket://<id>` QR every ticket surface uses
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,14 @@ export interface EventExtra {
|
||||||
nostr_notifications: boolean
|
nostr_notifications: boolean
|
||||||
notification_subject: string
|
notification_subject: string
|
||||||
notification_body: string
|
notification_body: string
|
||||||
|
/** Shown as "<organizer_name> via <site>" in the From line of ticket
|
||||||
|
* emails. Optional. */
|
||||||
|
organizer_name?: string
|
||||||
|
/** Reply-To for ticket emails; null/absent = the organizer's LNbits
|
||||||
|
* account email. */
|
||||||
|
reply_to_email?: string | null
|
||||||
|
/** BCC every ticket email to the reply-to address. */
|
||||||
|
copy_to_organizer?: boolean
|
||||||
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
|
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
|
||||||
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
|
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
|
||||||
* Same shape as upstream v2's `extra.payment_methods`. */
|
* Same shape as upstream v2's `extra.payment_methods`. */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useElementVisibility } from '@vueuse/core'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
@ -197,6 +198,38 @@ function handleCheckoutReturn() {
|
||||||
|
|
||||||
onMounted(handleCheckoutReturn)
|
onMounted(handleCheckoutReturn)
|
||||||
|
|
||||||
|
// --- Sticky buy bar --------------------------------------------------
|
||||||
|
// The ticket block sits right under the date/location so it is above the
|
||||||
|
// fold on most screens, but long descriptions still scroll it away. When
|
||||||
|
// it leaves the viewport a compact bar with the price + the same CTA
|
||||||
|
// pins above the bottom nav so the primary action is never more than a
|
||||||
|
// tap away. Hidden while a dialog is open (the dialog is the action).
|
||||||
|
const ticketBlockRef = ref<HTMLElement | null>(null)
|
||||||
|
const ticketBlockVisible = useElementVisibility(ticketBlockRef)
|
||||||
|
|
||||||
|
const priceLabel = computed(() => {
|
||||||
|
const info = event.value?.ticketInfo
|
||||||
|
if (!info) return ''
|
||||||
|
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const buyLabel = computed(() =>
|
||||||
|
ownedPaidCount.value > 0
|
||||||
|
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
||||||
|
: t('events.detail.buyTicket', 'Buy ticket'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const showStickyCta = computed(
|
||||||
|
() =>
|
||||||
|
!!event.value?.ticketInfo &&
|
||||||
|
!ownedLnbitsEvent.value &&
|
||||||
|
!isPast.value &&
|
||||||
|
(canBuyTicket.value || ownedPaidCount.value > 0) &&
|
||||||
|
!ticketBlockVisible.value &&
|
||||||
|
!showPurchaseDialog.value &&
|
||||||
|
!showReturnDialog.value,
|
||||||
|
)
|
||||||
|
|
||||||
// Re-fetch the user's tickets when the purchase dialog closes (the
|
// Re-fetch the user's tickets when the purchase dialog closes (the
|
||||||
// buyer may have just paid). The inventory side updates automatically
|
// buyer may have just paid). The inventory side updates automatically
|
||||||
// via the relay republish from the events extension.
|
// via the relay republish from the events extension.
|
||||||
|
|
@ -210,7 +243,7 @@ function goToMyTickets() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }">
|
||||||
<!-- Top bar — back-link only. Edit moves into the title row as a
|
<!-- Top bar — back-link only. Edit moves into the title row as a
|
||||||
prominent icon button; Scan moves into the tickets section
|
prominent icon button; Scan moves into the tickets section
|
||||||
where it replaces the Buy-ticket CTA for the host. -->
|
where it replaces the Buy-ticket CTA for the host. -->
|
||||||
|
|
@ -319,13 +352,6 @@ function goToMyTickets() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="prose prose-sm max-w-none text-foreground">
|
|
||||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Host's primary CTA is to scan tickets at the door. Lives
|
<!-- Host's primary CTA is to scan tickets at the door. Lives
|
||||||
OUTSIDE the ticketInfo gate so it appears even when the
|
OUTSIDE the ticketInfo gate so it appears even when the
|
||||||
event was published without AIO ticket tags — a host always
|
event was published without AIO ticket tags — a host always
|
||||||
|
|
@ -345,8 +371,15 @@ function goToMyTickets() {
|
||||||
by the calendar→Event converter from the AIO custom
|
by the calendar→Event converter from the AIO custom
|
||||||
tickets_* tags on the published event). Skipped for the
|
tickets_* tags on the published event). Skipped for the
|
||||||
host entirely — they have the Scan CTA above and don't
|
host entirely — they have the Scan CTA above and don't
|
||||||
need a Buy CTA for their own event. -->
|
need a Buy CTA for their own event. Placed ABOVE the
|
||||||
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
description on purpose: the buy action is the reason most
|
||||||
|
visitors open the page, and a long description must not
|
||||||
|
push it below the fold (a sticky bar covers the rest). -->
|
||||||
|
<div
|
||||||
|
v-if="event.ticketInfo && !ownedLnbitsEvent"
|
||||||
|
ref="ticketBlockRef"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-if="ownedPaidCount > 0"
|
v-if="ownedPaidCount > 0"
|
||||||
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||||
|
|
@ -376,14 +409,8 @@ function goToMyTickets() {
|
||||||
@click="openPurchaseDialog"
|
@click="openPurchaseDialog"
|
||||||
>
|
>
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
{{ ownedPaidCount > 0
|
{{ buyLabel }}
|
||||||
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
<span class="ml-2 opacity-80 font-normal">{{ priceLabel }}</span>
|
||||||
: t('events.detail.buyTicket', 'Buy ticket') }}
|
|
||||||
<span class="ml-2 opacity-80 font-normal">
|
|
||||||
{{ event.ticketInfo.price === 0
|
|
||||||
? t('events.detail.free')
|
|
||||||
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<p class="text-xs text-muted-foreground text-center">
|
<p class="text-xs text-muted-foreground text-center">
|
||||||
<span v-if="event.ticketInfo.available === undefined">
|
<span v-if="event.ticketInfo.available === undefined">
|
||||||
|
|
@ -402,6 +429,13 @@ function goToMyTickets() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="prose prose-sm max-w-none text-foreground">
|
||||||
|
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PurchaseTicketDialog
|
<PurchaseTicketDialog
|
||||||
v-if="purchaseEvent"
|
v-if="purchaseEvent"
|
||||||
:is-open="showPurchaseDialog"
|
:is-open="showPurchaseDialog"
|
||||||
|
|
@ -417,6 +451,59 @@ function goToMyTickets() {
|
||||||
@update:is-open="showReturnDialog = $event"
|
@update:is-open="showReturnDialog = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Sticky buy bar — see showStickyCta. Pinned flush above the
|
||||||
|
bottom nav via the height it publishes (--bottom-nav-height,
|
||||||
|
BottomNav.vue) and mirrors the inline CTA. -->
|
||||||
|
<!-- Teleported to <body>: inside the page the bar would inherit the
|
||||||
|
parent's space-y margin (margins offset fixed boxes too) and
|
||||||
|
float 24px above the nav. -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="translate-y-4 opacity-0"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-to-class="translate-y-4 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showStickyCta && event"
|
||||||
|
class="fixed inset-x-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||||
|
style="bottom: var(--bottom-nav-height)"
|
||||||
|
role="region"
|
||||||
|
:aria-label="t('events.detail.tickets')"
|
||||||
|
>
|
||||||
|
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
|
<span v-if="ownedPaidCount > 0">
|
||||||
|
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="event.ticketInfo?.available === undefined">
|
||||||
|
{{ priceLabel }} · {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ priceLabel }} · {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="canBuyTicket"
|
||||||
|
class="gap-1.5 shrink-0"
|
||||||
|
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
|
||||||
|
@click="openPurchaseDialog"
|
||||||
|
>
|
||||||
|
<Ticket class="w-4 h-4" />
|
||||||
|
{{ buyLabel }}
|
||||||
|
</Button>
|
||||||
|
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||||
|
<Ticket class="w-4 h-4" />
|
||||||
|
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- External references -->
|
<!-- External references -->
|
||||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||||
<!-- References would go here in future phases -->
|
<!-- References would go here in future phases -->
|
||||||
|
|
|
||||||
|
|
@ -198,13 +198,12 @@ function back() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Sticky bottom bar with qty stepper + add-to-cart.
|
<!-- Sticky bottom bar with qty stepper + add-to-cart.
|
||||||
Sits flush above BottomNav (h-14 + safe-area inset) on every
|
Sits flush above BottomNav on every breakpoint via the height
|
||||||
breakpoint. Previously `bottom-16` left an 8px gap on mobile
|
the nav publishes (--bottom-nav-height, BottomNav.vue) — no
|
||||||
(BottomNav is h-14 = 56px, not 64px), and `sm:bottom-0` made
|
hand-derived h-14 + safe-area arithmetic to drift. -->
|
||||||
it overlap the nav on desktop. -->
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-x-0 z-20 border-t border-border bg-background/95 px-4 py-3 backdrop-blur"
|
class="fixed inset-x-0 z-20 border-t border-border bg-background/95 px-4 py-3 backdrop-blur"
|
||||||
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
|
style="bottom: var(--bottom-nav-height)"
|
||||||
>
|
>
|
||||||
<div class="container mx-auto flex max-w-2xl items-center gap-3">
|
<div class="container mx-auto flex max-w-2xl items-center gap-3">
|
||||||
<div class="inline-flex items-center rounded-full border border-border bg-card">
|
<div class="inline-flex items-center rounded-full border border-border bg-card">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue