feat(events): surface the buy-ticket CTA above the description + sticky bar #149
1 changed files with 99 additions and 18 deletions
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { format } from 'date-fns'
|
||||
|
|
@ -197,6 +198,38 @@ function 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
|
||||
// buyer may have just paid). The inventory side updates automatically
|
||||
// via the relay republish from the events extension.
|
||||
|
|
@ -210,7 +243,7 @@ function goToMyTickets() {
|
|||
</script>
|
||||
|
||||
<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
|
||||
prominent icon button; Scan moves into the tickets section
|
||||
where it replaces the Buy-ticket CTA for the host. -->
|
||||
|
|
@ -319,13 +352,6 @@ function goToMyTickets() {
|
|||
</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
|
||||
OUTSIDE the ticketInfo gate so it appears even when the
|
||||
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
|
||||
tickets_* tags on the published event). Skipped for the
|
||||
host entirely — they have the Scan CTA above and don't
|
||||
need a Buy CTA for their own event. -->
|
||||
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
||||
need a Buy CTA for their own event. Placed ABOVE the
|
||||
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
|
||||
v-if="ownedPaidCount > 0"
|
||||
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"
|
||||
>
|
||||
<Ticket class="w-4 h-4" />
|
||||
{{ ownedPaidCount > 0
|
||||
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
||||
: 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>
|
||||
{{ buyLabel }}
|
||||
<span class="ml-2 opacity-80 font-normal">{{ priceLabel }}</span>
|
||||
</Button>
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
|
|
@ -402,6 +429,13 @@ function goToMyTickets() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Description -->
|
||||
<div class="prose prose-sm max-w-none text-foreground">
|
||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<PurchaseTicketDialog
|
||||
v-if="purchaseEvent"
|
||||
:is-open="showPurchaseDialog"
|
||||
|
|
@ -417,6 +451,53 @@ function goToMyTickets() {
|
|||
@update:is-open="showReturnDialog = $event"
|
||||
/>
|
||||
|
||||
<!-- Sticky buy bar — see showStickyCta. Sits above the bottom nav
|
||||
(h-14 + safe-area inset) and mirrors the inline CTA. -->
|
||||
<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"
|
||||
class="fixed left-0 right-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
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>
|
||||
|
||||
<!-- External references -->
|
||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||
<!-- References would go here in future phases -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue