Compare commits

..

18 commits

Author SHA1 Message Date
136a4c92fb fix(avatar): pair bg-secondary with text-secondary-foreground
The Avatar primitive paired bg-secondary with text-foreground, which
isn't a Shadcn-designed pair. In themes whose secondary is a bright
color (Starry Night dark: bright yellow) the global foreground
(near-white) lands on top and the initial becomes unreadable.

Switch to text-secondary-foreground so contrast is whatever the theme
author guaranteed for that pair. Pre-emptively protects any future
theme since every theme must keep secondary/secondary-foreground
contrast valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 21:18:25 +02:00
3cdd87063a fix(activities): relabel "Get invoice" → "Proceed" on PurchaseTicket
The lightning rail's CTA in PurchaseTicketDialog now reads
"Proceed" (or "Proceed (N tickets)" for multi-quantity) instead
of "Get invoice". Matches the language used on the fiat rails
("Continue to Stripe checkout" etc.) and reads as a generic
forward action regardless of which payment path the user picks.
2026-06-09 21:18:25 +02:00
8b02d74f84 fix(activities): drop duplicate empty-state line on ActivityList
The empty state rendered "No activities found" twice — once as the
heading and once as the description below, because
`activities.noActivities` and `activities.search.noResults`
translate to the same string in every locale. Drop the description
paragraph (and its mb-1 spacer on the heading).
2026-06-09 21:18:25 +02:00
0158edf793 feat(activities): fuzzy search on the Tickets roster
Adds a search box above the roster list that fuzzy-matches the
holder name and ticket id via the shared useFuzzySearch (Fuse.js)
composable. Empty query keeps the unregistered-first sort intact;
typing reorders by relevance. The empty-state message now
distinguishes "no tickets sold yet" from "no rows matched the
current query" so a busy roster + a typo doesn't look like
backend trouble.
2026-06-09 21:18:25 +02:00
cc245b4001 feat(activities): manual ticket registration from the roster tab
The "Scanned" tab becomes "Tickets" and now lists the full event
roster (sold tickets), not just the registered subset. Unregistered
rows lead the list with a Register button so the host can manually
mark someone present without a QR scan — e.g. lost phone, known in
person, or alternate proof of identity.

useTicketScanner gains registerManually(ticketId), which calls the
same PUT /tickets/register/{id} the scanner uses (so it inherits
the event-ownership gate and the unpaid/already-registered backend
checks), then refreshes stats. It skips the scanner pause + full-
screen banner since the operator initiated the action from the
list, and mirrors the session-local dedup so a subsequent QR scan
on the same ticket reports "Already scanned" instead of a duplicate
register round-trip.

The header now reads "registered / total · N to go" so the host
sees roster progress at a glance; failures from the manual register
surface as a sonner toast and the row reverts.
2026-06-09 21:18:25 +02:00
bc1f647daa revert: move scan counts back above the tabs + fix tab centering
Reverts 1aeea23 and folds in the actual fix the relocation was
chasing: the Scanner / Scanned tab labels were rendering with
their icons and text mis-aligned because TabsTrigger wraps its
slot in an inline `<span class="truncate">`. A `gap-1.5` on
TabsTrigger never reached the icon/label children. Wrap each
trigger's content in an `inline-flex items-center gap-1.5` span
so the icon and label share a real flex container.
2026-06-09 21:18:25 +02:00
f589eed4e3 feat(activities): move scan counts below the camera
The Scanned / Sold / Remaining strip moves out of the page header
to below the Tabs block. The camera (or scanned list, depending on
the active tab) stays prominent at the top; the counts read as a
summary footer instead of competing with the title for attention.
The stats-error notice follows the counts strip so the warning
stays adjacent to the values it affects.
2026-06-09 21:18:25 +02:00
aaa98d02c2 feat(activities): refine activity card for pending/rejected + compact
- Wash out pending/rejected events with opacity-50 + grayscale on a
  wrapper div so the operator sees at a glance the event isn't live,
  not just the small badge.
- Pull the status badge OUT of the wash-out wrapper and absolute-
  position it on Card root (bottom-2 left-2, z-10) so it stays in
  full color above the dim card. Both pending and rejected use the
  destructive token — the label text differentiates the two states.
  Bottom-left so it doesn't collide with the category chip on full
  cards or the thumbnail on compact ones.
- Compact rows in the Hosting view now show a small left-aligned
  thumbnail (w-20 h-20, self-center, ml-3, rounded-md) when the
  event carries an image — host can still recognize each event at a
  glance without paying the visual weight of a full hero.
- Card root becomes `relative overflow-hidden`; the wrapper div
  owns the conditional flex-row (compact) / flex-col (default)
  layout and the opacity/grayscale toggling.
2026-06-09 21:18:25 +02:00
2ce3d3f53d feat(activities): My tickets toggle on the calendar view
Adds a small filter chip above the month grid that, when on, limits
the calendar to events the signed-in user holds at least one paid
ticket for (intersecting ownedActivityIds from useOwnedTickets).
Hidden when logged out — nothing to own. Left-aligned so it
doesn't collide with the fixed top-right hamburger menu.

State is local to the page on purpose: narrowing the calendar
shouldn't also narrow the feed when the user navigates back.
2026-06-09 21:08:48 +02:00
4778c34671 feat(activities): tailor Hosting tab + host detail view for operators
Hosting feed (ActivitiesPage when onlyHosting):
- Hide the date picker strip + calendar shortcut and the entire
  Filters/temporal-pills row; an operator managing their roster
  doesn't need calendar navigation or temporal narrowing.
- Keep the search bar — finding a specific event in a long roster
  still matters.
