Compare commits

...

8 commits

Author SHA1 Message Date
77b0bc557c feat(events): "send me a copy of each ticket email" toggle in the event editor
Pairs with aiolabs/events `extra.copy_to_organizer` (v1.6.1-aio.15): BCC
of every ticket email to the reply-to address (or the account email).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 17:41:36 +02:00
967233be40 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-13 17:40:24 +02:00
9c0abfae1f fix(events): let the event card shrink below its truncated rows (min-w-0)
On phone widths the event list is a one-column grid, and a grid item
defaults to min-width:auto (= min-content). The card's nowrap truncate
rows (location, organizer) push that min-content past the viewport for
any event with a long address, so the card spilled off screen and the
text was clipped by the window instead of ellipsised. min-w-0 on the
Card root lets the item shrink; overflow-hidden would have clipped the
pending/rejected badge that deliberately hangs past the bottom edge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVxfxB2sDFrdwnXrjhEp1b
2026-09-10 06:09:10 +02:00
4faa57f87a fix(events): resolve the ticket QR logo through @brand-app-logo
ticketQr.ts imported @brand/logo.png by literal filename, so any brand
kit that ships its logo as SVG only (cfaun's does) failed the events
standalone build with ENOENT. Every other logo consumer goes through
the @brand-app-logo alias, which resolves the per-app or brand logo in
SVG-then-PNG order; use the same alias here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nx87UjQtd1HS5u4DwCMve8
2026-09-09 14:48:31 +02:00
b080d802c5 Merge pull request 'fix(events): pin the sticky buy bar flush above the nav (--bottom-nav-height)' (#150) from fix/events-sticky-bar-gap into dev
Reviewed-on: #150
2026-09-09 11:19:46 +00:00
6e4e2f3d0c fix(events): pin the sticky buy bar flush above the nav
Two causes of the visible gap under the bar on phone and desktop: the
bar lived inside the page's `space-y-6` column, whose child margin also
offsets a fixed box (24px), and its bottom offset was hand-derived. It
now teleports to <body> and uses `--bottom-nav-height`. Measured in a
headless run: bar bottom == nav top on 390px and 1280px viewports.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-09 13:19:05 +02:00
82f7e5659a feat(layout): publish the bottom nav's rendered height as --bottom-nav-height
BottomNav measures itself (ResizeObserver) and writes
`--bottom-nav-height` on <html>; index.css declares the 0px default. Any
element that must sit flush above the nav pins with
`bottom: var(--bottom-nav-height)` instead of re-deriving
`3.5rem + env(safe-area-inset-bottom)` and drifting when the device's
inset, border or font scaling differs from that guess. The restaurant
item page's add-to-cart bar is migrated to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-09 13:19:05 +02:00
9eb75a2017 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
2026-09-09 12:47:38 +02:00
8 changed files with 209 additions and 25 deletions

View file

@ -96,6 +96,10 @@
/* 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,6 +1,8 @@
<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. */
@ -33,6 +35,22 @@ 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) {
@ -51,6 +69,7 @@ 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

@ -71,6 +71,10 @@ interface Props {
autoApprove?: boolean autoApprove?: boolean
} }
// Instance name used in the From display name of ticket emails
// (the LNbits site title on the backend; the standalone's brand here).
const siteName = (import.meta.env.VITE_APP_NAME as string) || 'Events'
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:open': [value: boolean] 'update:open': [value: boolean]
@ -142,8 +146,15 @@ 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(''),
reply_to_email: z
.string()
.trim()
.default('')
.refine((v) => v === '' || z.string().email().safeParse(v).success, 'Invalid email address'),
}) })
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time // End must not precede start. Compare on the folded date+time
@ -191,8 +202,11 @@ 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: '',
reply_to_email: '',
} }
}) })
@ -297,8 +311,11 @@ 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 ?? '',
reply_to_email: event.extra?.reply_to_email ?? '',
}) })
selectedCategories.value = [...(event.categories ?? [])] selectedCategories.value = [...(event.categories ?? [])]
if (event.banner) { if (event.banner) {
@ -423,8 +440,11 @@ 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,
reply_to_email: formValues.reply_to_email || null,
} }
if (isEditMode.value) { if (isEditMode.value) {
@ -826,6 +846,18 @@ 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">
@ -851,6 +883,35 @@ const handleOpenChange = (open: boolean) => {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Sender identity: the From address stays the instance
mailbox (that is what DKIM signs); the organizer only
controls the display name and where replies land. -->
<FormField v-slot="{ componentField }" name="organizer_name">
<FormItem>
<FormLabel class="text-sm">Organizer name</FormLabel>
<FormControl>
<Input placeholder="Château du Faune" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">
Ticket emails arrive from "Organizer name via {{ siteName }}".
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="reply_to_email">
<FormItem>
<FormLabel class="text-sm">Reply-to email</FormLabel>
<FormControl>
<Input type="email" inputmode="email" autocapitalize="none" placeholder="you@example.com" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">
Where attendee replies go. Leave blank to use your account email.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>

View file

@ -72,8 +72,14 @@ 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 cursor-pointer hover:shadow-lg transition-shadow duration-200" class="relative min-w-0 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/logo.png' import brandLogo from '@brand-app-logo?url'
/** /**
* Render the scannable `ticket://<id>` QR every ticket surface uses * Render the scannable `ticket://<id>` QR every ticket surface uses

View file

@ -26,6 +26,14 @@ export interface EventExtra {
nostr_notifications: boolean nostr_notifications: boolean
notification_subject: string notification_subject: string
notification_body: string notification_body: string
/** Shown as "<organizer_name> via <site>" in the From line of ticket
* emails. Optional. */
organizer_name?: string
/** 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 /** 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,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,59 @@ 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,13 +198,12 @@ 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 (h-14 + safe-area inset) on every Sits flush above BottomNav on every breakpoint via the height
breakpoint. Previously `bottom-16` left an 8px gap on mobile the nav publishes (--bottom-nav-height, BottomNav.vue) — no
(BottomNav is h-14 = 56px, not 64px), and `sm:bottom-0` made hand-derived h-14 + safe-area arithmetic to drift. -->
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: calc(3.5rem + env(safe-area-inset-bottom))" style="bottom: var(--bottom-nav-height)"
> >
<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">