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>
Display how many people have favorited (liked) an event next to its
heart, updating in real time. A like == the event appearing in someone's
NIP-51 bookmark list (kind 10003) — the same action the heart performs.
New useEventLikes composable keeps ONE batched subscription over every
mounted heart's event coordinate (filtered by #a). It stays open after
EOSE, so a like published by anyone is pushed live and the count ticks
up for everyone — verified end-to-end against a relay (a like from a
fresh key bumped the shown count 2→3 with no reload). The heart also
pops on a live increment (gated past the initial historical-load
window), and the user's own like/un-like reflects instantly via the
optimistic heart state.
Caveat: an un-like by another user only reflects on next load — a
replaceable list that no longer contains the coord stops matching the #a
filter, so the removal isn't pushed. Counts are correct on fresh load.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a successful ticket purchase, the feed/calendar "My tickets" filter
and EventCard owned badges didn't update until a full page reload — the
shared useOwnedTickets singleton was never refreshed. Its own docs note a
successful purchase should call refresh(); wire that in at the payment-
confirmed point in useTicketPurchase so every surface reflects the new
ticket immediately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The heart took ~1s to fill because toggleBookmark awaited the remote
LNbits signer + relay publish before updating state. Flip local state
optimistically so the heart responds on tap, then sign/publish in the
background and roll back (with an error toast) if it fails. Add a brief
scale pop on the heart when a favorite is added for tactile feedback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Defensive guard so the date-picker popup can never linger across
navigation (reported: it appeared open on the feed after returning from
an event detail page). Force calendarOpen=false in onBeforeRouteLeave on
both the feed and My Tickets, the two popup hosts. Modals shouldn't
survive a route change regardless of how the close/navigation interleave.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the My-tickets filter that lived on the removed calendar page.
A calendar button opens the date-picker popup with per-day dots over the
user's own events; picking a day filters the ticket list to it (a
removable date chip overrides the upcoming/past toggle while active).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The calendar is now a date-picker popup on the feed (and, next, a visual
on My Tickets), so the dedicated /events/calendar page and route are no
longer needed. Delete EventsCalendarPage.vue, drop its route, and update
the stale openCreate comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The calendar button now opens the date-picker popup instead of
navigating to a separate page. Picking a day filters the feed to that
day (button highlights while active) and shows a removable date chip
whose ✕ clears ONLY the date selection — distinct from category
clearing, which keeps its own clear in the filter dropdown. Dots in the
popup come from the full (unfiltered) event set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring back selectedDate filtering, now driven by the calendar popup
instead of the removed week-strip: when a day is picked it takes
priority over the temporal pills and bypasses the past/upcoming split.
Add a dedicated clearSelectedDate() so the date selection can be cleared
independently of categories (a preset pill also clears it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a pickerMode to EventCalendarView (month grid only, emits selectDate
on every day tap, no selected-day events panel) and a new
EventCalendarPopup dialog wrapping it. Picking a day emits the date and
closes. Groundwork for replacing the standalone calendar page with an
on-feed date-picker popup and a My-Tickets event-date visual.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The My-tickets filter chip sat on its own row below the Back link,
wasting vertical space. Move it onto the Back-button row (still
left-aligned to clear the fixed top-right hamburger), reclaiming a row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The aspect-square day cells made the month grid ~350px tall on mobile,
pushing the selected-day event list well below the fold. Switch cells to
a fixed h-12 (still a comfortable 48px tap target) and tighten the
section spacing (space-y-4 → space-y-2), so the grid + day's events fit
with less scrolling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The toggle sat in the header's top-right corner, where it collided with
the fixed top-right hamburger menu (the Past button rendered behind it).
Move it to its own left-aligned row above the tabs — same approach the
calendar page uses to clear the hamburger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
My Tickets listed every ticket with no way to separate events that
already happened. Add an Upcoming/Past segmented toggle (defaults to
upcoming) that filters the grouped tickets by their event's date, with
the tab counts (All/Paid/Pending/Registered) derived from the visible
set so badges match what's shown. Events not yet resolved from relays
stay visible under Upcoming until their date is known.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the DatePickerStrip gone, selectedDate/selectDate in useEventFilters
were unreachable — nothing set a specific date anymore (the calendar page
only navigates to event detail). Delete the orphaned DatePickerStrip
component and strip the now-dead date-filter branch, state, and actions.
The feed filters purely by temporal pills + past/upcoming + categories.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The feed had two redundant day controls — the DatePickerStrip week strip
and the temporal preset pills — on top of the calendar page. Remove the
week strip (the calendar already covers picking a specific date) and move
the calendar shortcut to the end of the temporal-filter row, next to the
pills. Frees a row and keeps coarse windows (Today/This Week/…) inline
with one-tap access to the full calendar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The calendar page had no way back to the feed except the browser/back
gesture. Add a top-bar ghost Back link (ArrowLeft + "Back" → events feed)
mirroring EventDetailPage's pattern for navigation consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The month-grid calendar opened with no day selected, so the events panel
below was empty until the user clicked a day. Initialise the selection to
today (currentMonth already starts on the current month) so it opens
showing today's events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reka-ui overlays (dialog, sheet, popover, dropdown, tooltip, …) animate
their open/close via the data-state open/closed attribute. Zero the
animation duration globally in index.css so overlays appear/dismiss
instantly — no fade/zoom/slide. Verified the dialog still mounts and
unmounts correctly (Presence resolves at 0s). Pulse/spin loaders and CSS
transitions (hovers, the favourite heart pop) are untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the hamburger icon on the fixed top-right menu button with a
context-aware affordance: the user's avatar when logged in (first
initial when they have no picture), or a login icon when logged out.
Opens the same profile/menu sheet either way; aria-label reflects the
state. overflow-hidden + the avatar filling the round button keeps it
clipped to the chip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The placeholder map icon used text-muted-foreground/30, applying alpha to
the stroke colour so the lucide icon's overlapping fold lines compounded
to a darker shade where they crossed the outline. Use element-level
opacity-30 with the full colour instead, so the whole icon fades
uniformly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Availability only showed remaining ("N tickets available"), not the total
capacity. Derive total (remaining + sold) on EventTicketInfo and display
"N of M tickets left" on both the event card and the detail page, so
buyers can gauge demand. Unlimited events are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Search only indexed title/summary/description/location/tags, so typing an
organizer's name found nothing. Organizer display names aren't stored on
the event (they're resolved per-pubkey into the shared ProfileService
cache), so enrich the search corpus with the resolved name read from that
same reactive cache and add it as a Fuse key. Opening the search overlay
warms the cache for any organizers not yet fetched, and the corpus
recomputes as kind-0 metadata arrives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the hardcoded rounded-full on the temporal filter pills so they
inherit each palette's --radius (square under neobrut, gently rounded
elsewhere) and the theme's offset drop-shadow, instead of overriding it.
Add pb-1 pr-1 so neobrut's hard 4px shadow isn't clipped by the
overflow-x-auto scroll box.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the gear (Settings) button in the profile sheet header with a
pencil button inside the identity card, by the avatar/name row. A pencil
reads as "edit your profile" more intuitively than a header gear, and
co-locating it with the identity it edits is clearer. Opens the same
edit-profile dialog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a deployer set the in-app color scheme a fresh visitor sees (e.g.
cfaun → darkmatter light) without forking. Two optional brand.json
fields, `theme` (light|dark|system) and `palette` (one of PALETTES),
distinct from `themeColor` which is PWA chrome only.
- vite-branding.ts surfaces them as VITE_BRAND_THEME / VITE_BRAND_PALETTE
at module load, so the default applies app-wide (hub + all standalones)
with no per-config wiring.
- theme-provider reads them as the INITIAL value of theme/palette; a
user's stored choice in localStorage still wins and persists.
- Splits the catppuccin = bare `:root` invariant (now BASE_PALETTE, used
by applyPalette to drop data-theme) from the configurable default.
Without this, a non-catppuccin brand default would strip the
data-theme attribute and silently render catppuccin instead.
Unset → the app's built-ins (dark + catppuccin), unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A brand may ship a wide banner (logo + wordmark in one image) that
replaces the brand-kit logo + app-name pair in a standalone's header.
Events is the first consumer.
Banners are optional and resolve at build time, mirroring the existing
@brand-app-logo chain:
- resolveAppBanner(app?) checks per-standalone override first
(branding/<dep>/icons/<app>/banner.{svg,png}) then the brand's primary
banner (branding/<dep>/banner.{svg,png}); returns null when absent
instead of throwing, so brands without a banner keep logo + name.
- brandAppBannerAliasEntry() always registers the @brand-app-banner
alias (falling back to the logo) so the static import resolves; whether
it renders is gated by the VITE_APP_BANNER build flag.
- EventsPage renders the banner when the flag is set, else logo + name.
Deployers override per-standalone without touching the component. SVG
banners must have their text outlined to paths (browsers lack designer
fonts) — documented in branding/README.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The send path detected and decoded uppercase invoices correctly (SendDialog's
isBolt11 lowercases first) but WalletService.sendPayment gated the bolt11
branch on a case-sensitive startsWith('ln'), so QR-scanned invoices (uppercase
bech32, e.g. LNBC...) fell through to the else and threw "Invalid payment
destination format" despite the UI showing a valid decode.
Detect BOLT11 case-insensitively by its HRP (lnbc/lntb/lntbs/lnbcrt) and send
the lowercase canonical form. The bare 'ln' prefix also incidentally matched
bech32 LNURLs (lnurl1...) and misrouted them to the bolt11 endpoint; matching
the full HRP closes that gap too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two changes that ship together:
- Compact variant of OrganizerCard (tiny avatar + display name on a
single line) used on every feed EventCard so viewers see who's
hosting before they tap into the detail page. Hidden on compact
feed rows (host's own roster — they already know).
- Refactor useOrganizerProfile.ts to route through the centralized
ProfileService. Two bugs the local impl was carrying:
* `displayName` was exposed via a `get` accessor on the returned
object; destructuring it (`const { displayName } = …`) resolved
once at the destructure site, so a kind-0 arriving later never
updated the bound name. Now exposed as `computed<string>`.
* `relayHub.subscribe()` throws synchronously when the hub isn't
connected yet. OrganizerCard mounted during cold start swallowed
the throw silently in onMounted, leaving the user with the
pubkey-truncated fallback forever. ProfileService.getProfile()
awaits the relay-hub connection before issuing the filter.
Bonus: ProfileService's kind-0 cache is shared with chat / market /
nostr-feed, so a profile fetched by any module is immediately
visible to all of them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the "Event: <8-char-id>…" placeholder with the actual event
title, sourced from the shared events store, and wrap it in a
RouterLink to the event detail page. Card title truncates so long
names don't push the per-event ticket-count badge out of the row.
Subscribe to the events feed on mount so titles resolve as relay
events stream in — the user can land on My tickets directly from a
purchase flow without having to visit /events first to populate the
store. Falls back to the old short-id label until the event arrives
(or if it's been deleted upstream).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The events page H1 had `{{ t('events.title') }}` which is hardcoded
"Events" in the i18n locale — so cfaun's events standalone showed
"Events" instead of the configured displayName "Oyez!".
Read import.meta.env.VITE_APP_NAME (set by vite.events.config.ts from
brand.name → brandManifestName()) and fall back to the i18n title
when no brand is configured. Now:
- aiolabs default → "Events"
- cfaun → "Oyez!"
- any other deployment → its synthesized brand name
Also bumps the inline logo from h-7/sm:h-8 to h-10/sm:h-12 for more
header presence per cfaun feedback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four shared layout/login components (Login, LoginDemo,
AppSidebar, MobileDrawer) hardcoded `<img src="@brand/logo.png">`,
which means an SVG-only brand kit (like cfaun's) fails the build
with "Could not load @brand/logo.png".
Switch the four to `<img src="@brand-app-logo">` — the alias registered
via brandAppLogoAliasEntry() (already used by events module) resolves
to whichever of logo.{svg,png} exists in BRAND_DIR (SVG preferred),
with per-app overrides under BRAND_DIR/icons/<app>/.
Also register brandAppLogoAliasEntry in the 8 vite configs that
didn't have it (only events did before), converting their alias maps
to array form so the regex-based logo entry doesn't get shadowed by
the bare-string `@brand` and `@` aliases.
Verified:
- AIO default brand (PNG-only): builds, ships logo-<hash>.png — no regression.
- cfaun brand (SVG-only): builds, ships logo-<hash>.svg — previous
ENOENT on logo.png gone.
Unblocks cfaun deploy with an SVG-only brand kit (no manual
rasterization needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>