- Render compact cards via a new `compact` prop on ActivityList +
  ActivityCard: no hero image, single-line title, no summary, no
  bookmark, no "Yours" badge (every card is the operator's own),
  tighter p-3 padding, single-column flex layout.

Host detail view (ActivityDetailPage when ownedLnbitsEvent):
- Drop the top-bar Scan and Edit buttons. Edit moves into the title
  row as a prominent filled-primary icon button right of the title;
  Scan moves into the tickets section.
- Render a full-width "Scan tickets" CTA in place of Buy ticket, and
  hoist it outside the ticketInfo gate so it appears even on hosted
  events that were published without AIO ticket tags.
- Hide BookmarkButton and RSVPButton for the host (favoriting /
  RSVPing your own event are noise affordances).

Detail-page badge row: "Yours" leads the row in the highlighted
secondary variant; category and tags drop to outline so the
ownership signal stands out.
2026-06-09 21:08:12 +02:00
0ad348f7f5 fix(activities): share filter refs across useActivities consumers
useActivityFilters allocated a fresh set of refs on every call, so
when activities-app/App.vue (Hosting bottom-nav tab) and
ActivitiesPage.vue each invoked useActivities(), they got
independent onlyHosting/temporal/etc state. Tapping Hosting toggled
the App.vue ref; the page never saw the change. Hoist the filter
refs to module scope so every consumer shares the same instance.
2026-06-09 21:04:51 +02:00
cade620623 feat(activities): restructure bottom nav around Home/MyTickets/Hosting
The bottom-nav tabs become Home, My tickets, Hosting, Map, Favorites.
- Feed is relabeled "Home" (en/fr; es was already "Inicio").
- My tickets and Hosting move out of the sidebar menu back into the
  bottom nav. Hosting is a synthetic tab — no path of its own; it
  toggles the existing onlyHosting feed filter and lands on
  /activities, with Home as the inverse (clears the filter on tap).
- Calendar leaves the bottom nav. The week strip now ends with a
  small calendar icon button that routes to /activities/calendar,
  so the entry point sits adjacent to the date UI instead of
  competing for a tab slot.
- Create activity leaves the bottom nav too. A full-width "+ Create
  activity" CTA appears at the top of the feed only when the Hosting
  tab is active, so the Create entry point lives inside the section
  it belongs to.

BottomTab gains an optional `isActive()` predicate so tabs whose
active condition doesn't reduce to "current path starts with x"
(e.g. Hosting) can compute their own state.
2026-06-09 21:03:54 +02:00
b94ebc598a feat(activities): drop image placeholder when an event has no image
Cards without an image no longer render the solid-color 16:9
placeholder + calendar glyph. They go straight to the content area
with the badges (category, price, Yours, status, Past) shown
inline in a small row at the top, so the title and details aren't
pushed below a meaningless filler block.

The placeholderBg computed (hash → HSL) is removed; it was only
feeding the deleted no-image branch.
2026-06-09 21:00:46 +02:00
cd34555bcb feat(activities): stationary Filters column next to scrolling pills
Filters icon + Clear-all sit in a stationary left-aligned column;
only the All/Today/Tomorrow/etc temporal pills scroll horizontally.
Clear-all is tucked tightly under the Filters icon (h-5, 10px text,
gap-0.5) and shows only when a filter is active. The badge no
longer lives inside the overflow-x scroll container, so the count
chip isn't clipped at the corner anymore.
2026-06-09 20:58:37 +02:00
9fd71df7a0 feat(activities): reclaim vertical space above the feed
Past events no longer gets its own row — it folds into the existing
collapsible (renamed "Filters") alongside Categories, so the feed
gains that row by default. The Filters trigger badge counts past-
events being on plus any selected categories, so users still see at
a glance when hidden toggles are active.

The standalone "Filters active / Clear all" notice is gone too;
Clear all sits inline beside the trigger only when something's
active. Header is tightened (text-xl) and inter-row margins drop
from mb-4 to mb-3 across the date strip + temporal pills.
2026-06-09 20:57:37 +02:00
39678126c9 feat(webapp): replace HubPill with hamburger sidebar menu
The top-right "Back to hub" pill in each standalone is replaced by a
hamburger button that opens a right-side sheet reusing the existing
ProfileSheetContent (identity card, back-to-hub link, theme/lang/
currency prefs, profile settings or log-in CTA). The redundant
Profile entry is removed from BottomNav and its loggedOutOpensSheet
plumbing (BottomNav → AppShell) is dropped — Hub.vue still mounts
ProfileSheetTrigger directly so it's unaffected.

ProfileSheetContent gains an `app-nav` slot so standalones can inject
app-specific nav items above the cross-app section. AppShell exposes
a new optional `sidebarNav` prop that forwards items to the menu;
unset on non-activities standalones, those still get the hamburger
menu showing just the shared profile/preferences content.

Activities passes "My tickets" (routes to /my-tickets) and "Hosting"
(toggles the onlyHosting feed filter and lands on /activities), so
those entries leave the inline filter chip row on ActivitiesPage and
live in the sidebar instead. The "Past events" chip stays inline —
it doesn't require auth and pairs visually with the temporal filters.
2026-06-09 20:56:03 +02:00
e822285b99 feat(activities): restructure event detail page layout
- Move bookmark heart from top bar to the right of the title.
- Replace the When/Where info cards with caption-style lines directly
  under the title (calendar + map-pin icons + muted text).
- Move description above the organizer so it sits right under the
  title/info separator; push the organizer card to the bottom.
- Promote the "you own N tickets" CTA (filled primary "View" button)
  and demote "Buy another ticket" to outline when the user already
  owns tickets, so the My-Tickets path is what jumps out.
- Tighten ticket availability against the buy button: standalone strip
  removed, count rendered as an xs muted caption directly under the
  buy CTA.
2026-06-09 20:53:31 +02:00
5541d2bc7a refactor(events): rename activities module to events + wire VITE_APP_NAME for per-deployment branding (#94)
## Why

The module was named `activities` originally to avoid colliding with Nostr's `Event` type. In practice that defense added friction without preventing confusion — the backend extension is named `events`, NIP-52 calls them "Calendar Events", and the UI already displayed "Events". The collision with `nostr-tools` `Event` is handled cleanly by the existing `import { Event as NostrEvent }` alias pattern (already in 5 files inside the module). Renaming collapses the 4-way mismatch into one consistent term.

Separately, deployments needed per-instance app names. `VITE_APP_NAME` was already plumbed per-standalone via NixOS `services.webapp-standalones.<app>.displayName` (e.g. cfaun shipped `"Sortir"`, now rebranded to `"Bouge"`), but nothing in the standalone consumed it — PWA manifest, HTML title, and runtime branding were all hardcoded. This PR wires the env through every app-name display point so any deploy can flip `displayName = "Bouge"` (or anything) and pick up the brand end-to-end.

## What

Nine commits on the branch:

1. **`refactor(events): rename activities module to events`** — 70-file rename. `src/modules/activities/` → `src/modules/events/`, `src/activities-app/` → `src/events-app/`, types/services/composables/views/components/store renamed (`Activity`→`Event`, `Activities`→`Events`). Routes `/activities/*` → `/events/*`; the legacy `/events` (ticketing management) moves to `/my-events` so `/events` belongs to the canonical feed. DI tokens `ACTIVITIES_*` → `EVENTS_*`. i18n namespace renamed; English domain strings updated, French/Spanish title key realigned. npm scripts `:activities` → `:events`. Build output `dist-activities/` → `dist-events/`.

2. **`feat(events): wire VITE_APP_NAME through PWA manifest, HTML, runtime`** — PWA manifest `name`/`short_name` template from `process.env.VITE_APP_NAME` with fallback `'Events'`. `events.html` uses Vite's `%VITE_APP_NAME%` substitution. `src/events-app/app.ts` + `main.ts` runtime brand string drives console logs, offline notification, and `acceptTokenFromUrl()`. `events.title` route meta sources from VITE_APP_NAME. `.env.example` updated with per-standalone scoping notes.

3. **`docs(events): update activities→events references`** — `docs/nostr-patterns/*.md` "Canonical: src/modules/activities/composables/useRSVP.ts" anchors point at renamed paths. CLAUDE.md Payment Rails Pattern section updated.

4. **`fix(events): drop lowercase from PWA description brand name`** — minor casing fix caught during verification.

5. **`fix(events): use domain noun in description, not brand name`** — manifest + HTML description switched from `"Discover ${BRAND} near you"` to static `"Discover events near you"`. Description is about *what* the app does, not the brand.

6. **`chore(events): scrub leftover sortir/activities references`** — nginx.conf.example, package.json (concurrently process label + `build:demo` BASE_PATH), router-helpers comment, useMarket comment, and vite.events.config.ts doc-comment.

7. **`refactor(events): conditional brand in console label, tighten docs`** — adds `APP_LABEL` next to `APP_NAME` in `events-app/app.ts`. Reads as `Events` on unbranded builds and `Events (Bouge)` (etc.) when `VITE_APP_NAME` is set to anything other than "Events" (case-insensitive). Used by all four console messages; `acceptTokenFromUrl` keeps the raw `APP_NAME` (it's a token-namespace identifier, not display copy). Also collapses the redundant "cfaun sets X; future deployments can override (e.g. X)" doc-comment.

8. **`i18n(events): finish activité/actividad → événement/evento sweep`** — completes the i18n rename in fr.ts and es.ts (search placeholders, favorites prompts, settings prompt) and fixes five gender-agreement errors that came along with the masculine `événement`/`evento` switch (French: `Aucune ... trouvée`→`Aucun ... trouvé`, `préférées`→`préférés`, `d'une ... la sauvegarder`→`d'un ... le sauvegarder`; Spanish: `favoritas`→`favoritos`, `guardarla`→`guardarlo`).

9. **`chore(events): finish sortir → bouge sweep in .env.example`** — four remaining doc-comment refs in `.env.example` (cfaun branding, ticket-scanner comment, section header, subdomain-mode URL example).

## Cross-repo coordination

This PR has matching changes already pushed to two other repos. They have to land in a coordinated bump because the names are tightly coupled.

- **`aiolabs/webapp-module` main** — commit `9d82016`. Renames `hubActivitiesUrl` → `hubEventsUrl` and `VITE_HUB_ACTIVITIES_URL` → `VITE_HUB_EVENTS_URL`. No backwards-compat shim.
- **`aiolabs/server-deploy` main** — commits `f15e1eb`, `bf4698b`, `d46a520`:
  - `standalones.nix` `apps.events` uses `build:events` / `dist-events` / `events.html`; `hubUrlOption` maps `events → "hubEventsUrl"`; comment + cfaun-as-example doc switched from sortir/Sortir to bouge/Bouge.
  - `hosts/cfaun/default.nix` rebranded: `subdomain = "bouge"` + `displayName = "Bouge"`. (Native-French feedback retired the "Sortir" branding as awkward.)
  - `hosts/atio/default.nix` stale "activities app" comment dropped (atio's `displayName = "Eventos"` config is unchanged).

server-deploy's `flake.lock` still pins the OLD webapp + OLD webapp-module, so nix builds from server-deploy main will fail until the bumps. Suggested order after this PR merges to `dev`: one server-deploy commit that does `nix flake lock --update-input webapp-module` + `--update-input webapp-demo` together. DNS for `bouge.ariege.io` needs to point at the cfaun host before the deploy lands.

## Verification

- `pnpm typecheck` — clean ✓
- `pnpm build:events` (default) — `dist-events/manifest.webmanifest` has `name: "Events"`, description "Discover events near you" ✓
- `VITE_APP_NAME=Bouge pnpm build:events` — `name: "Bouge"`, HTML title "Bouge", console label resolves to `Events (Bouge)` ✓
- Cross-repo grep sweep: no `Sortir`/`sortir`/`activities`/`Activities` references in events-module scope. (Domain false positives — "Market Activity" in market dashboard, "time-based activities" in nostr-feed content filters, "Activity Lifecycle Kills" in CLAUDE.md mobile-browser docs — are unrelated and intentionally left alone.)

Not verified in-browser (no GUI in this session) — needs a manual smoke at `app.ariege.io/bouge/` once the coordinated flake bump lands on cfaun. Watch for: feed loads, `/events/calendar`, `/events/map`, `/events/favorites`, `/events/:id` routes work; "My Events" appears in the user dropdown and `/my-events` shows the management page; PWA install prompt shows "Bouge".

## Out of scope (deferred)

- Backend extension rename — `aiolabs/events` is already named correctly.
- Nix standalone attribute name — `services.webapp-standalones.events` is already correct.
- Overriding domain-noun strings ("Your event was created", RSVP labels) — those stay locale-driven; `VITE_APP_NAME` covers app-name only.
- Backwards-compat redirects from `/activities/*` to `/events/*` — pre-launch, public URL was `/sortir/` (path-mode) or `sortir.ariege.io` (subdomain-mode), never `/activities/`. No external bookmarks to preserve. (`sortir.ariege.io` → `bouge.ariege.io` is a separate decision; up to the deploy operator whether to keep a 301 for word-of-mouth referrers.)

Reviewed-on: #94
2026-06-09 18:18:26 +00:00
76 changed files with 835 additions and 1093 deletions

View file

@ -1,4 +1,10 @@
# App Configuration
# Per-standalone display name — sets browser tab title, PWA install
# name/short_name, and the brand string in console logs. Each standalone
# (events, wallet, chat, market, …) gets its own VITE_APP_NAME at build
# time via NixOS `services.webapp-standalones.<app>.displayName` (see
# server-deploy). cfaun ships the events app as "Bouge"; defaults to
# "Events" / "Wallet" / etc. when unset.
VITE_APP_NAME=MyApp
# Nostr Configuration
@ -14,7 +20,7 @@ VITE_WEBSOCKET_ENABLED=true
# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint).
# Logged by the LNbits server at startup:
# `Nostr transport: starting with pubkey <hex>... on N relay(s)`
# Required for the activities ticket scanner; legacy HTTP path still
# Required for the events ticket scanner; legacy HTTP path still
# works without it.
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY=
@ -35,8 +41,8 @@ VITE_PUSH_NOTIFICATIONS_ENABLED=true
# Image Upload Configuration (pict-rs)
VITE_PICTRS_BASE_URL=https://img.mydomain.com
# Activities / Sortir Configuration
# Default language for the standalone activities app (fr, en, es)
# Events App Configuration
# Default language for the standalone events app (fr, en, es)
VITE_DEFAULT_LOCALE=fr
# Default map center as "lat,lng" (defaults to France center if not set)
VITE_DEFAULT_MAP_CENTER=42.9667,1.6000
@ -64,7 +70,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
#
# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined
# in the vite configs):
# VITE_HUB_ACTIVITIES_URL=http://localhost:5181
# VITE_HUB_EVENTS_URL=http://localhost:5181
# VITE_HUB_LIBRA_URL=http://localhost:5180
# VITE_HUB_WALLET_URL=http://localhost:5182
# VITE_HUB_CHAT_URL=http://localhost:5183
@ -74,7 +80,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
# VITE_HUB_RESTAURANT_URL=http://localhost:5187
#
# In PATH-MODE production (recommended for demo) — note the trailing slash:
# VITE_HUB_ACTIVITIES_URL=https://demo.example.com/activities/
# VITE_HUB_EVENTS_URL=https://demo.example.com/events/
# VITE_HUB_LIBRA_URL=https://demo.example.com/libra/
# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/
# VITE_HUB_CHAT_URL=https://demo.example.com/chat/
@ -84,11 +90,11 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
# VITE_HUB_RESTAURANT_URL=https://demo.example.com/restaurant/
#
# In SUBDOMAIN-MODE production:
# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com
# VITE_HUB_EVENTS_URL=https://events.example.com
# VITE_HUB_LIBRA_URL=https://libra.example.com
# ...etc
# ───────────────────────────────────────────────────────────────────────
VITE_HUB_ACTIVITIES_URL=
VITE_HUB_EVENTS_URL=
VITE_HUB_LIBRA_URL=
VITE_HUB_WALLET_URL=
VITE_HUB_CHAT_URL=

View file

@ -717,7 +717,7 @@ VITE_WEBSOCKET_ENABLED=true
## Payment Rails Pattern
Shared primitives for modules that mix Lightning + fiat (and, future,
cash / internal-wallet) payment rails. Activities is the first
cash / internal-wallet) payment rails. Events is the first
consumer; restaurant + marketplace will adopt the same primitives as
their backends gain fiat support.
@ -784,7 +784,7 @@ type PaymentMethod = {
```
Module usage:
- **Activities** passes `[lightning, ...one entry per organizer provider]`.
- **Events** passes `[lightning, ...one entry per organizer provider]`.
- **Restaurant** (future) passes the subset of
`[lightning, cash, internal, ...fiat providers]` enabled by the
restaurant's `accepts_*` flags.

View file

@ -1,7 +1,7 @@
# Nostr patterns
Living reference for reusable Nostr patterns that show up across modules
(activities, forum, market, chat, tasks, base, nostr-feed).
(events, forum, market, chat, tasks, base, nostr-feed).
**Read before writing any new Nostr code in this repo.** **Update whenever you
introduce, refine, or correct a pattern.** Each section has a "Canonical

View file

@ -2,7 +2,7 @@
## Treat `result.success === 0` as failure, not success
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
`if (!result || result.success <= 0) return null`.
```ts
@ -23,7 +23,7 @@ composable. Don't write code that silently treats both as success.
## Optimistic-on-success, not optimistic-on-click
**Canonical:** `src/modules/activities/composables/useRSVP.ts` — local
**Canonical:** `src/modules/events/composables/useRSVP.ts` — local
cache update after the `await` resolves with `success > 0`, before the
relay echoes the event back through the subscription.
@ -39,7 +39,7 @@ button flip twice.
## Pending-coord debounce: disable the button during in-flight publish
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
`pendingCoords: ref<Set<string>>` + `isPending(...)` predicate +
`try { … } finally { pendingCoords.value.delete(coord) }`.
@ -66,7 +66,7 @@ while a previous publish on activity B is still flying. A global
## Sign with `nostr-tools.finalizeEvent`, take privkey as bytes
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
`hexToUint8Array` helper + `finalizeEvent(template, signingKey)`.
`finalizeEvent` expects a `Uint8Array`, not a hex string. Several composables

View file

@ -7,7 +7,7 @@ in this file follows from that single fact.
## Strictly-monotonic `created_at` per coord
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
```ts
@ -31,7 +31,7 @@ than the last click on the same coord.
## Per-pubkey latest-wins state for derived counts
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
`rsvpStates: ref<Map<coord, Map<pubkey, RSVPEntry>>>` + `upsertRSVPState` +
`getRSVPCount` (count entries where status === 'accepted').
@ -51,7 +51,7 @@ any "who's currently in state X" question.
## Replaceable list, full-rewrite on toggle
**Canonical:** `src/modules/activities/composables/useBookmarks.ts` —
**Canonical:** `src/modules/events/composables/useBookmarks.ts` —
NIP-51 kind 10003 bookmark list.
For replaceable lists (10003 bookmarks, 10000 mute list, 10006 communities,
@ -66,7 +66,7 @@ diverges on next refresh.
## Vue 3 reactivity for nested `ref<Map>`
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
`upsertRSVPState` (the `rsvpStates.value.set(coord, inner)` after mutating
`inner`).

View file

@ -2,7 +2,7 @@
## Subscribe, store the unsubscribe handle, clean up on unmount
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
`loadRSVPs()` (subscribe block) + the matching `onUnmounted(() => unsubscribe?.())`.
```ts
@ -33,7 +33,7 @@ session-long vs view-long), not by accident.
## EOSE means "backfill done", not "all events delivered"
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
`onEose: () => { isLoaded.value = true }`.
`onEose` fires once, after the relay flushes everything stored that matches

View file

@ -1,5 +1,5 @@
<!doctype html>
<html lang="fr">
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
@ -9,12 +9,12 @@
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Sortir — Activités</title>
<meta name="apple-mobile-web-app-title" content="Sortir">
<meta name="description" content="Découvrez les activités et événements près de chez vous">
<title>%VITE_APP_NAME%</title>
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
<meta name="description" content="Discover events near you">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/activities-app/main.ts"></script>
<script type="module" src="/src/events-app/main.ts"></script>
</body>
</html>

View file

@ -15,7 +15,7 @@ http {
# PATH-MODE deployment (recommended)
#
# demo.<domain>.<com>/ — minimal AIO chakra hub
# demo.<domain>.<com>/activities/ — Sortir / activities standalone
# demo.<domain>.<com>/events/ — events standalone
# demo.<domain>.<com>/market/ — marketplace standalone
# demo.<domain>.<com>/wallet/ — wallet standalone
# demo.<domain>.<com>/chat/ — chat standalone
@ -46,11 +46,11 @@ http {
try_files $uri $uri/ /index.html;
}
# ── Activities (Sortir) ──────────────────────────────────────────
location = /activities { return 301 /activities/$is_args$args; }
location /activities/ {
alias /var/www/aio/dist-activities/;
try_files $uri $uri/ /activities.html;
# ── Events ──────────────────────────────────────────
location = /events { return 301 /events/$is_args$args; }
location /events/ {
alias /var/www/aio/dist-events/;
try_files $uri $uri/ /events.html;
}
# ── Market ───────────────────────────────────────────────────────
@ -107,13 +107,13 @@ http {
# If you want pretty subdomain URLs that funnel into the path-mode
# canonical, add 301 redirects per app. Example:
#
# events.demo.<domain>.<com> → demo.<domain>.<com>/activities/
# events.demo.<domain>.<com> → demo.<domain>.<com>/events/
# market.demo.<domain>.<com> → demo.<domain>.<com>/market/
# ───────────────────────────────────────────────────────────────────────
server {
listen 8080;
server_name events.demo.<domain>.<com>;
return 301 https://demo.<domain>.<com>/activities/$request_uri;
return 301 https://demo.<domain>.<com>/events/$request_uri;
}
server {
listen 8080;
@ -154,7 +154,7 @@ http {
#
# server { server_name app.<domain>; root /var/www/aio/dist; ... }
# server { server_name market.<domain>; root /var/www/aio/dist-market; ... }
# server { server_name sortir.<domain>; root /var/www/aio/dist-activities; ... }
# server { server_name events.<domain>; root /var/www/aio/dist-events; ... }
# server { server_name wallet.<domain>; root /var/www/aio/dist-wallet; ... }
# server { server_name chat.<domain>; root /var/www/aio/dist-chat; ... }
# server { server_name forum.<domain>; root /var/www/aio/dist-forum; ... }

View file

@ -9,9 +9,9 @@
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host",
"analyze": "vite build --mode analyze",
"dev:activities": "vite --host --config vite.activities.config.ts",
"build:activities": "vue-tsc -b && vite build --config vite.activities.config.ts",
"preview:activities": "vite preview --host --config vite.activities.config.ts",
"dev:events": "vite --host --config vite.events.config.ts",
"build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
"preview:events": "vite preview --host --config vite.events.config.ts",
"dev:libra": "vite --host --config vite.libra.config.ts",
"build:libra": "vue-tsc -b && vite build --config vite.libra.config.ts",
"preview:libra": "vite preview --host --config vite.libra.config.ts",
@ -33,8 +33,8 @@
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
"dev:all": "concurrently -n hub,libra,sortir,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
"build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
"dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
"build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder",

View file

@ -3,8 +3,8 @@ import type { AppConfig } from './core/types'
/**
* Minimal AIO hub configuration.
* The all-in-one app at app.${domain} ships only the base module
* each feature module (wallet, chat, market, tasks, forum, activities,
* libra) is now its own standalone PWA at its own subdomain.
* each feature module (wallet, chat, market, tasks, forum, events,
* libra) is now its own standalone PWA at its own subdomain.
*/
export const appConfig: AppConfig = {
modules: {

View file

@ -20,7 +20,7 @@ import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/rou
*
* The all-in-one app at app.${domain} now ships only the base module
* plus a chakra icon hub linking out to the standalone module apps
* (wallet, chat, market, tasks, forum, activities, libra).
* (wallet, chat, market, tasks, forum, events, libra).
*/
export async function createAppInstance() {
console.log('🚀 Starting AIO hub...')

View file

@ -26,7 +26,7 @@ export function useModularNavigation() {
items.push({ name: t('nav.home'), href: '/', requiresAuth: true })
// Add navigation items based on enabled modules
if (appConfig.modules.activities?.enabled) {
if (appConfig.modules.events?.enabled) {
items.push({
name: t('nav.events'),
href: '/events',
@ -67,14 +67,20 @@ export function useModularNavigation() {
const userMenuItems = computed<NavigationItem[]>(() => {
const items: NavigationItem[] = []
// Activities module items (events + tickets)
if (appConfig.modules.activities?.enabled) {
// Events module items (tickets + my events)
if (appConfig.modules.events?.enabled) {
items.push({
name: 'My Tickets',
href: '/my-tickets',
icon: 'Ticket',
requiresAuth: true
})
items.push({
name: 'My Events',
href: '/my-events',
icon: 'CalendarPlus',
requiresAuth: true
})
}
// Market module items

View file

@ -147,9 +147,9 @@ export const SERVICE_TOKENS = {
// Nostr transport (kind-21000 RPC over relays — LNbits backend)
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),
// Activities services (Nostr-native events + ticketing module)
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
// Events services (Nostr-native NIP-52 calendar events + LNbits ticketing)
EVENTS_NOSTR_SERVICE: Symbol('eventsNostrService'),
EVENTS_TICKET_API: Symbol('eventsTicketApi'),
TICKET_API: Symbol('ticketApi'),
// Invoice services

View file

@ -7,59 +7,59 @@ import { Home, Map, Heart, Ticket, Megaphone } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '@/modules/activities/stores/activities'
import { useActivities } from '@/modules/activities/composables/useActivities'
import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
import { useEventsStore } from '@/modules/events/stores/events'
import { useEvents } from '@/modules/events/composables/useEvents'
import { useApprovalState } from '@/modules/events/composables/useApprovalState'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue'
import type { TicketApiService } from '@/modules/events/services/TicketApiService'
import type { CreateEventRequest } from '@/modules/events/types/ticket'
import CreateEventDialog from '@/modules/events/components/CreateEventDialog.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { isAuthenticated, currentUser } = useAuth()
const activitiesStore = useActivitiesStore()
const eventsStore = useEventsStore()
const { isAdmin, autoApprove } = useApprovalState()
// Used to merge own LNbits drafts into the activities feed right after
// Used to merge own LNbits drafts into the events feed right after
// the user creates or edits an event otherwise the new draft only
// surfaces on the next ActivitiesPage subscribe cycle. `onlyHosting`
// surfaces on the next EventsPage subscribe cycle. `onlyHosting`
// is the feed filter that backs the Hosting bottom-nav tab tapping
// it toggles the filter on; Home tab toggles it off.
const { loadOwnEvents, onlyHosting, toggleHosting } = useActivities()
const { loadOwnEvents, onlyHosting, toggleHosting } = useEvents()
// True for /activities and its sub-routes (incl. detail pages) but
// True for /events and its sub-routes (incl. detail pages) but
// not for the routes owned by other tabs (map/favorites). Used by
// both Home and Hosting active-state predicates so the highlight
// only shifts based on the onlyHosting flag while you're in the feed.
function inFeedRoute(): boolean {
if (route.path.startsWith('/activities/map')) return false
if (route.path.startsWith('/activities/favorites')) return false
return route.path === '/activities' || route.path.startsWith('/activities/')
if (route.path.startsWith('/events/map')) return false
if (route.path.startsWith('/events/favorites')) return false
return route.path === '/events' || route.path.startsWith('/events/')
}
const tabs = computed<BottomTab[]>(() => [
{
name: t('activities.nav.feed'),
name: t('events.nav.feed'),
icon: Home,
onClick: () => {
// Tapping Home clears the hosting filter so the feed always
// returns to the unfiltered view, regardless of where the
// user just came from.
if (onlyHosting.value) toggleHosting()
if (route.path !== '/activities') router.push('/activities')
if (route.path !== '/events') router.push('/events')
},
isActive: () => inFeedRoute() && !onlyHosting.value,
},
{
name: t('activities.filters.myTickets'),
name: t('events.filters.myTickets'),
icon: Ticket,
path: '/my-tickets',
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('activities.detail.loginToBuyTickets'), {
toast.info(t('events.detail.loginToBuyTickets'), {
action: {
label: t('activities.detail.logIn'),
label: t('events.detail.logIn'),
onClick: () => router.push('/login'),
},
})
@ -70,43 +70,43 @@ const tabs = computed<BottomTab[]>(() => [
disabled: !isAuthenticated.value,
},
{
name: t('activities.filters.hosting'),
name: t('events.filters.hosting'),
icon: Megaphone,
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('activities.hosting.loginPrompt', 'Log in to manage your hosted activities'), {
toast.info(t('events.hosting.loginPrompt', 'Log in to manage your hosted events'), {
action: {
label: t('activities.favorites.logIn'),
label: t('events.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
if (!onlyHosting.value) toggleHosting()
if (route.path !== '/activities') router.push('/activities')
if (route.path !== '/events') router.push('/events')
},
isActive: () => inFeedRoute() && onlyHosting.value,
disabled: !isAuthenticated.value,
},
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
{
name: t('activities.nav.favorites'),
name: t('events.nav.favorites'),
icon: Heart,
// path kept so the tab stays active-highlighted while the user is
// on /activities/favorites; onClick wins for the actual tap so we
// on /events/favorites; onClick wins for the actual tap so we
// can gate on auth (mirrors the Create tab pattern above).
path: '/activities/favorites',
path: '/events/favorites',
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('activities.favorites.loginPrompt'), {
toast.info(t('events.favorites.loginPrompt'), {
action: {
label: t('activities.favorites.logIn'),
label: t('events.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
router.push('/activities/favorites')
router.push('/events/favorites')
},
disabled: !isAuthenticated.value,
},
@ -118,7 +118,7 @@ function isActive(path: string): boolean {
}
// Dialog mount lives at shell level so the Create tab works from any route
// within the activities standalone, not just /activities.
// within the events standalone, not just /events.
async function handleCreateEvent(eventData: CreateEventRequest) {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
@ -130,7 +130,7 @@ async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
// PUT /events/{id} requires the event's wallet admin key.
const wallet = (currentUser.value?.wallets ?? []).find(
(w) => w.id === activitiesStore.editingEvent?.wallet,
(w) => w.id === eventsStore.editingEvent?.wallet,
)
const adminKey = wallet?.adminkey
if (!adminKey) {
@ -140,18 +140,18 @@ async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest)
}
function handleDialogOpenChange(open: boolean) {
activitiesStore.showCreateDialog = open
eventsStore.showCreateDialog = open
// Closing always clears the edit selection so the next "+ Create"
// opens clean instead of inheriting the last-edited event.
if (!open) activitiesStore.editingEvent = null
if (!open) eventsStore.editingEvent = null
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive">
<CreateEventDialog
:open="activitiesStore.showCreateDialog"
:event="activitiesStore.editingEvent"
:open="eventsStore.showCreateDialog"
:event="eventsStore.editingEvent"
:is-admin="isAdmin"
:auto-approve="autoApprove"
:on-create-event="handleCreateEvent"

View file

@ -8,8 +8,8 @@ function parseMapCenter(envValue: string | undefined, fallback: { lat: number; l
}
/**
* Standalone activities app configuration.
* Only enables base + activities modules.
* Standalone events app configuration.
* Only enables base + events modules.
*/
export const appConfig: AppConfig = {
modules: {
@ -34,8 +34,8 @@ export const appConfig: AppConfig = {
}
}
},
activities: {
name: 'activities',
events: {
name: 'events',
enabled: true,
lazy: false,
config: {

View file

@ -7,7 +7,7 @@ import { container } from '@/core/di-container'
import appConfig from './app.config'
import baseModule from '@/modules/base'
import activitiesModule from '@/modules/activities'
import eventsModule from '@/modules/events'
import App from './App.vue'
@ -16,30 +16,35 @@ import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
import { acceptTokenFromUrl } from '@/lib/url-token'
const APP_NAME = (import.meta.env.VITE_APP_NAME as string) || 'Events'
// Console label shows the brand in parens only when it differs from the
// default — avoids the redundant "Events (Events)" on unbranded builds.
const APP_LABEL = APP_NAME.toLowerCase() === 'events' ? 'Events' : `Events (${APP_NAME})`
/**
* Initialize the standalone activities app
* Initialize the standalone events app
*/
export async function createAppInstance() {
console.log('🚀 Starting Sortir — Activities App...')
console.log(`🚀 Starting ${APP_LABEL}...`)
// Accept token from URL before anything else (cross-subdomain auth relay)
acceptTokenFromUrl('Sortir')
acceptTokenFromUrl(APP_NAME)
const app = createApp(App)
// Collect routes from enabled modules only
const moduleRoutes = [
...baseModule.routes || [],
...activitiesModule.routes || [],
...eventsModule.routes || [],
].filter(Boolean)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// Activities page is the home page in standalone mode
// Events page is the home page in standalone mode
{
path: '/',
redirect: '/activities'
redirect: '/events'
},
{
path: '/login',
@ -87,9 +92,9 @@ export async function createAppInstance() {
)
}
if (appConfig.modules.activities?.enabled) {
if (appConfig.modules.events?.enabled) {
moduleRegistrations.push(
pluginManager.register(activitiesModule, appConfig.modules.activities)
pluginManager.register(eventsModule, appConfig.modules.events)
)
}
@ -114,7 +119,7 @@ export async function createAppInstance() {
;(window as any).__container = container
}
console.log('✅ Sortir app initialized')
console.log(`${APP_LABEL} initialized`)
return { app, router }
}
@ -122,10 +127,10 @@ export async function startApp() {
try {
const { app } = await createAppInstance()
app.mount('#app')
console.log('🎉 Sortir app started!')
console.log(`🎉 ${APP_LABEL} started!`)
eventBus.emit('app:started', {}, 'app')
} catch (error) {
console.error('💥 Failed to start Sortir app:', error)
console.error(`💥 Failed to start ${APP_LABEL}:`, error)
document.getElementById('app')!.innerHTML = `
<div style="padding: 20px; text-align: center; color: red;">
<h1>Failed to Start</h1>

View file

@ -14,7 +14,7 @@ registerSW({
}, intervalMS)
},
onOfflineReady() {
console.log('Sortir app ready to work offline')
console.log(`${(import.meta.env.VITE_APP_NAME as string) || 'Events'} ready to work offline`)
}
})

View file

@ -35,27 +35,27 @@ async function handleLogout() {
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('activities.settings.title') }}</h1>
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('events.settings.title') }}</h1>
<!-- Account -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.account') }}</h2>
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.account') }}</h2>
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
<p class="text-sm text-foreground font-mono truncate">
{{ userPubkey }}
</p>
<Button variant="outline" size="sm" class="w-full gap-2" @click="handleLogout">
<LogOut class="w-4 h-4" />
{{ t('activities.settings.logOut') }}
{{ t('events.settings.logOut') }}
</Button>
</div>
<div v-else class="bg-muted/50 rounded-lg p-4">
<p class="text-sm text-muted-foreground mb-3">
{{ t('activities.settings.loginPrompt') }}
{{ t('events.settings.loginPrompt') }}
</p>
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
<LogIn class="w-4 h-4" />
{{ t('activities.settings.logIn') }}
{{ t('events.settings.logIn') }}
</Button>
</div>
</div>
@ -64,9 +64,9 @@ async function handleLogout() {
<!-- Appearance -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.appearance') }}</h2>
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.appearance') }}</h2>
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
<span class="text-sm text-foreground">{{ t('activities.settings.theme') }}</span>
<span class="text-sm text-foreground">{{ t('events.settings.theme') }}</span>
<Button variant="outline" size="icon" class="h-8 w-8" @click="toggleTheme">
<Sun v-if="theme === 'dark'" class="w-4 h-4" />
<Moon v-else class="w-4 h-4" />
@ -78,7 +78,7 @@ async function handleLogout() {
<!-- Language -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.language') }}</h2>
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.language') }}</h2>
<div class="flex gap-2">
<Button
v-for="lang in languages"

View file

@ -9,7 +9,6 @@ const messages: LocaleMessages = {
events: 'Events',
market: 'Market',
chat: 'Chat',
activities: 'Activities',
login: 'Login',
logout: 'Logout'
},
@ -56,10 +55,10 @@ const messages: LocaleMessages = {
de: 'German',
zh: 'Chinese'
},
activities: {
title: 'Activities',
createNew: 'Create Activity',
noActivities: 'No activities found',
events: {
title: 'Events',
createNew: 'Create Event',
noEvents: 'No events found',
filters: {
all: 'All',
today: 'Today',
@ -138,20 +137,20 @@ const messages: LocaleMessages = {
settings: 'Settings',
},
search: {
placeholder: 'Search activities...',
noResults: 'No activities found',
placeholder: 'Search events...',
noResults: 'No events found',
},
favorites: {
title: 'Favorites',
loginPrompt: 'Log in to save your favorite activities',
loginPrompt: 'Log in to save your favorite events',
empty: 'No favorites yet',
emptyHint: 'Tap the heart icon on any activity to save it here',
emptyHint: 'Tap the heart icon on any event to save it here',
logIn: 'Log in',
},
settings: {
title: 'Settings',
account: 'Account',
loginPrompt: 'Log in to bookmark activities, RSVP, and purchase tickets.',
loginPrompt: 'Log in to bookmark events, RSVP, and purchase tickets.',
logIn: 'Log in',
logOut: 'Log out',
appearance: 'Appearance',

View file

@ -9,7 +9,6 @@ const messages: LocaleMessages = {
events: 'Eventos',
market: 'Mercado',
chat: 'Chat',
activities: 'Actividades',
login: 'Iniciar Sesión',
logout: 'Cerrar Sesión'
},
@ -56,10 +55,10 @@ const messages: LocaleMessages = {
de: 'Alemán',
zh: 'Chino'
},
activities: {
title: 'Actividades',
createNew: 'Crear actividad',
noActivities: 'No se encontraron actividades',
events: {
title: 'Eventos',
createNew: 'Crear evento',
noEvents: 'No se encontraron eventos',
filters: {
all: 'Todas',
today: 'Hoy',
@ -138,20 +137,20 @@ const messages: LocaleMessages = {
settings: 'Ajustes',
},
search: {
placeholder: 'Buscar actividades...',
noResults: 'No se encontraron actividades',
placeholder: 'Buscar eventos...',
noResults: 'No se encontraron eventos',
},
favorites: {
title: 'Favoritos',
loginPrompt: 'Inicia sesión para guardar tus actividades favoritas',
loginPrompt: 'Inicia sesión para guardar tus eventos favoritos',
empty: 'Aún no tienes favoritos',
emptyHint: 'Toca el corazón en cualquier actividad para guardarla aquí',
emptyHint: 'Toca el corazón en cualquier evento para guardarlo aquí',
logIn: 'Iniciar sesión',
},
settings: {
title: 'Ajustes',
account: 'Cuenta',
loginPrompt: 'Inicia sesión para guardar actividades, confirmar asistencia y comprar boletos.',
loginPrompt: 'Inicia sesión para guardar eventos, confirmar asistencia y comprar boletos.',
logIn: 'Iniciar sesión',
logOut: 'Cerrar sesión',
appearance: 'Apariencia',

View file

@ -9,7 +9,6 @@ const messages: LocaleMessages = {
events: 'Événements',
market: 'Marché',
chat: 'Chat',
activities: 'Activités',
login: 'Connexion',
logout: 'Déconnexion'
},
@ -56,10 +55,10 @@ const messages: LocaleMessages = {
de: 'Allemand',
zh: 'Chinois'
},
activities: {
title: 'Activités',
createNew: 'Créer une activité',
noActivities: 'Aucune activité trouvée',
events: {
title: 'Événements',
createNew: 'Créer un événement',
noEvents: 'Aucun événement trouvé',
filters: {
all: 'Tout',
today: "Aujourd'hui",
@ -138,20 +137,20 @@ const messages: LocaleMessages = {
settings: 'Réglages',
},
search: {
placeholder: 'Rechercher des activités...',
noResults: 'Aucune activité trouvée',
placeholder: 'Rechercher des événements...',
noResults: 'Aucun événement trouvé',
},
favorites: {
title: 'Favoris',
loginPrompt: 'Connectez-vous pour sauvegarder vos activités préférées',
loginPrompt: 'Connectez-vous pour sauvegarder vos événements préférés',
empty: 'Pas encore de favoris',
emptyHint: "Appuyez sur le cœur d'une activité pour la sauvegarder ici",
emptyHint: "Appuyez sur le cœur d'un événement pour le sauvegarder ici",
logIn: 'Se connecter',
},
settings: {
title: 'Réglages',
account: 'Compte',
loginPrompt: 'Connectez-vous pour sauvegarder des activités, confirmer votre présence et acheter des billets.',
loginPrompt: 'Connectez-vous pour sauvegarder des événements, confirmer votre présence et acheter des billets.',
logIn: 'Se connecter',
logOut: 'Se déconnecter',
appearance: 'Apparence',

View file

@ -7,7 +7,6 @@ export interface LocaleMessages {
events: string
market: string
chat: string
activities: string
login: string
logout: string
}
@ -56,11 +55,11 @@ export interface LocaleMessages {
de: string
zh: string
}
// Activities module
activities?: {
// Events module
events?: {
title: string
createNew: string
noActivities: string
noEvents: string
filters: {
all: string
today: string

View file

@ -56,7 +56,7 @@ export function installStrictAuthGuard(router: Router): void {
/**
* Lenient guard only routes with meta.requiresAuth === true require auth.
* Used by hub and the public standalones (forum, market, tasks, activities).
* Used by hub and the public standalones (forum, market, tasks, events).
*/
export function installLenientAuthGuard(router: Router): void {
router.beforeEach(async (to) => {

View file

@ -1,278 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog'
import {
FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { CalendarPlus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
import type { ActivityCategory } from '../types/category'
import CategorySelector from './CategorySelector.vue'
import LocationPicker from './LocationPicker.vue'
import { toast } from 'vue-sonner'
defineProps<{
isOpen: boolean
}>()
const emit = defineEmits<{
'update:isOpen': [value: boolean]
'created': []
}>()
const { t } = useI18n()
const { currentUser } = useAuth()
const isPublishing = ref(false)
const selectedCategories = ref<ActivityCategory[]>([])
const location = ref('')
const formSchema = toTypedSchema(z.object({
title: z.string().min(1, 'Title is required').max(200),
summary: z.string().max(500).optional(),
description: z.string().min(1, 'Description is required').max(5000),
startDate: z.string().min(1, 'Start date is required'),
startTime: z.string().min(1, 'Start time is required'),
endDate: z.string().optional(),
endTime: z.string().optional(),
image: z.string().url('Must be a valid URL').optional().or(z.literal('')),
}))
const form = useForm({
validationSchema: formSchema,
initialValues: {
title: '',
summary: '',
description: '',
startDate: '',
startTime: '',
endDate: '',
endTime: '',
image: '',
},
})
const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => {
const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
if (!ticketApi) {
toast.error('Activities service not available')
return
}
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
if (!invoiceKey) {
toast.error('No wallet available. Please log in first.')
return
}
isPublishing.value = true
try {
// Compose ISO 8601 datetime strings the events extension parses.
const startIso = `${values.startDate}T${values.startTime}`
const endIso =
values.endDate && values.endTime
? `${values.endDate}T${values.endTime}`
: undefined
// Fold summary + description into `info` since the events extension
// CreateEventRequest has no separate summary field.
const info =
values.summary && values.description
? `${values.summary}\n\n${values.description}`
: values.description || values.summary || ''
// Ticket-less activity amount_tickets and price_per_ticket both
// pinned at 0 (events extension treats 0 as "unlimited / not
// ticketed" per models.py:45-46). Server-side `signer.sign_event`
// produces the kind-31922 calendar event and publishes via the
// operator's configured relays no webapp signing path needed.
const eventData: CreateEventRequest = {
name: values.title,
info,
event_start_date: startIso,
event_end_date: endIso,
location: location.value || null,
banner: values.image || null,
categories: selectedCategories.value,
amount_tickets: 0,
price_per_ticket: 0,
}
await ticketApi.createEvent(eventData, invoiceKey)
// Approval workflow caveat: non-admin users on instances with
// `auto_approve=false` (the default) land in the proposal queue;
// their event isn't published to relays until an admin approves.
// Admins-and-auto-approve-on instances publish immediately.
toast.success('Activity created!')
emit('created')
handleClose()
} catch (err) {
console.error('Failed to create activity:', err)
toast.error(err instanceof Error ? err.message : 'Failed to create activity')
} finally {
isPublishing.value = false
}
})
function handleClose() {
emit('update:isOpen', false)
form.resetForm()
selectedCategories.value = []
location.value = ''
}
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<CalendarPlus class="w-5 h-5" />
{{ t('activities.createNew') }}
</DialogTitle>
<DialogDescription>
Publish a new activity to Nostr relays
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4 py-2">
<!-- Title -->
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input placeholder="e.g. Marché de Noël de Foix" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Summary -->
<FormField v-slot="{ componentField }" name="summary">
<FormItem>
<FormLabel>Summary</FormLabel>
<FormControl>
<Input placeholder="Brief one-line description" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="Full details about the activity..."
v-bind="componentField"
:disabled="isPublishing"
rows="4"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Start date/time -->
<div class="grid grid-cols-2 gap-3">
<FormField v-slot="{ componentField }" name="startDate">
<FormItem>
<FormLabel>Start date *</FormLabel>
<FormControl>
<Input type="date" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="startTime">
<FormItem>
<FormLabel>Start time *</FormLabel>
<FormControl>
<Input type="time" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- End date/time -->
<div class="grid grid-cols-2 gap-3">
<FormField v-slot="{ componentField }" name="endDate">
<FormItem>
<FormLabel>End date</FormLabel>
<FormControl>
<Input type="date" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="endTime">
<FormItem>
<FormLabel>End time</FormLabel>
<FormControl>
<Input type="time" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Location -->
<LocationPicker
v-model="location"
:disabled="isPublishing"
/>
<!-- Categories -->
<div class="space-y-1.5">
<label class="text-sm font-medium">Categories</label>
<CategorySelector v-model="selectedCategories" />
</div>
<!-- Image URL -->
<FormField v-slot="{ componentField }" name="image">
<FormItem>
<FormLabel>Image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/image.jpg"
v-bind="componentField"
:disabled="isPublishing"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit -->
<Button
type="submit"
:disabled="isPublishing || !isFormValid"
class="w-full"
>
<span v-if="isPublishing" class="animate-spin mr-2"></span>
{{ isPublishing ? 'Publishing...' : 'Publish Activity' }}
</Button>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -1,55 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { Map } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import ActivityMap from '../components/ActivityMap.vue'
const { allActivities, isLoading, subscribe } = useActivities()
function parseMapCenter(): { lat: number; lng: number } | undefined {
const raw = import.meta.env.VITE_DEFAULT_MAP_CENTER
if (!raw) return undefined
const [lat, lng] = raw.split(',').map(Number)
if (isNaN(lat) || isNaN(lng)) return undefined
return { lat, lng }
}
const mapCenter = parseMapCenter()
const geoActivities = computed(() =>
allActivities.value.filter(a => a.coordinates)
)
onMounted(() => {
subscribe()
})
</script>
<template>
<div class="flex flex-col h-[calc(100dvh-3.5rem)]">
<!-- Loading overlay -->
<div v-if="isLoading && geoActivities.length === 0" class="flex-1 flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<!-- No geotagged activities -->
<div v-else-if="!isLoading && geoActivities.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">No geotagged activities found</p>
<p class="text-sm text-muted-foreground/70 mt-1">Activities with location data will appear as markers on the map</p>
</div>
<!-- Map -->
<ActivityMap
v-else
:activities="geoActivities"
:center="mapCenter"
class="flex-1"
/>
<!-- Activity count -->
<div v-if="geoActivities.length > 0" class="px-4 py-2 text-xs text-muted-foreground border-t bg-background">
{{ geoActivities.length }} activit{{ geoActivities.length === 1 ? 'y' : 'ies' }} on map
</div>
</div>
</template>

View file

@ -18,8 +18,8 @@ const router = useRouter()
const { isAuthenticated } = useAuth()
const { isBookmarked, toggleBookmark } = useBookmarks()
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const bookmarked = computed(() => isBookmarked(activityKind.value, props.pubkey, props.dTag))
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
function handleToggle() {
if (!isAuthenticated.value) {
@ -31,7 +31,7 @@ function handleToggle() {
})
return
}
toggleBookmark(activityKind.value, props.pubkey, props.dTag)
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
}
</script>

View file

@ -3,22 +3,22 @@ import { useI18n } from 'vue-i18n'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
import type { ActivityCategory } from '../types/category'
import type { EventCategory } from '../types/category'
import { ALL_CATEGORIES } from '../types/category'
const props = defineProps<{
selected: ActivityCategory[]
selected: EventCategory[]
}>()
const emit = defineEmits<{
toggle: [category: ActivityCategory]
toggle: [category: EventCategory]
clear: []
}>()
const { t } = useI18n()
function categoryLabel(cat: ActivityCategory): string {
return t(`activities.categories.${cat}`, cat)
function categoryLabel(cat: EventCategory): string {
return t(`events.categories.${cat}`, cat)
}
</script>

View file

@ -1,20 +1,20 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Badge } from '@/components/ui/badge'
import type { ActivityCategory } from '../types/category'
import type { EventCategory } from '../types/category'
import { ALL_CATEGORIES } from '../types/category'
const props = defineProps<{
modelValue: ActivityCategory[]
modelValue: EventCategory[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: ActivityCategory[]]
'update:modelValue': [value: EventCategory[]]
}>()
const { t } = useI18n()
function toggle(cat: ActivityCategory) {
function toggle(cat: EventCategory) {
const current = [...props.modelValue]
const idx = current.indexOf(cat)
if (idx >= 0) {
@ -25,8 +25,8 @@ function toggle(cat: ActivityCategory) {
emit('update:modelValue', current)
}
function label(cat: ActivityCategory): string {
return t(`activities.categories.${cat}`, cat)
function label(cat: EventCategory): string {
return t(`events.categories.${cat}`, cat)
}
</script>

View file

@ -577,7 +577,7 @@ const handleOpenChange = (open: boolean) => {
class="cursor-pointer text-xs capitalize"
@click="toggleCategory(cat)"
>
{{ t(`activities.categories.${cat}`, cat) }}
{{ t(`events.categories.${cat}`, cat) }}
</Badge>
</div>
</div>

View file

@ -8,15 +8,15 @@ import {
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useDateLocale } from '../composables/useDateLocale'
import type { Activity } from '../types/activity'
import type { Event } from '../types/event'
const props = defineProps<{
activities: Activity[]
events: Event[]
}>()
const emit = defineEmits<{
selectDate: [date: Date]
selectActivity: [activity: Activity]
selectEvent: [event: Event]
}>()
const { dateLocale } = useDateLocale()
@ -47,31 +47,31 @@ const calendarDays = computed(() => {
return eachDayOfInterval({ start: calStart, end: calEnd })
})
// Map of date string -> activities on that day
const activityDayMap = computed(() => {
const map = new Map<string, Activity[]>()
for (const activity of props.activities) {
if (!activity.startDate || isNaN(activity.startDate.getTime())) continue
const key = format(activity.startDate, 'yyyy-MM-dd')
// Map of date string -> events on that day
const eventDayMap = computed(() => {
const map = new Map<string, Event[]>()
for (const event of props.events) {
if (!event.startDate || isNaN(event.startDate.getTime())) continue
const key = format(event.startDate, 'yyyy-MM-dd')
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(activity)
map.get(key)!.push(event)
}
return map
})
function getActivitiesForDay(date: Date): Activity[] {
function getEventsForDay(date: Date): Event[] {
const key = format(date, 'yyyy-MM-dd')
return activityDayMap.value.get(key) ?? []
return eventDayMap.value.get(key) ?? []
}
function getDotCount(date: Date): number {
return Math.min(getActivitiesForDay(date).length, 3)
return Math.min(getEventsForDay(date).length, 3)
}
const selectedDay = ref<Date | null>(null)
const selectedDayActivities = computed(() => {
const selectedDayEvents = computed(() => {
if (!selectedDay.value) return []
return getActivitiesForDay(selectedDay.value)
return getEventsForDay(selectedDay.value)
})
function selectDay(date: Date) {
@ -133,7 +133,7 @@ function nextMonth() {
@click="selectDay(date)"
>
<span class="text-sm">{{ format(date, 'd') }}</span>
<!-- Activity dots -->
<!-- Event dots -->
<div v-if="getDotCount(date) > 0" class="flex gap-0.5 mt-0.5">
<div
v-for="i in getDotCount(date)"
@ -145,36 +145,36 @@ function nextMonth() {
</button>
</div>
<!-- Selected day activities -->
<!-- Selected day events -->
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
<h3 class="text-sm font-medium text-muted-foreground">
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
<span v-if="selectedDayActivities.length > 0" class="ml-1">
({{ selectedDayActivities.length }})
<span v-if="selectedDayEvents.length > 0" class="ml-1">
({{ selectedDayEvents.length }})
</span>
</h3>
<div v-if="selectedDayActivities.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
No activities on this day
<div v-if="selectedDayEvents.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
No events on this day
</div>
<div
v-for="activity in selectedDayActivities"
:key="activity.nostrEventId"
v-for="event in selectedDayEvents"
:key="event.nostrEventId"
class="flex items-center gap-3 p-2 rounded-lg hover:bg-muted cursor-pointer"
@click="emit('selectActivity', activity)"
@click="emit('selectEvent', event)"
>
<img
v-if="activity.image"
:src="activity.image"
:alt="activity.title"
v-if="event.image"
:src="event.image"
:alt="event.title"
class="w-12 h-12 rounded object-cover shrink-0"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
<p class="text-xs text-muted-foreground truncate">
{{ activity.type === 'time' ? format(activity.startDate, 'HH:mm') : '' }}
{{ activity.location ? `· ${activity.location}` : '' }}
{{ event.type === 'time' ? format(event.startDate, 'HH:mm') : '' }}
{{ event.location ? `· ${event.location}` : '' }}
</p>
</div>
</div>

View file

@ -8,10 +8,10 @@ import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vu
import BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import type { Activity } from '../types/activity'
import type { Event } from '../types/event'
const props = defineProps<{
activity: Activity
event: Event
/** Render a compact row: no hero image, no summary, single-line
* title, tighter padding. Used by the Hosting view where the
* host already knows what their events look like. */
@ -19,17 +19,17 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
click: [activity: Activity]
click: [event: Event]
}>()
const { t } = useI18n()
const { dateLocale } = useDateLocale()
const { paidCount } = useOwnedTickets()
const ownedCount = computed(() => paidCount(props.activity.id))
const ownedCount = computed(() => paidCount(props.event.id))
const dateDisplay = computed(() => {
const a = props.activity
const a = props.event
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
try {
const opts = { locale: dateLocale.value }
@ -45,19 +45,19 @@ const dateDisplay = computed(() => {
})
const categoryLabel = computed(() => {
if (!props.activity.category) return null
return t(`activities.categories.${props.activity.category}`, props.activity.category)
if (!props.event.category) return null
return t(`events.categories.${props.event.category}`, props.event.category)
})
const priceDisplay = computed(() => {
const info = props.activity.ticketInfo
const info = props.event.ticketInfo
if (!info) return null
if (info.price === 0) return t('activities.detail.free')
if (info.price === 0) return t('events.detail.free')
return `${info.price} ${info.currency}`
})
const isPast = computed(() => {
const a = props.activity
const a = props.event
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
@ -66,14 +66,14 @@ const isPast = computed(() => {
// Pending / rejected events get a washed-out look so the user
// sees at a glance the event isn't live, not just the small badge.
const isNonApproved = computed(
() => !!props.activity.lnbitsStatus && props.activity.lnbitsStatus !== 'approved',
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved',
)
</script>
<template>
<Card
class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
@click="emit('click', activity)"
@click="emit('click', event)"
>
<!-- Wash-out wrapper. The pending/rejected status badge below sits
OUTSIDE this wrapper so it stays in full color and reads
@ -90,9 +90,9 @@ const isNonApproved = computed(
vertically centered against a taller content column so we
don't get a top-anchored thumb with dead space below. -->
<img
v-if="compact && activity.image"
:src="activity.image"
:alt="activity.title"
v-if="compact && event.image"
:src="event.image"
:alt="event.title"
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
loading="lazy"
/>
@ -101,10 +101,10 @@ const isNonApproved = computed(
badges inline at the top of the content block the solid-
color placeholder + calendar glyph wasn't communicating
anything the title + details don't already. -->
<div v-if="activity.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
<img
:src="activity.image"
:alt="activity.title"
:src="event.image"
:alt="event.title"
class="w-full h-full object-cover"
loading="lazy"
/>
@ -121,7 +121,7 @@ const isNonApproved = computed(
<!-- Ownership badge the creator can spot their own events at a
glance on the feed. -->
<Badge
v-if="activity.isMine"
v-if="event.isMine"
variant="outline"
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
@ -137,7 +137,7 @@ const isNonApproved = computed(
{{ priceDisplay }}
</Badge>
<!-- Past badge shown when the activity has already ended. The
<!-- Past badge shown when the event has already ended. The
pending/rejected status badge that used to share this slot
is now an absolute overlay on Card root, above the wash-out,
so we still suppress Past when isNonApproved (the status
@ -148,7 +148,7 @@ const isNonApproved = computed(
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
<History class="w-3 h-3" />
{{ t('activities.filters.past', 'Past') }}
{{ t('events.filters.past', 'Past') }}
</Badge>
</div>
@ -159,14 +159,14 @@ const isNonApproved = computed(
badges as the image-overlay set, stacked horizontally at the
top of the content area. The "Yours" chip is dropped in
compact mode since every card in the hosting view is owned. -->
<div v-if="!activity.image || compact" class="flex flex-wrap items-center gap-1.5">
<div v-if="!event.image || compact" class="flex flex-wrap items-center gap-1.5">
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }}
</Badge>
<Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }}
</Badge>
<Badge v-if="activity.isMine && !compact" variant="outline" class="text-xs gap-1">
<Badge v-if="event.isMine && !compact" variant="outline" class="text-xs gap-1">
<User class="w-3 h-3" />
Yours
</Badge>
@ -176,7 +176,7 @@ const isNonApproved = computed(
class="text-xs gap-1"
>
<History class="w-3 h-3" />
{{ t('activities.filters.past', 'Past') }}
{{ t('events.filters.past', 'Past') }}
</Badge>
</div>
@ -190,21 +190,21 @@ const isNonApproved = computed(
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
]"
>
{{ activity.title }}
{{ event.title }}
</h3>
<BookmarkButton
v-if="!compact"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
<!-- Summary (hidden in compact mode) -->
<p
v-if="activity.summary && !compact"
v-if="event.summary && !compact"
class="text-sm text-muted-foreground line-clamp-2"
>
{{ activity.summary }}
{{ event.summary }}
</p>
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
@ -216,34 +216,34 @@ const isNonApproved = computed(
<!-- Location -->
<div
v-if="activity.location"
v-if="event.location"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<MapPin class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ activity.location }}</span>
<span class="truncate">{{ event.location }}</span>
</div>
<!-- Tickets available. `available === undefined` means
unlimited capacity (no `tickets_available` tag was
published); show the price-only line in that case. -->
<div
v-if="activity.ticketInfo"
v-if="event.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
<span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
<span v-else-if="event.ticketInfo.available > 0">
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }}
{{ t('events.detail.soldOut') }}
</span>
</div>
<!-- Owned tickets shown when the current user holds at
least one paid ticket for this activity. Sits next to
least one paid ticket for this event. Sits next to
the availability line so the buyer can see at a glance
whether they've already bought in. -->
<div
@ -252,7 +252,7 @@ const isNonApproved = computed(
>
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span>
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
{{ t('events.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
</span>
</div>
</div>
@ -272,7 +272,7 @@ const isNonApproved = computed(
variant="destructive"
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
</Card>
</template>

View file

@ -1,11 +1,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { CalendarSearch } from 'lucide-vue-next'
import ActivityCard from './ActivityCard.vue'
import type { Activity } from '../types/activity'
import EventCard from './EventCard.vue'
import type { Event } from '../types/event'
defineProps<{
activities: Activity[]
events: Event[]
isLoading?: boolean
/** Render compact rows instead of full-image cards. Used by the
* Hosting view so an operator can scan their roster of events
@ -14,7 +14,7 @@ defineProps<{
}>()
const emit = defineEmits<{
select: [activity: Activity]
select: [event: Event]
}>()
const { t } = useI18n()
@ -39,16 +39,16 @@ const { t } = useI18n()
<!-- Empty state -->
<div
v-else-if="activities.length === 0"
v-else-if="events.length === 0"
class="flex flex-col items-center justify-center py-16 text-center"
>
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<h3 class="text-lg font-medium text-foreground">
{{ t('activities.noActivities') }}
{{ t('events.noEvents') }}
</h3>
</div>
<!-- Activity grid compact mode collapses to a single column of
<!-- Event grid compact mode collapses to a single column of
tight rows; default mode is the responsive card grid. The
compact gap is bumped a notch so the status badge spilling
past the card's bottom edge has room to sit between cards. -->
@ -56,12 +56,12 @@ const { t } = useI18n()
v-else
:class="compact ? 'flex flex-col gap-4' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'"
>
<ActivityCard
v-for="activity in activities"
:key="activity.nostrEventId"
:activity="activity"
<EventCard
v-for="event in events"
:key="event.nostrEventId"
:event="event"
:compact="compact"
@click="emit('select', activity)"
@click="emit('select', event)"
/>
</div>
</template>

View file

@ -3,10 +3,10 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { Activity } from '../types/activity'
import type { Event } from '../types/event'
const props = defineProps<{
activities: Activity[]
events: Event[]
center?: { lat: number; lng: number }
zoom?: number
}>()
@ -54,19 +54,19 @@ function updateMarkers() {
markerGroup.clearLayers()
const geoActivities = props.activities.filter(a => a.coordinates)
const geoEvents = props.events.filter(a => a.coordinates)
for (const activity of geoActivities) {
const { lat, lng } = activity.coordinates!
for (const event of geoEvents) {
const { lat, lng } = event.coordinates!
const marker = L.marker([lat, lng], { icon: defaultIcon })
const popupContent = `
<div style="min-width: 200px; cursor: pointer;" class="activity-popup">
${activity.image ? `<img src="${activity.image}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;" />` : ''}
<div style="font-weight: 600; font-size: 14px; margin-bottom: 4px;">${escapeHtml(activity.title)}</div>
${activity.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(activity.location)}</div>` : ''}
<div style="font-size: 12px; color: #888;">📅 ${activity.startDate.toLocaleDateString()}</div>
<div style="min-width: 200px; cursor: pointer;" class="event-popup">
${event.image ? `<img src="${event.image}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;" />` : ''}
<div style="font-weight: 600; font-size: 14px; margin-bottom: 4px;">${escapeHtml(event.title)}</div>
${event.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(event.location)}</div>` : ''}
<div style="font-size: 12px; color: #888;">📅 ${event.startDate.toLocaleDateString()}</div>
</div>
`
@ -79,10 +79,10 @@ function updateMarkers() {
const popup = marker.getPopup()
if (popup) {
const el = popup.getElement()
const content = el?.querySelector('.activity-popup')
const content = el?.querySelector('.event-popup')
if (content) {
(content as HTMLElement).onclick = () => {
router.push({ name: 'activity-detail', params: { id: activity.id } })
router.push({ name: 'event-detail', params: { id: event.id } })
}
}
}
@ -91,11 +91,11 @@ function updateMarkers() {
markerGroup.addLayer(marker)
}
// Fit bounds only on first load, not when new activities stream in
if (!hasFittedBounds && geoActivities.length > 0) {
// Fit bounds only on first load, not when new events stream in
if (!hasFittedBounds && geoEvents.length > 0) {
hasFittedBounds = true
const bounds = L.latLngBounds(
geoActivities.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
geoEvents.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
)
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 })
}
@ -107,7 +107,7 @@ function escapeHtml(text: string): string {
return div.innerHTML
}
watch(() => props.activities, updateMarkers, { deep: true })
watch(() => props.events, updateMarkers, { deep: true })
onMounted(() => {
initMap()

View file

@ -7,14 +7,14 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
import type { Activity } from '../types/activity'
import type { Event } from '../types/event'
const props = defineProps<{
activities: Activity[]
events: Event[]
}>()
const emit = defineEmits<{
select: [activity: Activity]
select: [event: Event]
}>()
const { t } = useI18n()
@ -22,7 +22,7 @@ const { dateLocale } = useDateLocale()
const isOpen = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const searchOptions: FuzzySearchOptions<Activity> = {
const searchOptions: FuzzySearchOptions<Event> = {
fuseOptions: {
keys: [
{ name: 'title', weight: 0.5 },
@ -39,7 +39,7 @@ const searchOptions: FuzzySearchOptions<Activity> = {
resultLimit: 8,
}
const activitiesRef = computed(() => props.activities)
const eventsRef = computed(() => props.events)
const {
searchQuery,
@ -47,26 +47,26 @@ const {
isSearching,
clearSearch,
setSearchQuery,
} = useFuzzySearch(activitiesRef, searchOptions)
} = useFuzzySearch(eventsRef, searchOptions)
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
function formatDate(activity: Activity): string {
if (!activity.startDate || isNaN(activity.startDate.getTime())) return ''
function formatDate(event: Event): string {
if (!event.startDate || isNaN(event.startDate.getTime())) return ''
try {
const opts = { locale: dateLocale.value }
if (activity.type === 'date') return format(activity.startDate, 'MMM d', opts)
return format(activity.startDate, 'MMM d · HH:mm', opts)
if (event.type === 'date') return format(event.startDate, 'MMM d', opts)
return format(event.startDate, 'MMM d · HH:mm', opts)
} catch {
return ''
}
}
function handleSelect(activity: Activity) {
function handleSelect(event: Event) {
clearSearch()
isOpen.value = false
emit('select', activity)
emit('select', event)
}
function handleClear() {
@ -110,7 +110,7 @@ watch(isOpen, (open) => {
:model-value="searchQuery"
@update:model-value="handleInput"
@focus="handleFocus"
:placeholder="t('activities.search.placeholder')"
:placeholder="t('events.search.placeholder')"
class="pl-9 pr-9"
/>
<Button
@ -131,21 +131,21 @@ watch(isOpen, (open) => {
>
<!-- No results -->
<div v-if="showNoResults" class="p-4 text-sm text-muted-foreground text-center">
{{ t('activities.search.noResults') }}
{{ t('events.search.noResults') }}
</div>
<!-- Result items -->
<button
v-for="activity in filteredItems"
:key="activity.nostrEventId"
v-for="event in filteredItems"
:key="event.nostrEventId"
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-muted transition-colors text-left border-b last:border-b-0"
@click="handleSelect(activity)"
@click="handleSelect(event)"
>
<!-- Thumbnail -->
<img
v-if="activity.image"
:src="activity.image"
:alt="activity.title"
v-if="event.image"
:src="event.image"
:alt="event.title"
class="w-10 h-10 rounded object-cover shrink-0"
/>
<div v-else class="w-10 h-10 rounded bg-muted flex items-center justify-center shrink-0">
@ -154,12 +154,12 @@ watch(isOpen, (open) => {
<!-- Info -->
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span v-if="formatDate(activity)" class="truncate">{{ formatDate(activity) }}</span>
<span v-if="activity.location" class="flex items-center gap-0.5 truncate">
<span v-if="formatDate(event)" class="truncate">{{ formatDate(event) }}</span>
<span v-if="event.location" class="flex items-center gap-0.5 truncate">
<MapPin class="w-2.5 h-2.5 shrink-0" />
{{ activity.location }}
{{ event.location }}
</span>
</div>
</div>

View file

@ -20,15 +20,15 @@ const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP()
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const myStatus = computed(() => getMyRSVP(activityKind.value, props.pubkey, props.dTag))
const goingCount = computed(() => getRSVPCount(activityKind.value, props.pubkey, props.dTag))
const pending = computed(() => isPending(activityKind.value, props.pubkey, props.dTag))
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const myStatus = computed(() => getMyRSVP(eventKind.value, props.pubkey, props.dTag))
const goingCount = computed(() => getRSVPCount(eventKind.value, props.pubkey, props.dTag))
const pending = computed(() => isPending(eventKind.value, props.pubkey, props.dTag))
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
{ status: 'accepted', labelKey: 'activities.detail.going', icon: Check },
{ status: 'tentative', labelKey: 'activities.detail.maybe', icon: HelpCircle },
{ status: 'declined', labelKey: 'activities.detail.notGoing', icon: X },
{ status: 'accepted', labelKey: 'events.detail.going', icon: Check },
{ status: 'tentative', labelKey: 'events.detail.maybe', icon: HelpCircle },
{ status: 'declined', labelKey: 'events.detail.notGoing', icon: X },
]
const statusLabel: Record<RSVPStatus, string> = {
@ -47,7 +47,7 @@ async function handleClick(status: RSVPStatus) {
})
return
}
const published = await setRSVP(activityKind.value, props.pubkey, props.dTag, status)
const published = await setRSVP(eventKind.value, props.pubkey, props.dTag, status)
if (published) {
toast.success(statusLabel[published])
} else if (!pending.value) {

View file

@ -14,11 +14,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const options: { value: TemporalFilter; labelKey: string }[] = [
{ value: 'all', labelKey: 'activities.filters.all' },
{ value: 'today', labelKey: 'activities.filters.today' },
{ value: 'tomorrow', labelKey: 'activities.filters.tomorrow' },
{ value: 'this-week', labelKey: 'activities.filters.thisWeek' },
{ value: 'this-month', labelKey: 'activities.filters.thisMonth' },
{ value: 'all', labelKey: 'events.filters.all' },
{ value: 'today', labelKey: 'events.filters.today' },
{ value: 'tomorrow', labelKey: 'events.filters.tomorrow' },
{ value: 'this-week', labelKey: 'events.filters.thisWeek' },
{ value: 'this-month', labelKey: 'events.filters.thisMonth' },
]
</script>

View file

@ -17,7 +17,7 @@ import type { TicketApiService } from '../services/TicketApiService'
* when in doubt). Probe re-runs whenever auth flips to authenticated.
*
* Used by every surface that opens the edit-mode CreateEventDialog
* (activities-app/App.vue shell mount, activities EventsPage). Keeps
* (events-app/App.vue shell mount, events EventsPage). Keeps
* the probe logic single-source-of-truth.
*/
export function useApprovalState() {

View file

@ -5,7 +5,7 @@ import { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
/**
* NIP-51 Bookmarks (kind 10003) for saving favorite activities.
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
*
* Stores references to NIP-52 calendar events as 'a' tags:
* ['a', '<kind>:<pubkey>:<d-tag>']
@ -17,7 +17,7 @@ import { signEventViaLnbits } from '@/lib/nostr/signing'
const BOOKMARK_KIND = 10003
interface BookmarkState {
/** Set of bookmarked activity coordinates: "kind:pubkey:d-tag" */
/** Set of bookmarked event coordinates: "kind:pubkey:d-tag" */
bookmarkedCoords: Set<string>
/** The latest bookmark event we've seen */
lastEventId: string | null
@ -36,8 +36,8 @@ export function useBookmarks() {
const bookmarkedIds = computed(() => state.value.bookmarkedCoords)
function isBookmarked(activityKind: number, pubkey: string, dTag: string): boolean {
return state.value.bookmarkedCoords.has(`${activityKind}:${pubkey}:${dTag}`)
function isBookmarked(eventKind: number, pubkey: string, dTag: string): boolean {
return state.value.bookmarkedCoords.has(`${eventKind}:${pubkey}:${dTag}`)
}
function isBookmarkedByDTag(dTag: string): boolean {
@ -87,12 +87,12 @@ export function useBookmarks() {
}
/**
* Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list.
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
*/
async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) {
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
const coord = `${activityKind}:${pubkey}:${dTag}`
const coord = `${eventKind}:${pubkey}:${dTag}`
const newCoords = new Set(state.value.bookmarkedCoords)
if (newCoords.has(coord)) {

View file

@ -1,30 +1,30 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import { useActivitiesStore } from '../stores/activities'
import type { Activity } from '../types/activity'
import type { EventsNostrService } from '../services/EventsNostrService'
import { useEventsStore } from '../stores/events'
import type { Event } from '../types/event'
/**
* Composable for loading a single activity by its d-tag identifier.
* Composable for loading a single event by its d-tag identifier.
* First checks the store cache, then queries relays if not found.
*/
export function useActivityDetail(activityId: string) {
const store = useActivitiesStore()
export function useEventDetail(eventId: string) {
const store = useEventsStore()
const isLoading = ref(false)
const error = ref<string | null>(null)
let unsubscribe: (() => void) | null = null
const activity = computed<Activity | undefined>(() =>
store.getActivityById(activityId)
const event = computed<Event | undefined>(() =>
store.getEventById(eventId)
)
async function load() {
// Already in cache
if (activity.value) return
if (event.value) return
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
if (!nostrService) {
error.value = 'Activities service not available'
error.value = 'Events service not available'
return
}
@ -33,16 +33,16 @@ export function useActivityDetail(activityId: string) {
error.value = null
// Scope both the subscription and the one-shot query to this
// activity's d-tag. Without this scope, the query asks every
// event's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout
// to find ours — on a cold page refresh that race is often lost
// even when the activity is reachable.
const detailFilters = { dTags: [activityId] }
// even when the event is reachable.
const detailFilters = { dTags: [eventId] }
unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => {
store.upsertActivity(incoming)
if (incoming.id === activityId) {
store.upsertEvent(incoming)
if (incoming.id === eventId) {
isLoading.value = false
}
},
@ -50,17 +50,17 @@ export function useActivityDetail(activityId: string) {
)
const results = await nostrService.queryCalendarEvents(detailFilters)
store.upsertActivities(results)
store.upsertEvents(results)
// If we still don't have it after query, stop loading
setTimeout(() => {
isLoading.value = false
if (!activity.value) {
error.value = 'Activity not found'
if (!event.value) {
error.value = 'Event not found'
}
}, 5000)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load activity'
error.value = err instanceof Error ? err.message : 'Failed to load event'
isLoading.value = false
}
}
@ -76,7 +76,7 @@ export function useActivityDetail(activityId: string) {
})
return {
activity,
event,
isLoading,
error,
reload: load,

View file

@ -3,48 +3,48 @@ import {
startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays, isSameDay,
} from 'date-fns'
import type { Activity } from '../types/activity'
import type { ActivityCategory } from '../types/category'
import type { TemporalFilter, ActivityFilters } from '../types/filters'
import type { Event } from '../types/event'
import type { EventCategory } from '../types/category'
import type { TemporalFilter, EventFilters } from '../types/filters'
import { DEFAULT_FILTERS } from '../types/filters'
// Filter state is hoisted to module scope so every `useActivities()` /
// `useActivityFilters()` call shares the same refs. The bottom-nav
// Hosting tab in activities-app/App.vue and the feed view in
// ActivitiesPage.vue both rely on this — without a shared instance,
// Filter state is hoisted to module scope so every `useEvents()` /
// `useEventFilters()` call shares the same refs. The bottom-nav
// Hosting tab in events-app/App.vue and the feed view in
// EventsPage.vue both rely on this — without a shared instance,
// tapping Hosting toggled a private ref the page never saw.
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const selectedCategories = ref<EventCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
const onlyOwnedTickets = ref(false)
const onlyHosting = ref(false)
const showPast = ref(false)
/**
* Composable for managing activity filter state and applying filters reactively.
* Composable for managing event filter state and applying filters reactively.
*/
export function useActivityFilters() {
export function useEventFilters() {
const filters = computed<ActivityFilters>(() => ({
const filters = computed<EventFilters>(() => ({
temporal: temporal.value,
categories: selectedCategories.value,
}))
/**
* Apply the current filters to a list of activities.
* Apply the current filters to a list of events.
*/
function applyFilters(activities: Activity[]): Activity[] {
let result = activities
function applyFilters(events: Event[]): Event[] {
let result = events
// Specific date filter (from DatePickerStrip) takes priority over
// temporal. Picking a date also bypasses the past/upcoming split
// so the user can browse activities for any day they choose.
// so the user can browse events for any day they choose.
if (selectedDate.value) {
const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value)
result = result.filter(a => {
const activityEnd = a.endDate ?? a.startDate
return a.startDate <= dayEnd && activityEnd >= dayStart
const eventEnd = a.endDate ?? a.startDate
return a.startDate <= dayEnd && eventEnd >= dayStart
})
} else {
// Temporal filter
@ -56,8 +56,8 @@ export function useActivityFilters() {
// showPast=true shows only the days already passed this week.
const now = new Date()
result = result.filter(a => {
const activityEnd = a.endDate ?? a.startDate
return showPast.value ? activityEnd < now : activityEnd >= now
const eventEnd = a.endDate ?? a.startDate
return showPast.value ? eventEnd < now : eventEnd >= now
})
}
@ -68,8 +68,8 @@ export function useActivityFilters() {
)
}
// Hosting filter — activities the signed-in user organizes.
// Read off `activity.isMine` which `useActivities.tagOwnership()`
// Hosting filter — events the signed-in user organizes.
// Read off `event.isMine` which `useEvents.tagOwnership()`
// populates from organizer-pubkey match + LNbits drafts.
if (onlyHosting.value) {
result = result.filter(a => a.isMine === true)
@ -92,7 +92,7 @@ export function useActivityFilters() {
}
}
function toggleCategory(category: ActivityCategory) {
function toggleCategory(category: EventCategory) {
const idx = selectedCategories.value.indexOf(category)
if (idx >= 0) {
selectedCategories.value.splice(idx, 1)
@ -161,8 +161,8 @@ export function useActivityFilters() {
// --- Helpers ---
function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Activity[] {
if (filter === 'all') return activities
function applyTemporalFilter(events: Event[], filter: TemporalFilter): Event[] {
if (filter === 'all') return events
const now = new Date()
let start: Date
@ -186,12 +186,12 @@ function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Ac
end = endOfMonth(now)
break
default:
return activities
return events
}
return activities.filter(a => {
const activityEnd = a.endDate ?? a.startDate
// Activity overlaps with the filter range
return a.startDate <= end && activityEnd >= start
return events.filter(a => {
const eventEnd = a.endDate ?? a.startDate
// Event overlaps with the filter range
return a.startDate <= end && eventEnd >= start
})
}

View file

@ -1,24 +1,24 @@
import { ref, computed, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
import type { EventsNostrService } from '../services/EventsNostrService'
import type { CalendarEventFilters } from '../services/EventsNostrService'
import type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket'
import { ticketedEventToActivity } from '../types/activity'
import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters'
import { ticketedEventToEvent } from '../types/event'
import { useEventsStore } from '../stores/events'
import { useEventFilters } from './useEventFilters'
import { useOwnedTickets } from './useOwnedTickets'
/**
* Main composable for activities discovery.
* Subscribes to NIP-52 events via ActivitiesNostrService and manages the activity feed.
* Main composable for events discovery.
* Subscribes to NIP-52 events via EventsNostrService and manages the event feed.
*/
export function useActivities() {
const store = useActivitiesStore()
const filters = useActivityFilters()
export function useEvents() {
const store = useEventsStore()
const filters = useEventFilters()
const { isAuthenticated, currentUser } = useAuth()
const { ownedActivityIds } = useOwnedTickets()
const { ownedEventIds } = useOwnedTickets()
const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null)
@ -27,27 +27,27 @@ export function useActivities() {
/**
* Merge the caller's own LNbits events (any status) into the feed.
*
* The `/activities` feed is Nostr-driven, so an event that hasn't
* The `/events` feed is Nostr-driven, so an event that hasn't
* been published yet typically because it's still `proposed` under
* auto_approve=off would silently vanish from the creator's view
* until an admin approves it. Pull own events from the events
* extension and upsert them as Activities so users see their own
* extension and upsert them as Events so users see their own
* drafts with a Pending-review badge.
*
* Once an event is approved and the Nostr relay delivers the kind
* 31922/31923 event, the relay-sourced Activity has a newer
* 31922/31923 event, the relay-sourced Event has a newer
* createdAt and wins on upsert (it lacks `lnbitsStatus`, so the
* badge disappears).
*/
/**
* Stamp `isMine` on a Nostr-sourced activity when the organizer
* Stamp `isMine` on a Nostr-sourced event when the organizer
* pubkey matches the logged-in user's Nostr key. LNbits drafts come
* pre-tagged via the adapter.
*/
function tagOwnership(activity: { organizer: { pubkey: string }; isMine?: boolean }) {
function tagOwnership(event: { organizer: { pubkey: string }; isMine?: boolean }) {
const myPubkey = currentUser.value?.pubkey
if (myPubkey && activity.organizer.pubkey === myPubkey) {
activity.isMine = true
if (myPubkey && event.organizer.pubkey === myPubkey) {
event.isMine = true
}
}
@ -60,21 +60,21 @@ export function useActivities() {
try {
const mine = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
for (const ev of mine) {
store.upsertActivity(ticketedEventToActivity(ev))
store.upsertEvent(ticketedEventToEvent(ev))
}
} catch (err) {
console.warn('[useActivities] loadOwnEvents failed:', err)
console.warn('[useEvents] loadOwnEvents failed:', err)
}
}
// Filtered and sorted activities (from all activities, filters handle time range)
const filteredActivities = computed(() => {
const all = store.activities.sort(
// Filtered and sorted events (from all events, filters handle time range)
const filteredEvents = computed(() => {
const all = store.events.sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime()
)
const filtered = filters.applyFilters(all)
if (!filters.onlyOwnedTickets.value) return filtered
const owned = ownedActivityIds.value
const owned = ownedEventIds.value
return filtered.filter(a => owned.has(a.id))
})
@ -84,9 +84,9 @@ export function useActivities() {
function subscribe(eventFilters?: CalendarEventFilters) {
if (isSubscribed.value) return
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
if (!nostrService) {
subscriptionError.value = 'Activities service not available'
subscriptionError.value = 'Events service not available'
return
}
@ -95,9 +95,9 @@ export function useActivities() {
subscriptionError.value = null
unsubscribe = nostrService.subscribeToCalendarEvents(
(activity) => {
tagOwnership(activity)
store.upsertActivity(activity)
(event) => {
tagOwnership(event)
store.upsertEvent(event)
store.isLoading = false
},
eventFilters
@ -123,20 +123,20 @@ export function useActivities() {
* One-shot query for calendar events.
*/
async function query(eventFilters?: CalendarEventFilters) {
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
if (!nostrService) {
subscriptionError.value = 'Activities service not available'
subscriptionError.value = 'Events service not available'
return
}
try {
store.isLoading = true
subscriptionError.value = null
const activities = await nostrService.queryCalendarEvents(eventFilters)
for (const a of activities) tagOwnership(a)
store.upsertActivities(activities)
const events = await nostrService.queryCalendarEvents(eventFilters)
for (const a of events) tagOwnership(a)
store.upsertEvents(events)
} catch (err) {
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query events'
} finally {
store.isLoading = false
}
@ -169,8 +169,8 @@ export function useActivities() {
return {
// State
activities: filteredActivities,
allActivities: computed(() => store.activities),
events: filteredEvents,
allEvents: computed(() => store.events),
isLoading: computed(() => store.isLoading),
isSubscribed,
error: subscriptionError,

View file

@ -5,7 +5,7 @@ import { useAuth } from '@/composables/useAuthService'
import type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket'
export function useEvents() {
export function useMyEvents() {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const { isAuthenticated, currentUser } = useAuth()
@ -34,7 +34,7 @@ export function useEvents() {
// can still browse, they just won't see their own pending events.
// Log so a flaky probe is debuggable from the console without
// toast-spamming the user on every transient failure.
console.warn('[useEvents] fetchMyEvents failed, showing public feed only:', err)
console.warn('[useMyEvents] fetchMyEvents failed, showing public feed only:', err)
return publicEvents
}
}

View file

@ -94,7 +94,7 @@ export function useOrganizerProfile(pubkey: string) {
}
/**
* Batch-fetch profiles for multiple pubkeys (for activity cards).
* Batch-fetch profiles for multiple pubkeys (for event cards).
*/
export function useBatchProfiles() {
function fetchProfiles(pubkeys: string[]) {

View file

@ -2,12 +2,12 @@ import { computed, ref, watch } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { ActivityTicket } from '../types/ticket'
import type { EventTicket } from '../types/ticket'
/**
* Module-level singleton: owned-ticket lookup keyed by activity id
* Module-level singleton: owned-ticket lookup keyed by event id
* (== LNbits event id == NIP-52 d-tag, all the same string by
* extension contract). Lives at module scope so every <ActivityCard>
* extension contract). Lives at module scope so every <EventCard>
* + the detail page + the feed filter share ONE underlying fetch
* instead of each instance hitting the API.
*
@ -18,7 +18,7 @@ import type { ActivityTicket } from '../types/ticket'
* atomically.
*/
const tickets = ref<ActivityTicket[]>([])
const tickets = ref<EventTicket[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
let hasAutoLoaded = false
@ -49,36 +49,36 @@ async function fetchTickets(): Promise<void> {
}
}
const ticketsByActivity = computed<Map<string, ActivityTicket[]>>(() => {
const m = new Map<string, ActivityTicket[]>()
const ticketsByEvent = computed<Map<string, EventTicket[]>>(() => {
const m = new Map<string, EventTicket[]>()
for (const ticket of tickets.value) {
const existing = m.get(ticket.activityId)
const existing = m.get(ticket.eventId)
if (existing) {
existing.push(ticket)
} else {
m.set(ticket.activityId, [ticket])
m.set(ticket.eventId, [ticket])
}
}
return m
})
const ownedActivityIds = computed<Set<string>>(() => {
const ownedEventIds = computed<Set<string>>(() => {
const s = new Set<string>()
for (const ticket of tickets.value) {
if (ticket.paid) s.add(ticket.activityId)
if (ticket.paid) s.add(ticket.eventId)
}
return s
})
function getTickets(activityId: string): ActivityTicket[] {
return ticketsByActivity.value.get(activityId) ?? []
function getTickets(eventId: string): EventTicket[] {
return ticketsByEvent.value.get(eventId) ?? []
}
/** Number of paid ticket rows for an activity. With the
/** Number of paid ticket rows for an event. With the
* multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2),
* this matches the number of attendees / scannable QRs. */
function paidCount(activityId: string): number {
return getTickets(activityId).filter(t => t.paid).length
function paidCount(eventId: string): number {
return getTickets(eventId).filter(t => t.paid).length
}
export function useOwnedTickets() {
@ -115,8 +115,8 @@ export function useOwnedTickets() {
return {
tickets,
ticketsByActivity,
ownedActivityIds,
ticketsByEvent,
ownedEventIds,
getTickets,
paidCount,
refresh: fetchTickets,

View file

@ -20,11 +20,11 @@ interface RSVPEntry {
createdAt: number
}
// Cache: activityCoord -> user's own (latest) RSVP entry
// Cache: eventCoord -> user's own (latest) RSVP entry
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
// Cache: activityCoord -> (pubkey -> latest RSVP entry from that pubkey).
// Cache: eventCoord -> (pubkey -> latest RSVP entry from that pubkey).
// NIP-52 RSVPs (kind 31925) are replaceable per (kind, pubkey, d-tag), so a
// user's earlier RSVP for an activity is superseded by their later one. The
// user's earlier RSVP for an event is superseded by their later one. The
// "going" count is derived from this map (count of pubkeys whose *latest*
// RSVP has status === 'accepted'), not by summing every accepted event seen
// — that would double-count replacements and never decrement on flip.
@ -51,7 +51,7 @@ function upsertRSVPState(coord: string, pubkey: string, entry: RSVPEntry) {
if (existing && existing.createdAt >= entry.createdAt) return
inner.set(pubkey, entry)
// Re-set on the outer map so the ref's reactive proxy notifies dependents
// (Vue 3's deep reactivity doesn't reach into nested Map values).
// (Vue 3's deep reevent doesn't reach into nested Map values).
rsvpStates.value.set(coord, inner)
}
@ -60,19 +60,19 @@ export function useRSVP() {
let unsubscribe: (() => void) | null = null
/**
* Get the user's RSVP status for an activity.
* Get the user's RSVP status for an event.
*/
function getMyRSVP(activityKind: number, pubkey: string, dTag: string): RSVPStatus | null {
const coord = `${activityKind}:${pubkey}:${dTag}`
function getMyRSVP(eventKind: number, pubkey: string, dTag: string): RSVPStatus | null {
const coord = `${eventKind}:${pubkey}:${dTag}`
return rsvpCache.value.get(coord)?.status ?? null
}
/**
* RSVP count for an activity = number of pubkeys whose latest RSVP for
* this activity has status 'accepted'.
* RSVP count for an event = number of pubkeys whose latest RSVP for
* this event has status 'accepted'.
*/
function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number {
const coord = `${activityKind}:${pubkey}:${dTag}`
function getRSVPCount(eventKind: number, pubkey: string, dTag: string): number {
const coord = `${eventKind}:${pubkey}:${dTag}`
const inner = rsvpStates.value.get(coord)
if (!inner) return 0
let count = 0
@ -83,7 +83,7 @@ export function useRSVP() {
}
/**
* Load the user's RSVPs and counts for visible activities from relays.
* Load the user's RSVPs and counts for visible events from relays.
*/
function loadRSVPs() {
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
@ -130,39 +130,39 @@ export function useRSVP() {
}
/**
* Whether a publish is currently in flight for the given activity. Bind
* Whether a publish is currently in flight for the given event. Bind
* to the RSVP buttons' `:disabled` so users can't queue racing clicks.
*/
function isPending(activityKind: number, pubkey: string, dTag: string): boolean {
const coord = `${activityKind}:${pubkey}:${dTag}`
function isPending(eventKind: number, pubkey: string, dTag: string): boolean {
const coord = `${eventKind}:${pubkey}:${dTag}`
return pendingCoords.value.has(coord)
}
/**
* Publish an RSVP for an activity.
* Publish an RSVP for an event.
* Clicking the same status again removes the RSVP (publishes 'declined').
*
* Returns the status that was published on success, or null if the publish
* was rejected, blocked, or threw caller should toast accordingly.
*/
async function setRSVP(
activityKind: number,
activityPubkey: string,
activityDTag: string,
eventKind: number,
eventPubkey: string,
eventDTag: string,
status: RSVPStatus
): Promise<RSVPStatus | null> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
const coord = `${eventKind}:${eventPubkey}:${eventDTag}`
// Throttle: refuse a second click while the first is still publishing.
if (pendingCoords.value.has(coord)) return null
// Toggle: if already this status, decline instead.
const currentStatus = getMyRSVP(activityKind, activityPubkey, activityDTag)
const currentStatus = getMyRSVP(eventKind, eventPubkey, eventDTag)
const newStatus = currentStatus === status ? 'declined' : status
const dTag = `rsvp-${activityDTag}`
const dTag = `rsvp-${eventDTag}`
// Strictly-monotonic created_at per coord so two clicks in the same
// wall-clock second don't both stamp the same timestamp (relays would
@ -181,7 +181,7 @@ export function useRSVP() {
['status', newStatus],
['L', 'status'],
['l', newStatus, 'status'],
['p', activityPubkey],
['p', eventPubkey],
],
}

View file

@ -57,7 +57,7 @@ export interface EventStats {
* route via HTTP rather than the kind-21000 nostr-transport RPC
* because post-#9 the webapp no longer holds a raw user prvkey.
*/
export function useTicketScanner(activityId: Ref<string>) {
export function useTicketScanner(eventId: Ref<string>) {
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
const { currentUser } = useAuth()
@ -80,7 +80,7 @@ export function useTicketScanner(activityId: Ref<string>) {
const statsError = ref<string | null>(null)
/** Session-local dedup. Hidden from UI; only guards repeat decodes. */
const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`,
() => `events_scanned_${eventId.value}`,
[],
)
@ -91,7 +91,7 @@ export function useTicketScanner(activityId: Ref<string>) {
}
async function refreshStats(): Promise<void> {
if (!activityId.value) return
if (!eventId.value) return
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) {
statsError.value = 'No wallet admin key available'
@ -100,7 +100,7 @@ export function useTicketScanner(activityId: Ref<string>) {
statsLoading.value = true
statsError.value = null
try {
const data = await ticketApi.getEventStats(activityId.value, adminKey)
const data = await ticketApi.getEventStats(eventId.value, adminKey)
eventStats.value = {
sold: data.sold,
registered: data.registered,

View file

@ -3,11 +3,11 @@ import { useAsyncState } from '@vueuse/core'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { ActivityTicket } from '../types/ticket'
import type { EventTicket } from '../types/ticket'
interface GroupedTickets {
eventId: string
tickets: ActivityTicket[]
tickets: EventTicket[]
paidCount: number
pendingCount: number
registeredCount: number
@ -26,7 +26,7 @@ export function useUserTickets() {
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
return await ticketApi.fetchUserTickets(currentUser.value.id, accessToken)
},
[] as ActivityTicket[],
[] as EventTicket[],
{
immediate: false,
resetOnExecute: false,
@ -71,7 +71,7 @@ export function useUserTickets() {
const groups = new Map<string, GroupedTickets>()
sortedTickets.value.forEach(ticket => {
const eventKey = ticket.activityId
const eventKey = ticket.eventId
if (!groups.has(eventKey)) {
groups.set(eventKey, {
eventId: eventKey,

View file

@ -1,10 +1,10 @@
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
import { SERVICE_TOKENS } from '@/core/di-container'
import { ActivitiesNostrService } from './services/ActivitiesNostrService'
import { EventsNostrService } from './services/EventsNostrService'
import { TicketApiService, type TicketApiConfig } from './services/TicketApiService'
import PurchaseTicketDialog from './components/PurchaseTicketDialog.vue'
export interface ActivitiesModuleConfig {
export interface EventsModuleConfig {
apiConfig: TicketApiConfig
defaultMapCenter?: { lat: number; lng: number }
maxTicketsPerUser?: number
@ -13,59 +13,59 @@ export interface ActivitiesModuleConfig {
}
/**
* Activities Module Plugin
* Events Module Plugin
*
* Nostr-native communal events module using NIP-52 Calendar Events
* for discovery, with database-backed ticketing via LNbits.
*/
export const activitiesModule = createModulePlugin({
name: 'activities',
export const eventsModule = createModulePlugin({
name: 'events',
version: '1.0.0',
dependencies: ['base'],
routes: [
{
path: '/activities',
name: 'activities',
component: () => import('./views/ActivitiesPage.vue'),
path: '/events',
name: 'events',
component: () => import('./views/EventsPage.vue'),
meta: {
title: 'Activities',
title: (import.meta.env.VITE_APP_NAME as string) || 'Events',
requiresAuth: false,
},
},
{
path: '/activities/calendar',
name: 'activities-calendar',
component: () => import('./views/ActivitiesCalendarPage.vue'),
path: '/events/calendar',
name: 'events-calendar',
component: () => import('./views/EventsCalendarPage.vue'),
meta: {
title: 'Calendar',
requiresAuth: false,
},
},
{
path: '/activities/map',
name: 'activities-map',
component: () => import('./views/ActivitiesMapPage.vue'),
path: '/events/map',
name: 'events-map',
component: () => import('./views/EventsMapPage.vue'),
meta: {
title: 'Map',
requiresAuth: false,
},
},
{
path: '/activities/favorites',
name: 'activities-favorites',
component: () => import('./views/ActivitiesFavoritesPage.vue'),
path: '/events/favorites',
name: 'events-favorites',
component: () => import('./views/EventsFavoritesPage.vue'),
meta: {
title: 'Favorites',
requiresAuth: false,
},
},
{
path: '/activities/:id',
name: 'activity-detail',
component: () => import('./views/ActivityDetailPage.vue'),
path: '/events/:id',
name: 'event-detail',
component: () => import('./views/EventDetailPage.vue'),
meta: {
title: 'Activity',
title: 'Event',
requiresAuth: false,
},
},
@ -79,7 +79,7 @@ export const activitiesModule = createModulePlugin({
},
},
{
path: '/scan/:activityId',
path: '/scan/:eventId',
name: 'scan-tickets',
component: () => import('./views/ScanTicketsPage.vue'),
meta: {
@ -88,12 +88,12 @@ export const activitiesModule = createModulePlugin({
},
},
{
path: '/events',
name: 'events',
component: () => import('./views/EventsPage.vue'),
path: '/my-events',
name: 'my-events',
component: () => import('./views/MyEventsPage.vue'),
meta: {
title: 'Events',
requiresAuth: false,
title: 'My Events',
requiresAuth: true,
},
},
],
@ -106,27 +106,27 @@ export const activitiesModule = createModulePlugin({
{
event: 'payment:completed',
handler: (event) => {
console.log('Activities module: payment completed', event.data)
console.log('Events module: payment completed', event.data)
},
description: 'Handle payment completion for ticket purchases',
},
],
onInstall: async (_app, options) => {
const config = options?.config as ActivitiesModuleConfig | undefined
const config = options?.config as EventsModuleConfig | undefined
if (!config) {
throw new Error('Activities module requires configuration')
throw new Error('Events module requires configuration')
}
const { container } = await import('@/core/di-container')
// 1. Create services
const nostrService = new ActivitiesNostrService()
const nostrService = new EventsNostrService()
const ticketApi = new TicketApiService(config.apiConfig)
// 2. Register in DI container BEFORE initialization
container.provide(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE, nostrService)
container.provide(SERVICE_TOKENS.ACTIVITIES_TICKET_API, ticketApi)
container.provide(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE, nostrService)
container.provide(SERVICE_TOKENS.EVENTS_TICKET_API, ticketApi)
container.provide(SERVICE_TOKENS.TICKET_API, ticketApi)
// 3. Initialize the Nostr service (needs RelayHub dependency)
@ -138,16 +138,16 @@ export const activitiesModule = createModulePlugin({
onUninstall: async () => {
const { container } = await import('@/core/di-container')
container.remove(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
container.remove(SERVICE_TOKENS.ACTIVITIES_TICKET_API)
container.remove(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
container.remove(SERVICE_TOKENS.EVENTS_TICKET_API)
container.remove(SERVICE_TOKENS.TICKET_API)
},
})
export default activitiesModule
export default eventsModule
// Re-export types for external use
export type { Activity, OrganizerInfo, ActivityTicketInfo } from './types/activity'
export type { ActivityTicket, TicketStatus, TicketedEvent, CreateEventRequest } from './types/ticket'
export type { ActivityCategory } from './types/category'
export type { Event, OrganizerInfo, EventTicketInfo } from './types/event'
export type { EventTicket, TicketStatus, TicketedEvent, CreateEventRequest } from './types/ticket'
export type { EventCategory } from './types/category'
export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52'

View file

@ -7,10 +7,10 @@ import {
parseCalendarDateEvent,
} from '../types/nip52'
import {
calendarTimeEventToActivity,
calendarDateEventToActivity,
type Activity,
} from '../types/activity'
calendarTimeEventToEvent,
calendarDateEventToEvent,
type Event,
} from '../types/event'
export interface CalendarEventFilters {
/** Only return events created after this timestamp */
@ -35,13 +35,13 @@ export interface CalendarEventFilters {
* 66076d6) `POST /events/api/v1/events` constructs and signs the
* event via NostrSigner and broadcasts it to the operator's configured
* relays. The webapp constructs only the request payload; see
* CreateActivityDialog for the flow.
* CreateEventDialog for the flow.
*
* Extends BaseService for standardized dependency injection and lifecycle.
*/
export class ActivitiesNostrService extends BaseService {
export class EventsNostrService extends BaseService {
protected readonly metadata = {
name: 'ActivitiesNostrService',
name: 'EventsNostrService',
version: '1.0.0',
dependencies: ['RelayHub'],
}
@ -49,7 +49,7 @@ export class ActivitiesNostrService extends BaseService {
private activeUnsubscribes: Array<() => void> = []
protected async onInitialize(): Promise<void> {
this.debug('ActivitiesNostrService initialized')
this.debug('EventsNostrService initialized')
}
/**
@ -57,7 +57,7 @@ export class ActivitiesNostrService extends BaseService {
* Returns an unsubscribe function.
*/
subscribeToCalendarEvents(
onActivity: (activity: Activity) => void,
onEvent: (event: Event) => void,
filters?: CalendarEventFilters
): () => void {
if (!this.relayHub) {
@ -66,15 +66,15 @@ export class ActivitiesNostrService extends BaseService {
const nostrFilters = this.buildNostrFilters(filters)
const subscriptionId = `activities-calendar-${Date.now()}`
const subscriptionId = `events-calendar-${Date.now()}`
const config: SubscriptionConfig = {
id: subscriptionId,
filters: nostrFilters,
onEvent: (event: NostrEvent) => {
const activity = this.parseNostrEventToActivity(event)
if (activity) {
onActivity(activity)
onEvent: (nostrEvent: NostrEvent) => {
const event = this.parseNostrEventToEvent(nostrEvent)
if (event) {
onEvent(event)
}
},
onEose: () => {
@ -94,29 +94,29 @@ export class ActivitiesNostrService extends BaseService {
/**
* Query relays for calendar events (one-shot, not a subscription).
*/
async queryCalendarEvents(filters?: CalendarEventFilters): Promise<Activity[]> {
async queryCalendarEvents(filters?: CalendarEventFilters): Promise<Event[]> {
if (!this.relayHub) {
throw new Error('RelayHub not available')
}
const nostrFilters = this.buildNostrFilters(filters)
const events: NostrEvent[] = await this.relayHub.queryEvents(nostrFilters)
const nostrEvents: NostrEvent[] = await this.relayHub.queryEvents(nostrFilters)
const activities: Activity[] = []
for (const event of events) {
const activity = this.parseNostrEventToActivity(event)
if (activity) {
activities.push(activity)
const events: Event[] = []
for (const nostrEvent of nostrEvents) {
const event = this.parseNostrEventToEvent(nostrEvent)
if (event) {
events.push(event)
}
}
return activities
return events
}
/**
* Parse a raw Nostr event into an Activity view model.
* Parse a raw Nostr event into an Event view model.
*/
private parseNostrEventToActivity(event: NostrEvent): Activity | null {
private parseNostrEventToEvent(event: NostrEvent): Event | null {
// Skip task events — they reuse NIP-52 kinds but can be identified by
// task-specific tags (event-type:task, status, recurrence)
const tags = event.tags ?? []
@ -126,12 +126,12 @@ export class ActivitiesNostrService extends BaseService {
if (event.kind === NIP52_KINDS.CALENDAR_TIME_EVENT) {
const parsed = parseCalendarTimeEvent(event)
if (parsed) return calendarTimeEventToActivity(parsed)
if (parsed) return calendarTimeEventToEvent(parsed)
}
if (event.kind === NIP52_KINDS.CALENDAR_DATE_EVENT) {
const parsed = parseCalendarDateEvent(event)
if (parsed) return calendarDateEventToActivity(parsed)
if (parsed) return calendarDateEventToEvent(parsed)
}
return null

View file

@ -1,6 +1,6 @@
import type {
ActivityTicket,
ActivityTicketExtra,
EventTicket,
EventTicketExtra,
CreateTicketRequest,
PaymentMethod,
TicketPurchaseInvoice,
@ -27,7 +27,7 @@ export class TicketApiService {
/**
* Fetch all public events from the LNbits events extension.
* Used to correlate Nostr activities with ticketed events.
* Used to correlate Nostr events with ticketed events.
*/
async fetchTicketedEvents(): Promise<any[]> {
const response = await this.request(
@ -133,7 +133,7 @@ export class TicketApiService {
async fetchUserTickets(
userId: string,
accessToken: string
): Promise<ActivityTicket[]> {
): Promise<EventTicket[]> {
const data = await this.request(
`/events/api/v1/tickets/user/${userId}`,
{
@ -147,7 +147,7 @@ export class TicketApiService {
return (data as any[]).map(t => ({
id: t.id,
wallet: t.wallet,
activityId: t.event,
eventId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
@ -155,14 +155,14 @@ export class TicketApiService {
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
extra: t.extra as EventTicketExtra | undefined,
}))
}
/**
* Validate/register a ticket at the door (scan).
*/
async validateTicket(ticketId: string): Promise<ActivityTicket[]> {
async validateTicket(ticketId: string): Promise<EventTicket[]> {
const data = await this.request(
`/events/api/v1/register/ticket/${ticketId}`,
{ method: 'GET' }
@ -171,7 +171,7 @@ export class TicketApiService {
return (data as any[]).map(t => ({
id: t.id,
wallet: t.wallet,
activityId: t.event,
eventId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
@ -179,7 +179,7 @@ export class TicketApiService {
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
extra: t.extra as EventTicketExtra | undefined,
}))
}
@ -229,7 +229,7 @@ export class TicketApiService {
async resendTicketEmail(
ticketId: string,
adminKey: string,
): Promise<ActivityTicket> {
): Promise<EventTicket> {
const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`,
{
@ -240,7 +240,7 @@ export class TicketApiService {
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
eventId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
@ -248,7 +248,7 @@ export class TicketApiService {
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
extra: t.extra as EventTicketExtra | undefined,
}
}
@ -286,7 +286,7 @@ export class TicketApiService {
* unpaid / already-registered / not-owned cases with HTTP errors
* whose `detail` becomes the thrown Error message.
*/
async registerTicket(ticketId: string, adminKey: string): Promise<ActivityTicket> {
async registerTicket(ticketId: string, adminKey: string): Promise<EventTicket> {
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
method: 'PUT',
headers: { 'X-API-KEY': adminKey },
@ -294,7 +294,7 @@ export class TicketApiService {
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
eventId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
@ -302,7 +302,7 @@ export class TicketApiService {
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
extra: t.extra as EventTicketExtra | undefined,
}
}

View file

@ -1,37 +1,37 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Activity } from '../types/activity'
import type { Event } from '../types/event'
import type { TicketedEvent } from '../types/ticket'
/**
* Pinia store for cached activities from Nostr relays.
* Pinia store for cached events from Nostr relays.
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
*/
export const useActivitiesStore = defineStore('activities', () => {
export const useEventsStore = defineStore('events', () => {
// State
const activitiesMap = ref<Map<string, Activity>>(new Map())
const eventsMap = ref<Map<string, Event>>(new Map())
const isLoading = ref(false)
const lastUpdated = ref<Date | null>(null)
/** Toggle by the standalone bottom-nav Create tab; mounted dialog lives
* in activities-app/App.vue so it's available from every route. */
* in events-app/App.vue so it's available from every route. */
const showCreateDialog = ref(false)
/** When set, the shell-mounted CreateEventDialog opens in edit mode
* for this LNbits event. Cleared when the dialog closes. */
const editingEvent = ref<TicketedEvent | null>(null)
// Computed
const activities = computed(() => Array.from(activitiesMap.value.values()))
const events = computed(() => Array.from(eventsMap.value.values()))
const upcomingActivities = computed(() => {
const upcomingEvents = computed(() => {
const now = new Date()
return activities.value
return events.value
.filter(a => a.startDate >= now || (a.endDate && a.endDate >= now))
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
})
const pastActivities = computed(() => {
const pastEvents = computed(() => {
const now = new Date()
return activities.value
return events.value
.filter(a => {
const endOrStart = a.endDate ?? a.startDate
return endOrStart < now
@ -42,68 +42,68 @@ export const useActivitiesStore = defineStore('activities', () => {
// Actions
/**
* Add or update an activity in the store.
* Add or update an event in the store.
* Deduplicates by id (d-tag). Newer events replace older ones.
*/
function upsertActivity(activity: Activity) {
const existing = activitiesMap.value.get(activity.id)
function upsertEvent(event: Event) {
const existing = eventsMap.value.get(event.id)
// Only update if this is a newer version
if (!existing || activity.createdAt >= existing.createdAt) {
activitiesMap.value.set(activity.id, activity)
if (!existing || event.createdAt >= existing.createdAt) {
eventsMap.value.set(event.id, event)
lastUpdated.value = new Date()
}
}
/**
* Add multiple activities (batch upsert).
* Add multiple events (batch upsert).
*/
function upsertActivities(newActivities: Activity[]) {
for (const activity of newActivities) {
upsertActivity(activity)
function upsertEvents(newEvents: Event[]) {
for (const event of newEvents) {
upsertEvent(event)
}
}
/**
* Remove an activity from the store.
* Remove an event from the store.
*/
function removeActivity(id: string) {
activitiesMap.value.delete(id)
function removeEvent(id: string) {
eventsMap.value.delete(id)
}
/**
* Clear all cached activities.
* Clear all cached events.
*/
function clearAll() {
activitiesMap.value.clear()
eventsMap.value.clear()
lastUpdated.value = null
}
/**
* Get a single activity by its id (d-tag).
* Get a single event by its id (d-tag).
*/
function getActivityById(id: string): Activity | undefined {
return activitiesMap.value.get(id)
function getEventById(id: string): Event | undefined {
return eventsMap.value.get(id)
}
return {
// State
activitiesMap,
eventsMap,
isLoading,
lastUpdated,
showCreateDialog,
editingEvent,
// Computed
activities,
upcomingActivities,
pastActivities,
events,
upcomingEvents,
pastEvents,
// Actions
upsertActivity,
upsertActivities,
removeActivity,
upsertEvent,
upsertEvents,
removeEvent,
clearAll,
getActivityById,
getEventById,
}
})

View file

@ -1,8 +1,8 @@
/**
* Activity categories inspired by p'a semana
* Event categories inspired by p'a semana
* Mapped to NIP-52 't' (hashtag) tags
*/
export const ACTIVITY_CATEGORIES = {
export const EVENT_CATEGORIES = {
concert: 'concert',
workshop: 'workshop',
market: 'market',
@ -30,6 +30,6 @@ export const ACTIVITY_CATEGORIES = {
other: 'other',
} as const
export type ActivityCategory = typeof ACTIVITY_CATEGORIES[keyof typeof ACTIVITY_CATEGORIES]
export type EventCategory = typeof EVENT_CATEGORIES[keyof typeof EVENT_CATEGORIES]
export const ALL_CATEGORIES = Object.values(ACTIVITY_CATEGORIES)
export const ALL_CATEGORIES = Object.values(EVENT_CATEGORIES)

View file

@ -1,13 +1,13 @@
import ngeohash from 'ngeohash'
import type { ActivityCategory } from './category'
import type { EventCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
import type { TicketedEvent } from './ticket'
/**
* Unified view model for displaying activities in the UI.
* Unified view model for displaying events in the UI.
* Created from NIP-52 CalendarTimeEvent or CalendarDateEvent.
*/
export interface Activity {
export interface Event {
/** Unique identifier (NIP-52 d-tag) */
id: string
/** Nostr event ID */
@ -16,7 +16,7 @@ export interface Activity {
type: 'date' | 'time'
/** Organizer information */
organizer: OrganizerInfo
/** Activity title */
/** Event title */
title: string
/** Brief summary */
summary?: string
@ -37,18 +37,18 @@ export interface Activity {
/** NIP-52 geohash (g tag) */
geohash?: string
/** Primary category */
category?: ActivityCategory
category?: EventCategory
/** All hashtags/tags */
tags: string[]
/** Ticket pricing info (if ticketed) */
ticketInfo?: ActivityTicketInfo
ticketInfo?: EventTicketInfo
/** Whether this is a private/invite-only event */
isPrivate: boolean
/** Nostr event created_at timestamp */
createdAt: Date
/**
* LNbits approval status, when the activity came from the events
* extension rather than a Nostr relay. Undefined for activities
* LNbits approval status, when the event came from the events
* extension rather than a Nostr relay. Undefined for events
* sourced from Nostr (approved by definition only published
* events make it onto relays). Used to render a "Pending review"
* badge for the creator's own non-approved drafts.
@ -56,7 +56,7 @@ export interface Activity {
lnbitsStatus?: 'approved' | 'proposed' | 'rejected'
/**
* Belongs to the current user. Set by the adapter for own LNbits
* drafts and by the activities-subscribe callback when the Nostr
* drafts and by the events-subscribe callback when the Nostr
* organizer pubkey matches the logged-in user. Used to render a
* "Yours" badge on the feed so the creator can spot their events
* at a glance.
@ -71,7 +71,7 @@ export interface OrganizerInfo {
nip05?: string
}
export interface ActivityTicketInfo {
export interface EventTicketInfo {
price: number
currency: string
/** Remaining capacity. Undefined means unlimited. */
@ -84,7 +84,7 @@ export interface ActivityTicketInfo {
fiatCurrency?: string
}
function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo | undefined {
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
if (!ticket) return undefined
return {
price: ticket.price,
@ -97,10 +97,10 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo |
}
/**
* Convert a CalendarTimeEvent to an Activity view model
* Convert a CalendarTimeEvent to an Event view model
*/
export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Activity {
const category = event.hashtags[0] as ActivityCategory | undefined
export function calendarTimeEventToEvent(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Event {
const category = event.hashtags[0] as EventCategory | undefined
return {
id: event.dTag,
@ -129,10 +129,10 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
}
/**
* Convert a CalendarDateEvent to an Activity view model
* Convert a CalendarDateEvent to an Event view model
*/
export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Activity {
const category = event.hashtags[0] as ActivityCategory | undefined
export function calendarDateEventToEvent(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Event {
const category = event.hashtags[0] as EventCategory | undefined
// Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC
const parseIsoDate = (dateStr: string): Date => {
@ -166,21 +166,21 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
}
/**
* Convert an LNbits TicketedEvent to an Activity view model.
* Convert an LNbits TicketedEvent to an Event view model.
*
* Used to surface the caller's own pending events on the activities
* feed alongside Nostr-published activities. Once an event is approved
* and published, the Nostr-derived Activity (newer createdAt) wins on
* upsert in the activities store and this draft version is replaced.
* Used to surface the caller's own pending events on the events
* feed alongside Nostr-published events. Once an event is approved
* and published, the Nostr-derived Event (newer createdAt) wins on
* upsert in the events store and this draft version is replaced.
*
* The wire format for dates mirrors how nostr_publisher emits NIP-52:
* - "YYYY-MM-DD" date-based (kind 31922 on publish)
* - "YYYY-MM-DDTHH:MM..." time-based (kind 31923 on publish)
*/
export function ticketedEventToActivity(
export function ticketedEventToEvent(
event: TicketedEvent,
organizer?: Partial<OrganizerInfo>,
): Activity {
): Event {
const hasTime = event.event_start_date.includes('T')
const startDate = hasTime
? new Date(event.event_start_date)
@ -192,7 +192,7 @@ export function ticketedEventToActivity(
: parseDateOnly(endRaw)
: undefined
const category = event.categories?.[0] as ActivityCategory | undefined
const category = event.categories?.[0] as EventCategory | undefined
return {
id: event.id,
@ -204,7 +204,7 @@ export function ticketedEventToActivity(
organizer: {
// Pending events have no Nostr pubkey yet. Empty string is fine
// — the card layer falls back gracefully and the OrganizerCard
// is only shown for approved (Nostr-sourced) activities anyway.
// is only shown for approved (Nostr-sourced) events anyway.
pubkey: '',
...organizer,
},
@ -221,7 +221,7 @@ export function ticketedEventToActivity(
// FastAPI serialization). new Date() handles both ISO strings and
// numeric epoch — same shape used in useEvents sorting.
createdAt: new Date(event.time) || new Date(),
lnbitsStatus: event.status as Activity['lnbitsStatus'],
lnbitsStatus: event.status as Event['lnbitsStatus'],
// fetchMyEvents only returns the caller's own events, so anything
// reaching this adapter is by definition mine.
isMine: true,

View file

@ -1,4 +1,4 @@
import type { ActivityCategory } from './category'
import type { EventCategory } from './category'
/**
* Temporal filter presets (p'a semana style)
@ -6,11 +6,11 @@ import type { ActivityCategory } from './category'
export type TemporalFilter = 'all' | 'today' | 'tomorrow' | 'this-week' | 'this-month'
/**
* Combined filter state for activity discovery
* Combined filter state for event discovery
*/
export interface ActivityFilters {
export interface EventFilters {
temporal: TemporalFilter
categories: ActivityCategory[]
categories: EventCategory[]
/** Free text search */
search?: string
/** Geohash prefix for geographic filtering */
@ -22,7 +22,7 @@ export interface ActivityFilters {
/**
* Default filter state
*/
export const DEFAULT_FILTERS: ActivityFilters = {
export const DEFAULT_FILTERS: EventFilters = {
temporal: 'all',
categories: [],
}

View file

@ -2,7 +2,7 @@
* Database-backed ticket types (via LNbits events extension).
*
* Wire-format types names match the snake_case fields the events
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
* extension serves over HTTP. Camel-cased aliases (e.g. EventTicket
* below) are the webapp-internal view models after adapter conversion.
*/
@ -28,7 +28,7 @@ export interface EventExtra {
notification_body: string
}
export interface ActivityTicketExtra {
export interface EventTicketExtra {
applied_promo_code?: string | null
sats_paid?: number | null
refund_address?: string | null
@ -39,11 +39,11 @@ export interface ActivityTicketExtra {
refunded: boolean
}
export interface ActivityTicket {
export interface EventTicket {
id: string
wallet: string
/** Reference to the activity (LNbits event ID) */
activityId: string
/** Reference to the event (LNbits event ID) */
eventId: string
/** Ticket holder name */
name: string | null
/** Ticket holder email */
@ -60,7 +60,7 @@ export interface ActivityTicket {
regTimestamp: string
/** Optional metadata promo code applied, sats paid, notification
* delivery flags, refund state. May be absent on older tickets. */
extra?: ActivityTicketExtra
extra?: EventTicketExtra
}
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
@ -68,7 +68,7 @@ export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
export type PaymentMethod = 'lightning' | 'fiat'
export interface TicketPurchaseRequest {
activityId: string
eventId: string
userId: string
accessToken: string
/** Lightning (default) or fiat. Only meaningful if the event has

View file

@ -10,14 +10,14 @@ import { Separator } from '@/components/ui/separator'
import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
} from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail'
import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '../stores/activities'
import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
@ -28,16 +28,16 @@ const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const activityId = route.params.id as string
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
const eventId = route.params.id as string
const { event, isLoading, error, reload } = useEventDetail(eventId)
const { dateLocale } = useDateLocale()
// Owner-edit affordance: the NIP-52 d-tag we use for the activity id is
// Owner-edit affordance: the NIP-52 d-tag we use for the event id is
// the same as the LNbits event id (set at publish time in
// nostr_publisher.build_nip52_event). Look the user's own events up
// once and offer an Edit button on a match.
const { isAuthenticated, currentUser } = useAuth()
const activitiesStore = useActivitiesStore()
const eventsStore = useEventsStore()
const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
async function loadOwnedEvent() {
@ -49,7 +49,7 @@ async function loadOwnedEvent() {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const mine = await ticketApi.fetchMyEvents(invoiceKey)
ownedLnbitsEvent.value =
(mine as TicketedEvent[]).find((e) => e.id === activityId) ?? null
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null
} catch {
ownedLnbitsEvent.value = null
}
@ -60,17 +60,17 @@ watch(isAuthenticated, () => loadOwnedEvent())
function openEditDialog() {
if (!ownedLnbitsEvent.value) return
activitiesStore.editingEvent = ownedLnbitsEvent.value
activitiesStore.showCreateDialog = true
eventsStore.editingEvent = ownedLnbitsEvent.value
eventsStore.showCreateDialog = true
}
function openScannerPage() {
router.push({ name: 'scan-tickets', params: { activityId } })
router.push({ name: 'scan-tickets', params: { eventId } })
}
const dateDisplay = computed(() => {
if (!activity.value) return ''
const a = activity.value
if (!event.value) return ''
const a = event.value
const opts = { locale: dateLocale.value }
if (a.type === 'date') {
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
@ -94,22 +94,22 @@ const dateDisplay = computed(() => {
})
const categoryLabel = computed(() => {
if (!activity.value?.category) return null
return t(`activities.categories.${activity.value.category}`, activity.value.category)
if (!event.value?.category) return null
return t(`events.categories.${event.value.category}`, event.value.category)
})
function goBack() {
router.push({ name: 'activities' })
router.push({ name: 'events' })
}
// --- Ticket purchase + owned-tickets surface ----------------------
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
const ownedPaidCount = computed(() => paidCount(activityId))
const ownedPaidCount = computed(() => paidCount(eventId))
const purchaseEvent = computed(() => {
const a = activity.value
const a = event.value
if (!a || !a.ticketInfo) return null
return {
id: a.id,
@ -125,7 +125,7 @@ const purchaseEvent = computed(() => {
// available === 0 sold out, button hidden
// available > 0 button shown
const canBuyTicket = computed(() => {
const info = activity.value?.ticketInfo
const info = event.value?.ticketInfo
if (!info) return false
return info.available === undefined || info.available > 0
})
@ -134,7 +134,7 @@ const canBuyTicket = computed(() => {
// buy CTA so the flow is unambiguous date alone is easy to miss
// on a long detail page.
const isPast = computed(() => {
const a = activity.value
const a = event.value
if (!a) return false
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
@ -145,9 +145,9 @@ const showPurchaseDialog = ref(false)
function openPurchaseDialog() {
if (!isAuthenticated.value) {
toastService.info(t('activities.detail.loginToBuyTickets'), {
toastService.info(t('events.detail.loginToBuyTickets'), {
action: {
label: t('activities.detail.logIn'),
label: t('events.detail.logIn'),
onClick: () => router.push('/login'),
},
})
@ -190,18 +190,18 @@ function goToMyTickets() {
<!-- Error -->
<div v-else-if="error" class="text-center py-16">
<h2 class="text-xl font-semibold text-foreground mb-2">Activity not found</h2>
<h2 class="text-xl font-semibold text-foreground mb-2">Event not found</h2>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button variant="outline" @click="reload">Retry</Button>
</div>
<!-- Detail content -->
<div v-else-if="activity" class="space-y-6">
<div v-else-if="event" class="space-y-6">
<!-- Hero image -->
<div v-if="activity.image" class="rounded-lg overflow-hidden">
<div v-if="event.image" class="rounded-lg overflow-hidden">
<img
:src="activity.image"
:alt="activity.title"
:src="event.image"
:alt="event.title"
class="w-full aspect-[16/9] object-cover"
/>
</div>
@ -213,7 +213,7 @@ function goToMyTickets() {
ownership signal stands out against the neutral
category/tag chips that follow. -->
<Badge
v-if="activity.isMine"
v-if="event.isMine"
variant="secondary"
class="shrink-0"
>
@ -223,19 +223,19 @@ function goToMyTickets() {
{{ categoryLabel }}
</Badge>
<Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="shrink-0 capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<div v-for="tag in activity.tags.slice(1)" :key="tag">
<div v-for="tag in event.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>
</div>
<div class="flex items-start justify-between gap-3">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ activity.title }}
{{ event.title }}
</h1>
<div class="flex items-center gap-1 shrink-0 mt-1">
<Button
@ -243,7 +243,7 @@ function goToMyTickets() {
variant="default"
size="icon"
class="h-8 w-8"
:aria-label="t('activities.detail.editEvent', 'Edit event')"
:aria-label="t('events.detail.editEvent', 'Edit event')"
@click="openEditDialog"
>
<Pencil class="w-4 h-4" />
@ -253,13 +253,13 @@ function goToMyTickets() {
affordance is meant for discovery, not management. -->
<BookmarkButton
v-else
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
</div>
<p v-if="activity.summary" class="text-muted-foreground mt-2">
{{ activity.summary }}
<p v-if="event.summary" class="text-muted-foreground mt-2">
{{ event.summary }}
</p>
<!-- When + Where captions -->
@ -268,12 +268,12 @@ function goToMyTickets() {
<Calendar class="w-4 h-4 shrink-0 mt-0.5" />
<span>
{{ dateDisplay }}
<span v-if="activity.timezone" class="opacity-70">({{ activity.timezone }})</span>
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span>
</span>
</div>
<div v-if="activity.location" class="flex items-start gap-1.5">
<div v-if="event.location" class="flex items-start gap-1.5">
<MapPin class="w-4 h-4 shrink-0 mt-0.5" />
<span>{{ activity.location }}</span>
<span>{{ event.location }}</span>
</div>
</div>
</div>
@ -282,20 +282,20 @@ function goToMyTickets() {
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
<p class="whitespace-pre-wrap">{{ event.description }}</p>
</div>
<!-- RSVP hidden for the host since RSVPing to your own event
is a noise affordance. -->
<!-- The NIP-52 RSVP `a` tag must reference the activity's actual kind
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
(31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every activity, leaving RSVPs
on date-based activities pointing at a non-existent event coord. -->
button would default to time-based for every event, leaving RSVPs
on date-based events pointing at a non-existent event coord. -->
<RSVPButton
v-if="!ownedLnbitsEvent"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/>
<!-- Host's primary CTA is to scan tickets at the door. Lives
@ -310,26 +310,26 @@ function goToMyTickets() {
@click="openScannerPage"
>
<ScanLine class="w-4 h-4" />
{{ t('activities.detail.scanTickets', 'Scan tickets') }}
{{ t('events.detail.scanTickets', 'Scan tickets') }}
</Button>
<!-- Tickets gated on the activity carrying ticketInfo (set
by the calendarActivity converter from the AIO custom
<!-- Tickets gated on the event carrying ticketInfo (set
by the calendarEvent 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. -->
<div v-if="activity.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
<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"
>
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</div>
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('activities.detail.viewMyTickets', 'View in My Tickets') }}
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
</div>
@ -338,7 +338,7 @@ function goToMyTickets() {
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
>
<History class="w-4 h-4 shrink-0" />
{{ t('activities.detail.pastEvent', 'This event has already happened') }}
{{ t('events.detail.pastEvent', 'This event has already happened') }}
</div>
<div v-else-if="canBuyTicket" class="space-y-1">
<Button
@ -349,20 +349,20 @@ function goToMyTickets() {
>
<Ticket class="w-4 h-4" />
{{ ownedPaidCount > 0
? t('activities.detail.buyAnotherTicket', 'Buy another ticket')
: t('activities.detail.buyTicket', 'Buy ticket') }}
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
: t('events.detail.buyTicket', 'Buy ticket') }}
<span class="ml-2 opacity-80 font-normal">
{{ activity.ticketInfo.price === 0
? t('activities.detail.free')
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
{{ 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="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
<span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else>
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
</span>
</p>
</div>
@ -370,7 +370,7 @@ function goToMyTickets() {
v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center"
>
{{ t('activities.detail.soldOut') }}
{{ t('events.detail.soldOut') }}
</p>
</div>
@ -382,7 +382,7 @@ function goToMyTickets() {
/>
<!-- External references -->
<div v-if="activity.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 -->
</div>
@ -391,9 +391,9 @@ function goToMyTickets() {
<!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
{{ t('activities.detail.organizer') }}
{{ t('events.detail.organizer') }}
</p>
<OrganizerCard :pubkey="activity.organizer.pubkey" />
<OrganizerCard :pubkey="event.organizer.pubkey" />
</div>
</div>
</div>

View file

@ -4,16 +4,16 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Ticket } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useActivities } from '../composables/useActivities'
import { useEvents } from '../composables/useEvents'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { useAuth } from '@/composables/useAuthService'
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
import type { Activity } from '../types/activity'
import EventCalendarView from '../components/EventCalendarView.vue'
import type { Event } from '../types/event'
const router = useRouter()
const { t } = useI18n()
const { allActivities, subscribe } = useActivities()
const { ownedActivityIds } = useOwnedTickets()
const { allEvents, subscribe } = useEvents()
const { ownedEventIds } = useOwnedTickets()
const { isAuthenticated } = useAuth()
// Per-page toggle, intentionally not wired to the feed's
@ -21,18 +21,18 @@ const { isAuthenticated } = useAuth()
// narrow the feed the user navigates back to.
const onlyMine = ref(false)
const visibleActivities = computed<Activity[]>(() => {
if (!onlyMine.value) return allActivities.value
const owned = ownedActivityIds.value
return allActivities.value.filter(a => owned.has(a.id))
const visibleEvents = computed<Event[]>(() => {
if (!onlyMine.value) return allEvents.value
const owned = ownedEventIds.value
return allEvents.value.filter(a => owned.has(a.id))
})
onMounted(() => {
subscribe()
})
function handleSelectActivity(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
function handleSelectEvent(event: Event) {
router.push({ name: 'event-detail', params: { id: event.id } })
}
</script>
@ -50,12 +50,12 @@ function handleSelectActivity(activity: Activity) {
@click="onlyMine = !onlyMine"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
{{ t('events.filters.myTickets', 'My tickets') }}
</Button>
</div>
<ActivityCalendarView
:activities="visibleActivities"
@select-activity="handleSelectActivity"
<EventCalendarView
:events="visibleEvents"
@select-event="handleSelectEvent"
/>
</div>
</template>

View file

@ -7,29 +7,29 @@ import { Button } from '@/components/ui/button'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { useBookmarks } from '../composables/useBookmarks'
import { useActivitiesStore } from '../stores/activities'
import ActivityList from '../components/ActivityList.vue'
import type { Activity } from '../types/activity'
import { useEventsStore } from '../stores/events'
import EventList from '../components/EventList.vue'
import type { Event } from '../types/event'
const router = useRouter()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { isBookmarkedByDTag, isLoaded } = useBookmarks()
const store = useActivitiesStore()
const store = useEventsStore()
const favoriteActivities = computed(() => {
return store.activities.filter(a => isBookmarkedByDTag(a.id))
const favoriteEvents = computed(() => {
return store.events.filter(a => isBookmarkedByDTag(a.id))
})
function handleSelect(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
function handleSelect(event: Event) {
router.push({ name: 'event-detail', params: { id: event.id } })
}
onMounted(() => {
if (!isAuthenticated.value) {
toast.info(t('activities.favorites.loginPrompt'), {
toast.info(t('events.favorites.loginPrompt'), {
action: {
label: t('activities.favorites.logIn'),
label: t('events.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
@ -39,14 +39,14 @@ onMounted(() => {
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">{{ t('activities.favorites.title') }}</h1>
<h1 class="text-2xl font-bold text-foreground mb-4">{{ t('events.favorites.title') }}</h1>
<!-- Not authenticated -->
<div v-if="!isAuthenticated" class="flex flex-col items-center justify-center py-16 text-center">
<Heart class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<p class="text-muted-foreground mb-3">{{ t('activities.favorites.loginPrompt') }}</p>
<p class="text-muted-foreground mb-3">{{ t('events.favorites.loginPrompt') }}</p>
<Button variant="outline" size="sm" @click="router.push('/login')">
{{ t('activities.favorites.logIn') }}
{{ t('events.favorites.logIn') }}
</Button>
</div>
@ -56,16 +56,16 @@ onMounted(() => {
</div>
<!-- Empty -->
<div v-else-if="favoriteActivities.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<div v-else-if="favoriteEvents.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<Heart class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<p class="text-muted-foreground">{{ t('activities.favorites.empty') }}</p>
<p class="text-sm text-muted-foreground/70 mt-1">{{ t('activities.favorites.emptyHint') }}</p>
<p class="text-muted-foreground">{{ t('events.favorites.empty') }}</p>
<p class="text-sm text-muted-foreground/70 mt-1">{{ t('events.favorites.emptyHint') }}</p>
</div>
<!-- Favorites list -->
<ActivityList
<EventList
v-else
:activities="favoriteActivities"
:events="favoriteEvents"
@select="handleSelect"
/>
</div>

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { Map } from 'lucide-vue-next'
import { useEvents } from '../composables/useEvents'
import EventMap from '../components/EventMap.vue'
const { allEvents, isLoading, subscribe } = useEvents()
function parseMapCenter(): { lat: number; lng: number } | undefined {
const raw = import.meta.env.VITE_DEFAULT_MAP_CENTER
if (!raw) return undefined
const [lat, lng] = raw.split(',').map(Number)
if (isNaN(lat) || isNaN(lng)) return undefined
return { lat, lng }
}
const mapCenter = parseMapCenter()
const geoEvents = computed(() =>
allEvents.value.filter(a => a.coordinates)
)
onMounted(() => {
subscribe()
})
</script>
<template>
<div class="flex flex-col h-[calc(100dvh-3.5rem)]">
<!-- Loading overlay -->
<div v-if="isLoading && geoEvents.length === 0" class="flex-1 flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<!-- No geotagged events -->
<div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">No geotagged events found</p>
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
</div>
<!-- Map -->
<EventMap
v-else
:events="geoEvents"
:center="mapCenter"
class="flex-1"
/>
<!-- Event count -->
<div v-if="geoEvents.length > 0" class="px-4 py-2 text-xs text-muted-foreground border-t bg-background">
{{ geoEvents.length }} event{{ geoEvents.length === 1 ? '' : 's' }} on map
</div>
</div>
</template>

View file

@ -10,21 +10,21 @@ import {
} from '@/components/ui/collapsible'
import { Separator } from '@/components/ui/separator'
import { SlidersHorizontal, History, CalendarDays, Plus } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import { useActivitiesStore } from '../stores/activities'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events'
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import DatePickerStrip from '../components/DatePickerStrip.vue'
import ActivityList from '../components/ActivityList.vue'
import type { Activity } from '../types/activity'
import EventList from '../components/EventList.vue'
import type { Event } from '../types/event'
const router = useRouter()
const { t } = useI18n()
const activitiesStore = useActivitiesStore()
const eventsStore = useEventsStore()
const {
activities,
events,
isLoading,
error,
temporal,
@ -40,7 +40,7 @@ const {
togglePast,
resetFilters,
subscribe,
} = useActivities()
} = useEvents()
const filtersOpen = ref(false)
@ -55,21 +55,21 @@ onMounted(() => {
subscribe()
})
function handleSelectActivity(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
function handleSelectEvent(event: Event) {
router.push({ name: 'event-detail', params: { id: event.id } })
}
// Create-activity CTA in the Hosting view. Calendar-tab page lives
// on /activities/calendar; the icon button at the end of the date
// on /events/calendar; the icon button at the end of the date
// strip is the only entry point now that the bottom-nav Calendar
// tab is gone.
function openCreate() {
activitiesStore.editingEvent = null
activitiesStore.showCreateDialog = true
eventsStore.editingEvent = null
eventsStore.showCreateDialog = true
}
function openCalendar() {
router.push('/activities/calendar')
router.push('/events/calendar')
}
</script>
@ -77,14 +77,14 @@ function openCalendar() {
<div class="container mx-auto py-4 px-4">
<!-- Page header -->
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground">
{{ t('activities.title') }}
{{ t('events.title') }}
</h1>
<!-- Search with dropdown overlay -->
<div class="mb-3">
<ActivitySearchOverlay
:activities="activities"
@select="handleSelectActivity"
<EventSearchOverlay
:events="events"
@select="handleSelectEvent"
/>
</div>
@ -103,7 +103,7 @@ function openCalendar() {
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:aria-label="t('activities.nav.calendar')"
:aria-label="t('events.nav.calendar')"
@click="openCalendar"
>
<CalendarDays class="h-4 w-4" />
@ -125,7 +125,7 @@ function openCalendar() {
size="icon"
class="rounded-full h-8 w-8 relative"
:class="{ 'bg-accent text-accent-foreground': filtersOpen || filterCount > 0 }"
:aria-label="t('activities.filters.filters', 'Filters')"
:aria-label="t('events.filters.filters', 'Filters')"
:aria-expanded="filtersOpen"
>
<SlidersHorizontal class="w-4 h-4" />
@ -144,7 +144,7 @@ function openCalendar() {
class="h-5 px-1 text-[10px] text-muted-foreground"
@click="resetFilters"
>
{{ t('activities.filters.clearAll', 'Clear all') }}
{{ t('events.filters.clearAll', 'Clear all') }}
</Button>
</div>
<div class="flex-1 min-w-0 pt-0.5">
@ -159,7 +159,7 @@ function openCalendar() {
@click="togglePast"
>
<History class="w-3.5 h-3.5" />
{{ t('activities.filters.pastEvents', 'Past events') }}
{{ t('events.filters.pastEvents', 'Past events') }}
</Button>
<Separator />
<CategoryFilterBar
@ -180,7 +180,7 @@ function openCalendar() {
@click="openCreate"
>
<Plus class="w-4 h-4" />
{{ t('activities.createNew') }}
{{ t('events.createNew') }}
</Button>
<!-- Error state -->
@ -188,14 +188,14 @@ function openCalendar() {
{{ error }}
</div>
<!-- Activity feed. The Hosting view renders compact rows so the
<!-- Event feed. The Hosting view renders compact rows so the
operator can scan their roster without the visual weight of
hero images they already recognize. -->
<ActivityList
:activities="activities"
<EventList
:events="events"
:is-loading="isLoading"
:compact="onlyHosting"
@select="handleSelectActivity"
@select="handleSelectEvent"
/>
</div>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEvents } from '../composables/useEvents'
import { useMyEvents } from '../composables/useMyEvents'
import { useApprovalState } from '../composables/useApprovalState'
import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
@ -17,7 +17,7 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useMyEvents()
const { isAuthenticated, userDisplay, currentUser } = useAuth()
const { isAdmin, autoApprove } = useApprovalState()

View file

@ -152,7 +152,7 @@ onMounted(async () => {
</div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
<p class="text-muted-foreground mb-4">You haven't purchased any tickets yet</p>
<Button @click="$router.push('/activities')">Browse Activities</Button>
<Button @click="$router.push('/events')">Browse Events</Button>
</div>
<div v-else-if="tickets.length > 0">

View file

@ -22,14 +22,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner'
import type { EventTicket } from '../composables/useTicketScanner'
import { useActivityDetail } from '../composables/useActivityDetail'
import { useEventDetail } from '../composables/useEventDetail'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
const route = useRoute()
const router = useRouter()
const activityId = ref(route.params.activityId as string)
const { activity } = useActivityDetail(activityId.value)
const eventId = ref(route.params.eventId as string)
const { event } = useEventDetail(eventId.value)
const {
isProcessing,
@ -42,7 +42,7 @@ const {
onDecode,
resume,
registerManually,
} = useTicketScanner(activityId)
} = useTicketScanner(eventId)
// Tracks tickets currently mid-register (manual button click), so each
// row can render a per-row spinner without blocking the rest of the
@ -65,10 +65,10 @@ const lastScanVariant = computed(() => {
}
})
// Backend-authoritative roster. Falls back to the activity nostr
// Backend-authoritative roster. Falls back to the event nostr
// event's `tickets_sold` tag if the RPC hasn't completed yet.
const soldCount = computed(
() => eventStats.value?.sold ?? activity.value?.ticketInfo?.sold,
() => eventStats.value?.sold ?? event.value?.ticketInfo?.sold,
)
const registeredCount = computed(() => eventStats.value?.registered ?? 0)
const remainingCount = computed(() => {
@ -134,7 +134,7 @@ function handleResult(qrText: string) {
function goBack() {
if (window.history.length > 1) router.back()
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
else router.push({ name: 'event-detail', params: { id: eventId.value } })
}
function fmtTime(iso: string) {
@ -167,8 +167,8 @@ function fmtTime(iso: string) {
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
<p v-if="activity" class="text-sm text-muted-foreground mb-4">
{{ activity.title }}
<p v-if="event" class="text-sm text-muted-foreground mb-4">
{{ event.title }}
</p>
<!-- Counts strip backend-authoritative. Source: the

View file

@ -204,7 +204,7 @@ export function useMarket() {
// Logged-in user has no published market event yet — show their
// namespace as "My Market". Avoids leaking VITE_APP_NAME (which
// is the brand of whichever standalone app is bundled, e.g.
// "Sortir" for activities) into the market label.
// "Bouge" for the events app) into the market label.
name: 'My Market',
description: 'A communal market to sell your goods',
merchants: [],

View file

@ -451,7 +451,7 @@ const placeOrder = async () => {
// Try to get pubkey from main auth first, fallback to auth service
const userPubkey = auth.currentUser.value?.pubkey || authService.user.value?.pubkey
// Friendly toast instead of throw same pattern as Activities favorites prompt.
// Friendly toast instead of throw same pattern as Events favorites prompt.
if (!auth.isAuthenticated.value) {
toast.info(t('market.auth.loginPrompt'), {
action: {

View file

@ -32,7 +32,7 @@ const modules: Module[] = [
{ label: 'Restaurant', chakra: 'Muladhara', icon: UtensilsCrossed, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_RESTAURANT_URL', status: 'alpha' },
{ label: 'Market', chakra: 'Muladhara', icon: Store, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_MARKET_URL', status: 'alpha' },
{ label: 'Wallet', chakra: 'Manipura', icon: Wallet, bgClass: '', glow: 'rgba(255,200,0,0.5)', envKey: 'VITE_HUB_WALLET_URL', status: 'alpha', authRequired: true },
{ label: 'Activities', chakra: 'Swadhisthana', icon: CalendarDays, bgClass: '', glow: 'rgba(255,165,0,0.5)', envKey: 'VITE_HUB_ACTIVITIES_URL', status: 'beta' },
{ label: 'Events', chakra: 'Swadhisthana', icon: CalendarDays, bgClass: '', glow: 'rgba(255,165,0,0.5)', envKey: 'VITE_HUB_EVENTS_URL', status: 'beta' },
{ label: 'Chat', chakra: 'Anahata', icon: MessageCircle, bgClass: '', glow: 'rgba(0,200,80,0.5)', envKey: 'VITE_HUB_CHAT_URL', status: 'alpha', authRequired: true },
{ label: 'Forum', chakra: 'Vishuddha', icon: Newspaper, bgClass: '', glow: 'rgba(60,120,255,0.5)', envKey: 'VITE_HUB_FORUM_URL', status: 'alpha' },
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },

View file

@ -7,15 +7,15 @@ import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
/**
* Plugin to rewrite dev server requests to activities.html
* (SPA fallback for the standalone activities app entry point)
* Plugin to rewrite dev server requests to events.html
* (SPA fallback for the standalone events app entry point)
*/
function activitiesHtmlPlugin(): Plugin {
function eventsHtmlPlugin(): Plugin {
return {
name: 'activities-html-rewrite',
name: 'events-html-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
// Rewrite all non-asset requests to activities.html.
// Rewrite all non-asset requests to events.html.
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
// contain dots and would otherwise get mistaken for an asset request.
const path = req.url ? req.url.split('?')[0] : ''
@ -26,7 +26,7 @@ function activitiesHtmlPlugin(): Plugin {
!req.url.startsWith('/node_modules/') &&
!path.includes('.')
) {
req.url = '/activities.html'
req.url = '/events.html'
}
next()
})
@ -35,22 +35,30 @@ function activitiesHtmlPlugin(): Plugin {
}
/**
* Vite config for the standalone Sortir activities app.
* Vite config for the standalone events app.
*
* Set VITE_BASE_PATH to deploy under a path prefix:
* VITE_BASE_PATH=/sortir/ app.ariege.io/sortir/ (shared auth)
* (default: /) sortir.ariege.io (standalone subdomain)
* VITE_BASE_PATH=/events/ app.ariege.io/events/ (shared auth)
* (default: /) bouge.ariege.io (standalone subdomain)
*
* Set VITE_APP_NAME to brand the standalone (PWA name, HTML title, console
* logs). cfaun overrides to "Bouge" via NixOS. Defaults to "Events".
*/
const APP_NAME = process.env.VITE_APP_NAME || 'Events'
// Surface the resolved value back into env so Vite's HTML %VITE_APP_NAME%
// substitution picks up the fallback when nothing was explicitly set.
process.env.VITE_APP_NAME = APP_NAME
export default defineConfig(({ mode }) => ({
base: process.env.VITE_BASE_PATH || '/',
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
cacheDir: 'node_modules/.vite-activities',
cacheDir: 'node_modules/.vite-events',
server: {
port: 5181,
strictPort: true,
},
plugins: [
activitiesHtmlPlugin(),
eventsHtmlPlugin(),
vue(),
tailwindcss(),
VitePWA({
@ -61,7 +69,7 @@ export default defineConfig(({ mode }) => ({
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
// Scope the service worker to only handle requests within this app's path
navigateFallback: 'activities.html',
navigateFallback: 'events.html',
navigateFallbackAllowlist: [
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
],
@ -76,18 +84,17 @@ export default defineConfig(({ mode }) => ({
'icon-maskable-512.png',
],
manifest: {
name: 'Sortir — Activités & Événements',
short_name: 'Sortir',
description: 'Découvrez les activités et événements près de chez vous',
name: APP_NAME,
short_name: APP_NAME,
description: 'Discover events near you',
theme_color: '#1f2937',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
scope: process.env.VITE_BASE_PATH || '/',
id: 'sortir-activities',
id: 'aiolabs-events',
categories: ['social', 'entertainment', 'lifestyle'],
lang: 'fr',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
@ -104,7 +111,7 @@ export default defineConfig(({ mode }) => ({
mode === 'analyze' &&
visualizer({
open: true,
filename: 'dist-activities/stats.html',
filename: 'dist-events/stats.html',
gzipSize: true,
brotliSize: true,
}),
@ -115,9 +122,9 @@ export default defineConfig(({ mode }) => ({
},
},
build: {
outDir: 'dist-activities',
outDir: 'dist-events',
rollupOptions: {
input: 'activities.html',
input: 'events.html',
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],