feat(events): surface the buy-ticket CTA above the description + sticky bar #149
1 changed files with 99 additions and 18 deletions
feat(events): surface the buy-ticket CTA above the description + sticky bar
The ticket block (owned banner, Buy ticket + availability, past/sold-out notices, and the host's Scan CTA) moved from below the description to directly under the date and location. A long description was pushing the primary action below the fold — on the Château du Faune event the button sat under ~1.7k characters of text. A sticky bar (title, price, tickets left, the same CTA) pins above the bottom nav whenever the inline block is scrolled out of view (useElementVisibility), and hides again when it is back, or while the purchase / return dialogs are open. Container gets extra bottom padding while the bar is up so the organizer card is never covered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
commit
9eb75a2017
|
|
@ -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,53 @@ function goToMyTickets() {
|
||||||
@update:is-open="showReturnDialog = $event"
|
@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 -->
|
<!-- 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 -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue