Compare commits

..

1 commit

Author SHA1 Message Date
67583b7074 feat(events): organizer name + reply-to fields in the event editor
Pairs with aiolabs/events v1.6.1-aio.11: `extra.organizer_name` becomes
the From display name of ticket emails ("Organizer via <site>") and
`extra.reply_to_email` the Reply-To (blank = the organizer's account
email). Both live under the notifications section next to the per-event
subject/body; the reply-to is validated as an email or left empty.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-09 12:34:26 +02:00
8 changed files with 25 additions and 158 deletions

View file

@ -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);

View file

@ -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)"
> >

View file

@ -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">

View file

@ -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

View file

@ -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

View file

@ -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`. */

View file

@ -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 -->

View file

@ -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">