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
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
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
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
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
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
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
A tester's ticket landed in spam. A mail-tester run of the demo mailer
scored 8.3/10 with authentication fully green, so the sender setup is
not at fault and the mailer is being hardened separately (aiolabs/events
fix/ticket-email-headers). Until sender reputation builds, tell buyers
where to look: a red hint under the email field.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
`selectedMethodId` started as 'lightning', so the "keep the current
selection" guard in the default-rail watcher always found a match and the
guest preference for fiat never applied — verified on demo, where an
anonymous buyer saw "Proceed" (Lightning) preselected with Card on offer.
Start empty and reset to empty on close so each open re-evaluates:
guests land on Card when it exists, everyone else on the first enabled
rail, exactly as the comment already promised.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Replace the "Also accept fiat" switch with a Lightning / Card checkbox
group so an organizer can switch Lightning off for a card-only sale.
Sends extra.payment_methods (upstream v2 shape) and keeps allow_fiat in
sync (fiat in list ⇒ allow_fiat=true) for older readers. Card stays
disabled with a tooltip when the organizer has no fiat provider; the
fiat-currency picker appears when Card is on and the price is in sats.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Target of the emailed ticket link the extension now builds under the
webapp root. Anonymous, like the LNbits UI's own /events/ticket/{id}:
reads the PublicTicket, resolves the event name (Nostr store → scoped
relay query → public REST as a last resort), and renders the logo QR
with paid / pending / checked-in state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Stripe now brings the buyer back to the event page with
?checkout=success&tickets=<ids> or ?checkout=cancelled. The page strips
the query, then opens CheckoutReturnDialog, which polls the anonymous
ticket endpoint until every row is paid (60 s budget with a retry), and
renders one logo QR per ticket with a link to its public ticket page and
the "emailed to" note from the pending-checkout record. Logged-in buyers
also get their owned tickets refreshed and a My Tickets shortcut.
The login toast on "Buy ticket" now only fires for free events; paid
events open the dialog for guests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Buyers no longer need an account to buy a paid ticket. The dialog gains a
"Your details" block (name for guests, email + confirm-email for everyone,
prefilled from the account email) as a vee-validate/zod form, and builds
its rail buttons from the event's published payment methods so an
organizer can run card-only events.
- Fiat: same-tab redirect to the provider right after the POST, with the
pending checkout (ticket ids, email) remembered in localStorage for the
return page; the two-step "open in new tab" panel is gone.
- Lightning: guests get the bolt11 QR + "Open in wallet" and settle via
the existing poll; the LNbits-wallet button stays login-only.
- Free tickets remain login-gated (events#29).
- Guest success state renders the ticket QRs inline with the "emailed
to" note, since guests have no My Tickets.
i18n keys added under events.purchase / checkout / ticket (en, es, fr).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Prepare the webapp for account-less ticket purchases against events ext
v1.6.1-aio.8:
- requestTicket takes an options object: user_id (+ optional email) for
logged-in buyers, name + email for guests, Authorization only when a
token exists, and frontend_url so the backend builds the Stripe return
URLs and the emailed ticket link under this app's root.
- getTicket (anonymous PublicTicket) and getPublicEvent for the return
dialog and the public ticket page.
- resendTicketEmail maps the structured TicketResendResult (legacy bare
ticket tolerated).
- NIP-52 tickets_payment_methods tag parsed into ticketInfo.paymentMethods;
effectivePaymentMethods() mirrors the backend rule.
- renderTicketQr: level-H ticket:// QR with the brand logo centred, shared
by every ticket surface; useTicketPurchase accepts a guest contact.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
The fiat path pulled `currentUser` off the LNbits API client, which
never exposed it — only `getAccessToken()`. `userId` was therefore
always undefined and every Stripe/fiat "Continue" click failed with
"Missing user id" before the request was ever sent.
Use `useAuth()`'s `currentUser`, the same source the Lightning path
already goes through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLt6zWfXA1hM8zYQdpbeyn
Add a compact inline amber warning (icon + text) next to the
"Select the account…" instruction on both the expense and income
account-selection steps, so users know to fall back to the "Other"
account when unsure.
Splits the income i18n into selectAccount + a new otherAccountHint
key (en/fr/es + types).
Remove the "Amount in selected currency" and "Currency for this
income/expense" helper lines on both forms, drop the Reference helper
line, and mark the field as "Reference (optional)" in the label
instead. Also drops the now-unused FormDescription import from the
income form.
Fold the description guidance into the placeholder and drop the
separate FormDescription helper line, so the income Description field
mirrors the expense form's structure.
Append the same "Other" account guidance to the income form's Step 1
(libra.income.selectAccount) that the expense form already shows, for
consistency. Updated across en / fr / es locales.
Wrap the Amount and Currency fields in a two-column grid on both the
expense (AddExpense.vue) and income (AddIncome.vue) forms, and add
w-full to the currency SelectTrigger so the dropdown fills its column.
Tightens the vertical layout of the entry dialogs.
Step 1 (account selection) now tells the user to pick the "Other"
account if they're not sure. The Description field's placeholder is
rewritten to prompt for a detailed description of the purchase /
invoice / bill and what it was used for (event/project/etc), replacing
the redundant helper line.
Client feedback: with no fixed category taxonomy yet, steering unsure
users to the Other account plus a richer free-text description lets us
reconcile/recategorize later.
Refs aiolabs/webapp#137
Companion to aiolabs/events#31. Free events (price 0 / 100%-off promo)
now come back from POST /tickets/{event_id} as paid=true with the row
ids inline and no payment_request — the backend issued them
already-paid, no invoice to settle.
Previously the composable's `!paymentRequest` guard treated any
invoice-less response as fiat and threw "This event uses fiat
checkout", so free tickets were unbuyable.
- TicketPurchaseInvoice gains `paid` + `ticketIds`; TicketApiService
maps them.
- purchaseTicketForEvent short-circuits on `invoice.paid`: skip the QR /
payment-poll and go straight to the ticket-QR success state. The fiat
error now only fires for an actual fiat (not-paid) response.
- The ticket-QR rendering (refresh owned tickets, one QR per row, toast)
is extracted into a shared finalizePurchasedTickets() used by both the
Lightning-poll path and the free path.
- PurchaseTicketDialog: for free events drop the payment-method selector
and price line, show "Free", and label the CTA "Get ticket".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hub rendered all 8 standalones statically and greyed out any whose
VITE_HUB_<APP>_URL was unset — so an events-only deploy showed 7 dead
"coming soon" tiles. The greyed path was overloaded: hubLink() returned
null for both "not deployed" and "logged out", which the template then
couldn't tell apart.
Replace that with a registry → resolver → view-model pipeline:
- availabilityOf(m) resolves an explicit state from deploy config + auth:
available | auth-locked | inactive | unavailable.
- A `tiles` computed maps the catalog to view-models and drops
'unavailable' (not provisioned) entirely — no more ghost tiles.
- 'auth-locked' keeps today's greyed + login-prompt behavior for
deployed-but-logged-out apps (wallet, chat, tasks, libra).
- 'inactive' (active:false) is wired as a greyed, inert state — an unused
seam for a future install/disable model; no app sets it yet.
The view treats the catalog as an opaque list, so the source can later
move from this in-code array to a runtime feed (LNbits /hub/apps or a
NIP-78 event) without touching the render path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverses the events-only hide (c037d45) now that the link has a real
home. Three parts:
- Add a @brand-hub-logo alias (brandHubLogoAliasEntry) resolving to the
brand's primary/global logo — the HUB's logo, never the per-standalone
@brand-app-logo. Wired into all 9 app vite configs since the shared
ProfileSheetContent renders it.
- Restructure the profile sheet into a fixed-height flex column: a
flex-1 min-h-0 overflow-y-auto scroll region over a shrink-0 footer,
so "Back to hub" + the log-in/out bar stay pinned to the bottom while
the identity/preferences area scrolls.
- Move the edit-profile Dialog out of the flex root (it portals to body,
so it's not part of the sheet flow).
Logo bumped to w-8 h-8, centered, with tightened footer padding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coordinate-keying the events store (kind:pubkey:d-tag) regressed the
own-events merge: a creator's own event showed up twice in the feed.
`loadOwnEvents` surfaces the caller's own LNbits events via REST
(`ticketedEventToEvent`) so drafts appear before their NIP-52 event is
on a relay. That adapter stamps an empty `organizer.pubkey`, `isMine`,
and no ticket info. The relay-published copy lands under the real
publisher pubkey (`resolve_for_wallet` — NOT the user's Nostr login
key) with full ticket counts. Pre-coordinate-keying both collapsed on
the bare d-tag so the relay copy replaced the draft; under
`kind:pubkey:d-tag` the empty-pubkey draft and the real-pubkey copy are
distinct keys, so both render — the empty "..."/no-tickets card next to
the real one. Only the logged-in owner sees it, since only own events
get the REST merge.
upsertEvent now reconciles by d-tag: the published copy supersedes the
provisional draft and inherits its `isMine`, so the creator keeps the
Yours badge + Hosting filter even though the publisher key differs from
their login key. Handles both arrival orderings; a draft with no
published copy yet (pending review) still shows alone; genuinely
distinct authors sharing a d-tag are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding vitest (#124) updated pnpm-lock.yaml (pulled in @standard-schema/spec
and other vitest deps) but the fixed-output pnpmDeps.hash in mkWebapp was
not regenerated, so the build reused the stale offline store and failed
with ERR_PNPM_NO_OFFLINE_TARBALL during `pnpm install --offline`. Rehash
so the deps FOD is refetched from the current lockfile.
Verified: `nix build .#chat` succeeds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scan station's third stat card (sold minus scanned) was labeled
"Remaining", which read like leftover sales capacity. Relabel it "Not
scanned" so the trio reads unambiguously: Scanned + Not scanned = Sold.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The page header used py-4, putting the logo/banner ~6px below the fixed
top-right profile icon's center. Use pt-2.5 so the header sits centered
on the same horizontal axis as the profile icon (both centers at 30px).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment out the profile sheet's "Back to hub" link (and its hubRootUrl)
since only the events app ships first — nothing to go back to yet. The
commented markup swaps the old Home icon for the HUB's brand-kit logo
(noted as a @brand-hub-logo alias to add when re-enabled — the hub logo,
not the per-standalone @brand-app-logo) so it's ready to re-enable when
the hub launches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The top-right menu trigger showed a LogIn (arrow-into-door) icon when
logged out; use the generic User icon instead — it reads as "your
account / profile" and matches the avatar shown when logged in. Still
opens the same profile/menu sheet (with the login CTA).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useEventDetail.load() early-returned when the event was already in the
store, so arriving from the feed (cached) set up no live subscription.
NIP-52 calendar events are replaceable and the events extension
republishes them when a ticket sells (updating tickets_sold/available),
but with no subscription the detail page never received the update —
counts went stale until a manual reload.
Always open the dTag-scoped subscription (only the one-shot query +
loading state are skipped on a cache hit), and unsubscribe a prior sub
before re-subscribing so reload() can't leak one. The reactive `event`
computed then reflects republished counts without a reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The calendar popup already narrows its day-dots to the active category
filter; surface those categories inside the popup so the user can see —
and loosen — what's narrowing it without closing. Renders only the
selected categories as removable chips; clicking one emits toggle-category
to the parent, which reactively re-widens the dots in place.
- EventCalendarPopup: optional selectedCategories prop (defaults to none
for callers like My Tickets) + toggle-category emit; chip row between
the header and the month grid.
- EventsPage: wire selectedCategories + toggleCategory through.
- i18n: events.filters.filteringBy + removeCategory (en/fr/es + schema).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "strictly-monotonic created_at per coord" section named useRSVP.ts as
canonical, but that file no longer exists. monotonicCreatedAt() in
src/lib/nostr/timestamp.ts is now the single implementation — make the
doc reference it and show both the per-coord-Map and single-field
tracking shapes. Keeps doc and code aligned per the docs discipline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relays only push a replaceable-event update to OPEN subscriptions when
its created_at is strictly newer than the held version. created_at is
second-resolution, so useBookmarks' `Math.floor(Date.now()/1000)` lets
two rapid toggles collide in the same second — the second is treated as
not-newer and never reaches live subscribers (only a reload shows it).
This is the same root cause found while debugging the live ticket count.
- Add `monotonicCreatedAt(lastCreatedAt, now?)` = max(now, last+1), a
reusable helper for any replaceable-event publisher.
- Use it in `toggleBookmark`; track `lastCreatedAt` as a typed field on
BookmarkState (drops the `(state as any)` casts).
Unit tests cover no-prior, same-second bump, wall-clock tracking,
future-dated prior, and a strictly-increasing same-second burst.
The aiolabs/events extension's nostr_publisher uses int(time.time()) the
same way — flagged in #122 for a follow-up on the backend.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NIP-52 calendar events (kinds 31922/31923) are addressable: their d-tag
is author-scoped, so the replacement key is kind:pubkey:d-tag, not the
bare d-tag. The store keyed `eventsMap` by `event.id` (d-tag) and
replaced on newer `created_at` ignoring pubkey, so a different author
republishing the same d-tag could overwrite a legit event in the store
(cross-author hijack). NDK (`event.coordinate()`) and welshman
(`eventsByAddress`) both key addressable events by the full coordinate.
- Key `eventsMap` by `eventCoordinate()` = `${kind}:${pubkey}:${dtag}`;
same-coordinate-newer-wins replacement, different authors stored apart.
- Keep the d-tag as the route identifier: `getEventById(dtag)` scans and
returns the newest match (single-publisher in practice). Add
`getByCoordinate()` for precise, author-known lookups.
- `removeEvent(dtag)` deletes every coordinate sharing that d-tag.
Client-side only — the store is rebuilt from relays each session, so no
demo-DB surgery. Covered by vitest unit tests including the cross-author
no-overwrite case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No test runner existed in the repo. Add vitest (node env, *.spec.ts
discovery) with a minimal config mirroring only the `@`→src alias, plus
`test`/`test:watch` scripts and a smoke test as a known-good baseline.
Precursor for the nostr-patterns review fixes (events store coordinate
keying #121, monotonic created_at #122), which ship with unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The date-picker popup showed dots for all events regardless of the
active category filter. Feed it a category-filtered set so its per-day
dots reflect what the user is browsing (temporal/day filters still don't
apply — the calendar is for picking any date). No categories selected
behaves as before (all events).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuild the calendar popup on the reka-ui dialog primitives instead of
the shared DialogContent, so it can use a light, blurred overlay
(bg-background/20 + backdrop-blur-md) instead of the usual opaque dark
dim. The panel itself is translucent + blurred (bg-background/70 +
backdrop-blur-xl). Result: the feed stays visible, softly blurred,
behind the frosted glass. Scoped to this popup — other dialogs keep
their solid dark overlay.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The like count only watched bookmark lists by #a, so when someone removed
an event their new (replaceable) list no longer matched the filter and
never arrived — the count stayed stale until reload. Also watch known
likers by `authors` and track each author's current liked-coords, diffing
prev vs next on every update so a dropped coord decrements live. Verified
end-to-end against a relay: a like incremented the count and the same
key's updated list (coord dropped) decremented it with no reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wallet WebSocket handler assumed LNbits sends the PRE-payment balance
for outgoing payments and subtracted the amount again. LNbits actually
re-fetches the wallet AFTER the payment settles before emitting the
notification (core payments.py `_send_payment_notification_in_background`
→ "fetch balance again" → notifications.py `send_ws_payment_notification`
→ `wallet.balance`), so `wallet_balance` is already POST-payment for both
directions. The extra subtraction double-deducted: 100 sats, send 10 →
balance showed 80, then a refresh (which reads the authoritative
/api/v1/wallet balance) corrected it to 90.
Use `wallet_balance` as-is for all messages, matching the polling
fallback which already trusts the API balance directly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>