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).
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
: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);
--foreground: oklch(0.4355 0.0430 279.3250);
--card: oklch(1.0000 0 0);

View file

@ -1,8 +1,6 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { Component } from 'vue'
import { useResizeObserver } from '@vueuse/core'
export interface BottomTab {
/** Translated label shown under the icon. */
@ -35,22 +33,6 @@ interface 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()
function onTabClick(tab: BottomTab) {
@ -69,7 +51,6 @@ function isTabActive(tab: BottomTab): boolean {
<template>
<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"
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),
email_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_body: z.string().max(2000).default(''),
organizer_name: z.string().trim().max(100).default(''),
@ -202,7 +201,6 @@ const form = useForm({
price_per_ticket: 0,
email_notifications: false,
nostr_notifications: false,
copy_to_organizer: false,
notification_subject: '',
notification_body: '',
organizer_name: '',
@ -311,7 +309,6 @@ async function populateFromEvent(event: TicketedEvent) {
price_per_ticket: event.price_per_ticket ?? 0,
email_notifications: event.extra?.email_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_body: event.extra?.notification_body ?? '',
organizer_name: event.extra?.organizer_name ?? '',
@ -440,7 +437,6 @@ const onSubmit = form.handleSubmit(async (formValues) => {
payment_methods: formValues.payment_methods,
email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications,
copy_to_organizer: formValues.copy_to_organizer,
notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body,
organizer_name: formValues.organizer_name,
@ -846,18 +842,6 @@ const handleOpenChange = (open: boolean) => {
</FormControl>
</FormItem>
</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>
<FormField v-slot="{ componentField }" name="notification_subject">

View file

@ -72,14 +72,8 @@ const isNonApproved = computed(
</script>
<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
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)"
>
<!-- 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

View file

@ -32,8 +32,6 @@ export interface EventExtra {
/** 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
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
* Same shape as upstream v2's `extra.payment_methods`. */

View file

@ -1,6 +1,5 @@
<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'
@ -198,38 +197,6 @@ 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.
@ -243,7 +210,7 @@ function goToMyTickets() {
</script>
<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
prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. -->
@ -352,6 +319,13 @@ 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
@ -371,15 +345,8 @@ 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. 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"
>
need a Buy CTA for their own event. -->
<div v-if="event.ticketInfo && !ownedLnbitsEvent" 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"
@ -409,8 +376,14 @@ function goToMyTickets() {
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ buyLabel }}
<span class="ml-2 opacity-80 font-normal">{{ priceLabel }}</span>
{{ 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>
</Button>
<p class="text-xs text-muted-foreground text-center">
<span v-if="event.ticketInfo.available === undefined">
@ -429,13 +402,6 @@ 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"
@ -451,59 +417,6 @@ function goToMyTickets() {
@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 -->
<div v-if="event.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->

View file

@ -198,12 +198,13 @@ function back() {
</section>
<!-- Sticky bottom bar with qty stepper + add-to-cart.
Sits flush above BottomNav on every breakpoint via the height
the nav publishes (--bottom-nav-height, BottomNav.vue) — no
hand-derived h-14 + safe-area arithmetic to drift. -->
Sits flush above BottomNav (h-14 + safe-area inset) on every
breakpoint. Previously `bottom-16` left an 8px gap on mobile
(BottomNav is h-14 = 56px, not 64px), and `sm:bottom-0` made
it overlap the nav on desktop. -->
<div
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="inline-flex items-center rounded-full border border-border bg-card">