Compare commits
1 commit
77b0bc557c
...
67583b7074
| Author | SHA1 | Date | |
|---|---|---|---|
| 67583b7074 |
8 changed files with 25 additions and 158 deletions
|
|
@ -96,10 +96,6 @@
|
||||||
/* 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,8 +1,6 @@
|
||||||
<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. */
|
||||||
|
|
@ -35,22 +33,6 @@ 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) {
|
||||||
|
|
@ -69,7 +51,6 @@ 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)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,6 @@ 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(''),
|
organizer_name: z.string().trim().max(100).default(''),
|
||||||
|
|
@ -202,7 +201,6 @@ 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: '',
|
organizer_name: '',
|
||||||
|
|
@ -311,7 +309,6 @@ 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 ?? '',
|
organizer_name: event.extra?.organizer_name ?? '',
|
||||||
|
|
@ -440,7 +437,6 @@ 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,
|
organizer_name: formValues.organizer_name,
|
||||||
|
|
@ -846,18 +842,6 @@ 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">
|
||||||
|
|
|
||||||
|
|
@ -72,14 +72,8 @@ 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 min-w-0 cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
class="relative 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-app-logo?url'
|
import brandLogo from '@brand/logo.png'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the scannable `ticket://<id>` QR every ticket surface uses
|
* Render the scannable `ticket://<id>` QR every ticket surface uses
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ export interface EventExtra {
|
||||||
/** Reply-To for ticket emails; null/absent = the organizer's LNbits
|
/** Reply-To for ticket emails; null/absent = the organizer's LNbits
|
||||||
* account email. */
|
* account email. */
|
||||||
reply_to_email?: string | null
|
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,6 +1,5 @@
|
||||||
<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'
|
||||||
|
|
@ -198,38 +197,6 @@ 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.
|
||||||
|
|
@ -243,7 +210,7 @@ function goToMyTickets() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }">
|
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
||||||
<!-- 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. -->
|
||||||
|
|
@ -352,6 +319,13 @@ 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
|
||||||
|
|
@ -371,15 +345,8 @@ 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. Placed ABOVE the
|
need a Buy CTA for their own event. -->
|
||||||
description on purpose: the buy action is the reason most
|
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
||||||
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"
|
||||||
|
|
@ -409,8 +376,14 @@ function goToMyTickets() {
|
||||||
@click="openPurchaseDialog"
|
@click="openPurchaseDialog"
|
||||||
>
|
>
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
{{ buyLabel }}
|
{{ ownedPaidCount > 0
|
||||||
<span class="ml-2 opacity-80 font-normal">{{ priceLabel }}</span>
|
? 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>
|
||||||
</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">
|
||||||
|
|
@ -429,13 +402,6 @@ 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"
|
||||||
|
|
@ -451,59 +417,6 @@ 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,12 +198,13 @@ 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 on every breakpoint via the height
|
Sits flush above BottomNav (h-14 + safe-area inset) on every
|
||||||
the nav publishes (--bottom-nav-height, BottomNav.vue) — no
|
breakpoint. Previously `bottom-16` left an 8px gap on mobile
|
||||||
hand-derived h-14 + safe-area arithmetic to drift. -->
|
(BottomNav is h-14 = 56px, not 64px), and `sm:bottom-0` made
|
||||||
|
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: var(--bottom-nav-height)"
|
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||||
>
|
>
|
||||||
<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