refactor(events): rename activities module to events

The module name "activities" was originally chosen to avoid colliding
with Nostr's `Event` type. In practice the 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).

Renames `src/modules/activities` → `src/modules/events` and
`src/activities-app` → `src/events-app`. Touches:

- Module dir + 48 internal files (types, services, composables, views,
  components, store) renamed with case-sensitive replacement
  (`Activity`→`Event`, `Activities`→`Events`).
- Route paths: `/activities/*` → `/events/*`. The existing legacy
  `/events` route (ticketing management view) moves to `/my-events`,
  freeing `/events` for the canonical feed/discovery page. A new
  "My Events" entry is added to the user dropdown menu for access.
- Standalone PWA entry: `activities.html` → `events.html`,
  `vite.activities.config.ts` → `vite.events.config.ts`.
- npm scripts: `dev:activities`/`build:activities`/`preview:activities`
  → `:events`. `build:demo` and `dev:all` updated accordingly.
- DI tokens: `SERVICE_TOKENS.ACTIVITIES_*` → `EVENTS_*`.
- Hub config: `appConfig.modules.activities` → `modules.events`.
- i18n: `activities.*` namespace → `events.*` in en/fr/es locales;
  English domain strings updated ("Activities"→"Events", "Search
  activities..."→"Search events..."). French/Spanish display values
  realigned to "Événements"/"Eventos" at the title key; deeper
  translation cleanup left for a follow-up.
- Hub icon label: "Activities" → "Events"; env key
  `VITE_HUB_ACTIVITIES_URL` → `VITE_HUB_EVENTS_URL` (also updated in
  `.env.example`).
- Stale `CreateActivityDialog.vue` removed (only referenced from a
  defunct comment; `CreateEventDialog.vue` is the live one).

Build-output dir renamed `dist-activities/` → `dist-events/`; the
`build:events` npm script reflects the new name. server-deploy still
hardcodes `buildScript = "build:activities"` and `distDir =
"dist-activities"` in `modules/services/standalones.nix:46-47`; a
matching update there needs to land before this branch's webapp commit
gets a flake-input bump.

`Event` naming inside the module no longer collides with the Nostr
`Event` import — those 5 files already use the `NostrEvent` alias.
Disambiguation inside `EventsNostrService.queryCalendarEvents` was
needed: local `events`/`event` variables for the domain side, with
`nostrEvent`/`nostrEvents` for the protocol side.

vue-tsc passes. PWA manifest still hardcodes "Sortir" branding; that
is templated through `VITE_APP_NAME` in a follow-up commit on the
same branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-09 17:39:24 +02:00
commit 131eef88ec
70 changed files with 781 additions and 1057 deletions

View file

@ -64,7 +64,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
# #
# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined # In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined
# in the vite configs): # 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_LIBRA_URL=http://localhost:5180
# VITE_HUB_WALLET_URL=http://localhost:5182 # VITE_HUB_WALLET_URL=http://localhost:5182
# VITE_HUB_CHAT_URL=http://localhost:5183 # VITE_HUB_CHAT_URL=http://localhost:5183
@ -74,7 +74,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
# VITE_HUB_RESTAURANT_URL=http://localhost:5187 # VITE_HUB_RESTAURANT_URL=http://localhost:5187
# #
# In PATH-MODE production (recommended for demo) — note the trailing slash: # 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_LIBRA_URL=https://demo.example.com/libra/
# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/ # VITE_HUB_WALLET_URL=https://demo.example.com/wallet/
# VITE_HUB_CHAT_URL=https://demo.example.com/chat/ # VITE_HUB_CHAT_URL=https://demo.example.com/chat/
@ -84,11 +84,11 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
# VITE_HUB_RESTAURANT_URL=https://demo.example.com/restaurant/ # VITE_HUB_RESTAURANT_URL=https://demo.example.com/restaurant/
# #
# In SUBDOMAIN-MODE production: # In SUBDOMAIN-MODE production:
# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com # VITE_HUB_EVENTS_URL=https://sortir.example.com
# VITE_HUB_LIBRA_URL=https://libra.example.com # VITE_HUB_LIBRA_URL=https://libra.example.com
# ...etc # ...etc
# ─────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────
VITE_HUB_ACTIVITIES_URL= VITE_HUB_EVENTS_URL=
VITE_HUB_LIBRA_URL= VITE_HUB_LIBRA_URL=
VITE_HUB_WALLET_URL= VITE_HUB_WALLET_URL=
VITE_HUB_CHAT_URL= VITE_HUB_CHAT_URL=

View file

@ -9,12 +9,12 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF"> <link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Sortir — Activités</title> <title>Sortir — Events</title>
<meta name="apple-mobile-web-app-title" content="Sortir"> <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"> <meta name="description" content="Découvrez les événements près de chez vous">
</head> </head>
<body> <body>
<div id="app"></div> <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> </body>
</html> </html>

View file

@ -9,9 +9,9 @@
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview --host", "preview": "vite preview --host",
"analyze": "vite build --mode analyze", "analyze": "vite build --mode analyze",
"dev:activities": "vite --host --config vite.activities.config.ts", "dev:events": "vite --host --config vite.events.config.ts",
"build:activities": "vue-tsc -b && vite build --config vite.activities.config.ts", "build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
"preview:activities": "vite preview --host --config vite.activities.config.ts", "preview:events": "vite preview --host --config vite.events.config.ts",
"dev:libra": "vite --host --config vite.libra.config.ts", "dev:libra": "vite --host --config vite.libra.config.ts",
"build:libra": "vue-tsc -b && vite build --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", "preview:libra": "vite preview --host --config vite.libra.config.ts",
@ -33,8 +33,8 @@
"dev:restaurant": "vite --host --config vite.restaurant.config.ts", "dev:restaurant": "vite --host --config vite.restaurant.config.ts",
"build:restaurant": "vue-tsc -b && vite build --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", "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\"", "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: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=/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", "build:demo": "npm run build && VITE_BASE_PATH=/sortir/ 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:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder", "electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder", "electron:package": "electron-builder",

View file

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

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 * 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 * 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() { export async function createAppInstance() {
console.log('🚀 Starting AIO hub...') console.log('🚀 Starting AIO hub...')

View file

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

View file

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

View file

@ -7,38 +7,38 @@ import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue' import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue' import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '@/modules/activities/stores/activities' import { useEventsStore } from '@/modules/events/stores/events'
import { useActivities } from '@/modules/activities/composables/useActivities' import { useEvents } from '@/modules/events/composables/useEvents'
import { useApprovalState } from '@/modules/activities/composables/useApprovalState' import { useApprovalState } from '@/modules/events/composables/useApprovalState'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '@/modules/activities/services/TicketApiService' import type { TicketApiService } from '@/modules/events/services/TicketApiService'
import type { CreateEventRequest } from '@/modules/activities/types/ticket' import type { CreateEventRequest } from '@/modules/events/types/ticket'
import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue' import CreateEventDialog from '@/modules/events/components/CreateEventDialog.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const activitiesStore = useActivitiesStore() const eventsStore = useEventsStore()
const { isAdmin, autoApprove } = useApprovalState() 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 // the user creates or edits an event otherwise the new draft only
// surfaces on the next ActivitiesPage subscribe cycle. // surfaces on the next EventsPage subscribe cycle.
const { loadOwnEvents } = useActivities() const { loadOwnEvents } = useEvents()
// Settings dropped theme/lang/currency now live in the shared profile sheet. // Settings dropped theme/lang/currency now live in the shared profile sheet.
// Create lives in the bottom nav: when logged out, tapping it shows an // Create lives in the bottom nav: when logged out, tapping it shows an
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of // auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
// opening the dialog. Per-app placement deliberation tracked at #53. // opening the dialog. Per-app placement deliberation tracked at #53.
const tabs = computed<BottomTab[]>(() => [ const tabs = computed<BottomTab[]>(() => [
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' }, { name: t('events.nav.feed'), icon: Search, path: '/events' },
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' }, { name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' },
{ {
name: t('activities.createNew'), name: t('events.createNew'),
icon: Plus, icon: Plus,
onClick: () => { onClick: () => {
if (!isAuthenticated.value) { if (!isAuthenticated.value) {
toast.info('Log in to create an activity', { toast.info('Log in to create an event', {
action: { action: {
label: 'Log in', label: 'Log in',
onClick: () => router.push('/login'), onClick: () => router.push('/login'),
@ -48,52 +48,52 @@ const tabs = computed<BottomTab[]>(() => [
} }
// Defensively clear any lingering edit selection so the Create // Defensively clear any lingering edit selection so the Create
// tap always opens in Create mode regardless of a prior Edit. // tap always opens in Create mode regardless of a prior Edit.
activitiesStore.editingEvent = null eventsStore.editingEvent = null
activitiesStore.showCreateDialog = true eventsStore.showCreateDialog = true
}, },
disabled: !isAuthenticated.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, icon: Heart,
// path kept so the tab stays active-highlighted while the user is // 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). // can gate on auth (mirrors the Create tab pattern above).
path: '/activities/favorites', path: '/events/favorites',
onClick: () => { onClick: () => {
if (!isAuthenticated.value) { if (!isAuthenticated.value) {
toast.info(t('activities.favorites.loginPrompt'), { toast.info(t('events.favorites.loginPrompt'), {
action: { action: {
label: t('activities.favorites.logIn'), label: t('events.favorites.logIn'),
onClick: () => router.push('/login'), onClick: () => router.push('/login'),
}, },
}) })
return return
} }
router.push('/activities/favorites') router.push('/events/favorites')
}, },
disabled: !isAuthenticated.value, disabled: !isAuthenticated.value,
}, },
]) ])
// Feed tab is active for the bare /activities route AND all sub-paths that // Feed tab is active for the bare /events route AND all sub-paths that
// aren't owned by another tab (e.g. /activities/<id> detail pages). // aren't owned by another tab (e.g. /events/<id> detail pages).
function isActive(path: string): boolean { function isActive(path: string): boolean {
if (path === '/activities') { if (path === '/events') {
return ( return (
route.path === '/activities' || route.path === '/events' ||
(route.path.startsWith('/activities/') && (route.path.startsWith('/events/') &&
!route.path.startsWith('/activities/calendar') && !route.path.startsWith('/events/calendar') &&
!route.path.startsWith('/activities/map') && !route.path.startsWith('/events/map') &&
!route.path.startsWith('/activities/favorites')) !route.path.startsWith('/events/favorites'))
) )
} }
return route.path.startsWith(path) return route.path.startsWith(path)
} }
// Dialog mount lives at shell level so the Create tab works from any route // 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) { async function handleCreateEvent(eventData: CreateEventRequest) {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
@ -105,7 +105,7 @@ async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
// PUT /events/{id} requires the event's wallet admin key. // PUT /events/{id} requires the event's wallet admin key.
const wallet = (currentUser.value?.wallets ?? []).find( const wallet = (currentUser.value?.wallets ?? []).find(
(w) => w.id === activitiesStore.editingEvent?.wallet, (w) => w.id === eventsStore.editingEvent?.wallet,
) )
const adminKey = wallet?.adminkey const adminKey = wallet?.adminkey
if (!adminKey) { if (!adminKey) {
@ -115,18 +115,18 @@ async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest)
} }
function handleDialogOpenChange(open: boolean) { function handleDialogOpenChange(open: boolean) {
activitiesStore.showCreateDialog = open eventsStore.showCreateDialog = open
// Closing always clears the edit selection so the next "+ Create" // Closing always clears the edit selection so the next "+ Create"
// opens clean instead of inheriting the last-edited event. // opens clean instead of inheriting the last-edited event.
if (!open) activitiesStore.editingEvent = null if (!open) eventsStore.editingEvent = null
} }
</script> </script>
<template> <template>
<AppShell :tabs="tabs" :is-active="isActive"> <AppShell :tabs="tabs" :is-active="isActive">
<CreateEventDialog <CreateEventDialog
:open="activitiesStore.showCreateDialog" :open="eventsStore.showCreateDialog"
:event="activitiesStore.editingEvent" :event="eventsStore.editingEvent"
:is-admin="isAdmin" :is-admin="isAdmin"
:auto-approve="autoApprove" :auto-approve="autoApprove"
:on-create-event="handleCreateEvent" :on-create-event="handleCreateEvent"

View file

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

View file

@ -7,7 +7,7 @@ import { container } from '@/core/di-container'
import appConfig from './app.config' import appConfig from './app.config'
import baseModule from '@/modules/base' import baseModule from '@/modules/base'
import activitiesModule from '@/modules/activities' import eventsModule from '@/modules/events'
import App from './App.vue' import App from './App.vue'
@ -17,10 +17,10 @@ import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/rou
import { acceptTokenFromUrl } from '@/lib/url-token' import { acceptTokenFromUrl } from '@/lib/url-token'
/** /**
* Initialize the standalone activities app * Initialize the standalone events app
*/ */
export async function createAppInstance() { export async function createAppInstance() {
console.log('🚀 Starting Sortir — Activities App...') console.log('🚀 Starting Sortir — Events App...')
// Accept token from URL before anything else (cross-subdomain auth relay) // Accept token from URL before anything else (cross-subdomain auth relay)
acceptTokenFromUrl('Sortir') acceptTokenFromUrl('Sortir')
@ -30,16 +30,16 @@ export async function createAppInstance() {
// Collect routes from enabled modules only // Collect routes from enabled modules only
const moduleRoutes = [ const moduleRoutes = [
...baseModule.routes || [], ...baseModule.routes || [],
...activitiesModule.routes || [], ...eventsModule.routes || [],
].filter(Boolean) ].filter(Boolean)
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
// Activities page is the home page in standalone mode // Events page is the home page in standalone mode
{ {
path: '/', path: '/',
redirect: '/activities' redirect: '/events'
}, },
{ {
path: '/login', path: '/login',
@ -87,9 +87,9 @@ export async function createAppInstance() {
) )
} }
if (appConfig.modules.activities?.enabled) { if (appConfig.modules.events?.enabled) {
moduleRegistrations.push( moduleRegistrations.push(
pluginManager.register(activitiesModule, appConfig.modules.activities) pluginManager.register(eventsModule, appConfig.modules.events)
) )
} }

View file

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

View file

@ -9,7 +9,6 @@ const messages: LocaleMessages = {
events: 'Events', events: 'Events',
market: 'Market', market: 'Market',
chat: 'Chat', chat: 'Chat',
activities: 'Activities',
login: 'Login', login: 'Login',
logout: 'Logout' logout: 'Logout'
}, },
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
de: 'German', de: 'German',
zh: 'Chinese' zh: 'Chinese'
}, },
activities: { events: {
title: 'Activities', title: 'Events',
createNew: 'Create Activity', createNew: 'Create Event',
noActivities: 'No activities found', noEvents: 'No events found',
filters: { filters: {
all: 'All', all: 'All',
today: 'Today', today: 'Today',
@ -135,20 +134,20 @@ const messages: LocaleMessages = {
settings: 'Settings', settings: 'Settings',
}, },
search: { search: {
placeholder: 'Search activities...', placeholder: 'Search events...',
noResults: 'No activities found', noResults: 'No events found',
}, },
favorites: { favorites: {
title: 'Favorites', title: 'Favorites',
loginPrompt: 'Log in to save your favorite activities', loginPrompt: 'Log in to save your favorite events',
empty: 'No favorites yet', 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', logIn: 'Log in',
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',
account: 'Account', 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', logIn: 'Log in',
logOut: 'Log out', logOut: 'Log out',
appearance: 'Appearance', appearance: 'Appearance',

View file

@ -9,7 +9,6 @@ const messages: LocaleMessages = {
events: 'Eventos', events: 'Eventos',
market: 'Mercado', market: 'Mercado',
chat: 'Chat', chat: 'Chat',
activities: 'Actividades',
login: 'Iniciar Sesión', login: 'Iniciar Sesión',
logout: 'Cerrar Sesión' logout: 'Cerrar Sesión'
}, },
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
de: 'Alemán', de: 'Alemán',
zh: 'Chino' zh: 'Chino'
}, },
activities: { events: {
title: 'Actividades', title: 'Eventos',
createNew: 'Crear actividad', createNew: 'Crear evento',
noActivities: 'No se encontraron actividades', noEvents: 'No se encontraron eventos',
filters: { filters: {
all: 'Todas', all: 'Todas',
today: 'Hoy', today: 'Hoy',

View file

@ -9,7 +9,6 @@ const messages: LocaleMessages = {
events: 'Événements', events: 'Événements',
market: 'Marché', market: 'Marché',
chat: 'Chat', chat: 'Chat',
activities: 'Activités',
login: 'Connexion', login: 'Connexion',
logout: 'Déconnexion' logout: 'Déconnexion'
}, },
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
de: 'Allemand', de: 'Allemand',
zh: 'Chinois' zh: 'Chinois'
}, },
activities: { events: {
title: 'Activités', title: 'Événements',
createNew: 'Créer une activité', createNew: 'Créer un événement',
noActivities: 'Aucune activité trouvée', noEvents: 'Aucun événement trouvé',
filters: { filters: {
all: 'Tout', all: 'Tout',
today: "Aujourd'hui", today: "Aujourd'hui",

View file

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

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,27 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useActivities } from '../composables/useActivities'
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
import type { Activity } from '../types/activity'
const router = useRouter()
const { allActivities, subscribe } = useActivities()
onMounted(() => {
subscribe()
})
function handleSelectActivity(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<ActivityCalendarView
:activities="allActivities"
@select-activity="handleSelectActivity"
/>
</div>
</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 { isAuthenticated } = useAuth()
const { isBookmarked, toggleBookmark } = useBookmarks() const { isBookmarked, toggleBookmark } = useBookmarks()
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT) const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const bookmarked = computed(() => isBookmarked(activityKind.value, props.pubkey, props.dTag)) const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
function handleToggle() { function handleToggle() {
if (!isAuthenticated.value) { if (!isAuthenticated.value) {
@ -31,7 +31,7 @@ function handleToggle() {
}) })
return return
} }
toggleBookmark(activityKind.value, props.pubkey, props.dTag) toggleBookmark(eventKind.value, props.pubkey, props.dTag)
} }
</script> </script>

View file

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

View file

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

View file

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

View file

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

View file

@ -8,24 +8,24 @@ import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vu
import BookmarkButton from './BookmarkButton.vue' import BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets' import { useOwnedTickets } from '../composables/useOwnedTickets'
import type { Activity } from '../types/activity' import type { Event } from '../types/event'
const props = defineProps<{ const props = defineProps<{
activity: Activity event: Event
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
click: [activity: Activity] click: [event: Event]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale()
const { paidCount } = useOwnedTickets() const { paidCount } = useOwnedTickets()
const ownedCount = computed(() => paidCount(props.activity.id)) const ownedCount = computed(() => paidCount(props.event.id))
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
const a = props.activity const a = props.event
if (!a.startDate || isNaN(a.startDate.getTime())) return '' if (!a.startDate || isNaN(a.startDate.getTime())) return ''
try { try {
const opts = { locale: dateLocale.value } const opts = { locale: dateLocale.value }
@ -41,26 +41,26 @@ const dateDisplay = computed(() => {
}) })
const categoryLabel = computed(() => { const categoryLabel = computed(() => {
if (!props.activity.category) return null if (!props.event.category) return null
return t(`activities.categories.${props.activity.category}`, props.activity.category) return t(`events.categories.${props.event.category}`, props.event.category)
}) })
const priceDisplay = computed(() => { const priceDisplay = computed(() => {
const info = props.activity.ticketInfo const info = props.event.ticketInfo
if (!info) return null 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}` return `${info.price} ${info.currency}`
}) })
const placeholderBg = computed(() => { const placeholderBg = computed(() => {
// Generate a consistent hue from the activity title // Generate a consistent hue from the event title
const hash = props.activity.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) const hash = props.event.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const hue = hash % 360 const hue = hash % 360
return `hsl(${hue}, 40%, 85%)` return `hsl(${hue}, 40%, 85%)`
}) })
const isPast = computed(() => { const isPast = computed(() => {
const a = props.activity const a = props.event
const end = a.endDate ?? a.startDate const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now() return end.getTime() < Date.now()
@ -70,14 +70,14 @@ const isPast = computed(() => {
<template> <template>
<Card <Card
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col" class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
@click="emit('click', activity)" @click="emit('click', event)"
> >
<!-- Image / Placeholder --> <!-- Image / Placeholder -->
<div class="relative aspect-[16/9] overflow-hidden"> <div class="relative aspect-[16/9] overflow-hidden">
<img <img
v-if="activity.image" v-if="event.image"
:src="activity.image" :src="event.image"
:alt="activity.title" :alt="event.title"
class="w-full h-full object-cover" class="w-full h-full object-cover"
loading="lazy" loading="lazy"
/> />
@ -101,7 +101,7 @@ const isPast = computed(() => {
<!-- Ownership badge the creator can spot their own events at a <!-- Ownership badge the creator can spot their own events at a
glance on the feed. --> glance on the feed. -->
<Badge <Badge
v-if="activity.isMine" v-if="event.isMine"
variant="outline" variant="outline"
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur" class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
> >
@ -118,18 +118,18 @@ const isPast = computed(() => {
</Badge> </Badge>
<!-- Pending/rejected overlay for the creator's own non-approved <!-- Pending/rejected overlay for the creator's own non-approved
drafts. Only present when the activity originated from a drafts. Only present when the event originated from a
local LNbits event (Nostr-sourced activities have no local LNbits event (Nostr-sourced events have no
lnbitsStatus). --> lnbitsStatus). -->
<Badge <Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'" v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'" :variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="absolute bottom-2 left-2 text-xs capitalize" class="absolute bottom-2 left-2 text-xs capitalize"
> >
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge> </Badge>
<!-- Past badge shown when the activity has already ended. <!-- Past badge shown when the event has already ended.
Only relevant on the feed when the "Past events" filter Only relevant on the feed when the "Past events" filter
chip is toggled on (otherwise these cards aren't rendered); chip is toggled on (otherwise these cards aren't rendered);
on the detail page the card view isn't used. Suppressed on the detail page the card view isn't used. Suppressed
@ -137,12 +137,12 @@ const isPast = computed(() => {
slot that case is the creator's own past draft, which is slot that case is the creator's own past draft, which is
vanishingly rare and the status hint is more actionable. --> vanishingly rare and the status hint is more actionable. -->
<Badge <Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')" v-if="isPast && !(event.lnbitsStatus && event.lnbitsStatus !== 'approved')"
variant="outline" variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur" class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
> >
<History class="w-3 h-3" /> <History class="w-3 h-3" />
{{ t('activities.filters.past', 'Past') }} {{ t('events.filters.past', 'Past') }}
</Badge> </Badge>
</div> </div>
@ -150,20 +150,20 @@ const isPast = computed(() => {
<!-- Title + Bookmark --> <!-- Title + Bookmark -->
<div class="flex items-start gap-1"> <div class="flex items-start gap-1">
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1"> <h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
{{ activity.title }} {{ event.title }}
</h3> </h3>
<BookmarkButton <BookmarkButton
:pubkey="activity.organizer.pubkey" :pubkey="event.organizer.pubkey"
:d-tag="activity.id" :d-tag="event.id"
/> />
</div> </div>
<!-- Summary --> <!-- Summary -->
<p <p
v-if="activity.summary" v-if="event.summary"
class="text-sm text-muted-foreground line-clamp-2" class="text-sm text-muted-foreground line-clamp-2"
> >
{{ activity.summary }} {{ event.summary }}
</p> </p>
<div class="mt-auto space-y-1.5 pt-2"> <div class="mt-auto space-y-1.5 pt-2">
@ -175,34 +175,34 @@ const isPast = computed(() => {
<!-- Location --> <!-- Location -->
<div <div
v-if="activity.location" v-if="event.location"
class="flex items-center gap-1.5 text-sm text-muted-foreground" class="flex items-center gap-1.5 text-sm text-muted-foreground"
> >
<MapPin class="w-3.5 h-3.5 shrink-0" /> <MapPin class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ activity.location }}</span> <span class="truncate">{{ event.location }}</span>
</div> </div>
<!-- Tickets available. `available === undefined` means <!-- Tickets available. `available === undefined` means
unlimited capacity (no `tickets_available` tag was unlimited capacity (no `tickets_available` tag was
published); show the price-only line in that case. --> published); show the price-only line in that case. -->
<div <div
v-if="activity.ticketInfo" v-if="event.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground" class="flex items-center gap-1.5 text-sm text-muted-foreground"
> >
<Ticket class="w-3.5 h-3.5 shrink-0" /> <Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="activity.ticketInfo.available === undefined"> <span v-if="event.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }} {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span> </span>
<span v-else-if="activity.ticketInfo.available > 0"> <span v-else-if="event.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }} {{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
</span> </span>
<span v-else class="text-destructive font-medium"> <span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }} {{ t('events.detail.soldOut') }}
</span> </span>
</div> </div>
<!-- Owned tickets shown when the current user holds at <!-- 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 the availability line so the buyer can see at a glance
whether they've already bought in. --> whether they've already bought in. -->
<div <div
@ -211,7 +211,7 @@ const isPast = computed(() => {
> >
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" /> <CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span> <span>
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }} {{ t('events.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
</span> </span>
</div> </div>
</div> </div>

View file

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { CalendarSearch } from 'lucide-vue-next' import { CalendarSearch } from 'lucide-vue-next'
import ActivityCard from './ActivityCard.vue' import EventCard from './EventCard.vue'
import type { Activity } from '../types/activity' import type { Event } from '../types/event'
defineProps<{ defineProps<{
activities: Activity[] events: Event[]
isLoading?: boolean isLoading?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
select: [activity: Activity] select: [event: Event]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@ -35,25 +35,25 @@ const { t } = useI18n()
<!-- Empty state --> <!-- Empty state -->
<div <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" 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" /> <CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<h3 class="text-lg font-medium text-foreground mb-1"> <h3 class="text-lg font-medium text-foreground mb-1">
{{ t('activities.noActivities') }} {{ t('events.noEvents') }}
</h3> </h3>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{{ t('activities.search.noResults') }} {{ t('events.search.noResults') }}
</p> </p>
</div> </div>
<!-- Activity grid --> <!-- Event grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<ActivityCard <EventCard
v-for="activity in activities" v-for="event in events"
:key="activity.nostrEventId" :key="event.nostrEventId"
:activity="activity" :event="event"
@click="emit('select', activity)" @click="emit('select', event)"
/> />
</div> </div>
</template> </template>

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ import type { TicketApiService } from '../services/TicketApiService'
* when in doubt). Probe re-runs whenever auth flips to authenticated. * when in doubt). Probe re-runs whenever auth flips to authenticated.
* *
* Used by every surface that opens the edit-mode CreateEventDialog * 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. * the probe logic single-source-of-truth.
*/ */
export function useApprovalState() { export function useApprovalState() {

View file

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

View file

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

View file

@ -3,61 +3,61 @@ import {
startOfDay, endOfDay, startOfWeek, endOfWeek, startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays, isSameDay, startOfMonth, endOfMonth, addDays, isSameDay,
} from 'date-fns' } from 'date-fns'
import type { Activity } from '../types/activity' import type { Event } from '../types/event'
import type { ActivityCategory } from '../types/category' import type { EventCategory } from '../types/category'
import type { TemporalFilter, ActivityFilters } from '../types/filters' import type { TemporalFilter, EventFilters } from '../types/filters'
import { DEFAULT_FILTERS } from '../types/filters' import { DEFAULT_FILTERS } from '../types/filters'
/** /**
* 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 temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal) const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([]) const selectedCategories = ref<EventCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined) const selectedDate = ref<Date | undefined>(undefined)
/** /**
* When true, the feed is narrowed to activities the current user * When true, the feed is narrowed to events the current user
* holds at least one paid ticket for. Crossed with the * holds at least one paid ticket for. Crossed with the
* `ownedActivityIds` set from useOwnedTickets in useActivities * `ownedEventIds` set from useOwnedTickets in useEvents
* (this composable stays free of ticket fetching). * (this composable stays free of ticket fetching).
*/ */
const onlyOwnedTickets = ref(false) const onlyOwnedTickets = ref(false)
/** /**
* When true, the feed is narrowed to activities the current user * When true, the feed is narrowed to events the current user
* is hosting (organizer pubkey matches the signed-in user, or the * is hosting (organizer pubkey matches the signed-in user, or the
* row is a local LNbits draft of theirs). Reads `activity.isMine` * row is a local LNbits draft of theirs). Reads `event.isMine`
* which `useActivities.tagOwnership()` populates. * which `useEvents.tagOwnership()` populates.
*/ */
const onlyHosting = ref(false) const onlyHosting = ref(false)
/** /**
* When false (default), activities that have already ended are * When false (default), events that have already ended are
* hidden from the feed. Toggling on includes them so the user can * hidden from the feed. Toggling on includes them so the user can
* browse past events. The date-picker overrides this picking a * browse past events. The date-picker overrides this picking a
* specific past date shows that day's activities regardless, * specific past date shows that day's events regardless,
* mirroring how it overrides the temporal pills. * mirroring how it overrides the temporal pills.
*/ */
const showPast = ref(false) const showPast = ref(false)
const filters = computed<ActivityFilters>(() => ({ const filters = computed<EventFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,
categories: selectedCategories.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[] { function applyFilters(events: Event[]): Event[] {
let result = activities let result = events
// Specific date filter (from DatePickerStrip) takes priority over // Specific date filter (from DatePickerStrip) takes priority over
// temporal. Picking a date also bypasses the past/upcoming split // 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) { if (selectedDate.value) {
const dayStart = startOfDay(selectedDate.value) const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value) const dayEnd = endOfDay(selectedDate.value)
result = result.filter(a => { result = result.filter(a => {
const activityEnd = a.endDate ?? a.startDate const eventEnd = a.endDate ?? a.startDate
return a.startDate <= dayEnd && activityEnd >= dayStart return a.startDate <= dayEnd && eventEnd >= dayStart
}) })
} else { } else {
// Temporal filter // Temporal filter
@ -69,8 +69,8 @@ export function useActivityFilters() {
// showPast=true shows only the days already passed this week. // showPast=true shows only the days already passed this week.
const now = new Date() const now = new Date()
result = result.filter(a => { result = result.filter(a => {
const activityEnd = a.endDate ?? a.startDate const eventEnd = a.endDate ?? a.startDate
return showPast.value ? activityEnd < now : activityEnd >= now return showPast.value ? eventEnd < now : eventEnd >= now
}) })
} }
@ -81,8 +81,8 @@ export function useActivityFilters() {
) )
} }
// Hosting filter — activities the signed-in user organizes. // Hosting filter — events the signed-in user organizes.
// Read off `activity.isMine` which `useActivities.tagOwnership()` // Read off `event.isMine` which `useEvents.tagOwnership()`
// populates from organizer-pubkey match + LNbits drafts. // populates from organizer-pubkey match + LNbits drafts.
if (onlyHosting.value) { if (onlyHosting.value) {
result = result.filter(a => a.isMine === true) result = result.filter(a => a.isMine === true)
@ -105,7 +105,7 @@ export function useActivityFilters() {
} }
} }
function toggleCategory(category: ActivityCategory) { function toggleCategory(category: EventCategory) {
const idx = selectedCategories.value.indexOf(category) const idx = selectedCategories.value.indexOf(category)
if (idx >= 0) { if (idx >= 0) {
selectedCategories.value.splice(idx, 1) selectedCategories.value.splice(idx, 1)
@ -174,8 +174,8 @@ export function useActivityFilters() {
// --- Helpers --- // --- Helpers ---
function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Activity[] { function applyTemporalFilter(events: Event[], filter: TemporalFilter): Event[] {
if (filter === 'all') return activities if (filter === 'all') return events
const now = new Date() const now = new Date()
let start: Date let start: Date
@ -199,12 +199,12 @@ function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Ac
end = endOfMonth(now) end = endOfMonth(now)
break break
default: default:
return activities return events
} }
return activities.filter(a => { return events.filter(a => {
const activityEnd = a.endDate ?? a.startDate const eventEnd = a.endDate ?? a.startDate
// Activity overlaps with the filter range // Event overlaps with the filter range
return a.startDate <= end && activityEnd >= start return a.startDate <= end && eventEnd >= start
}) })
} }

View file

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

View file

@ -5,7 +5,7 @@ import { useAuth } from '@/composables/useAuthService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket' import type { TicketedEvent } from '../types/ticket'
export function useEvents() { export function useMyEvents() {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
@ -34,7 +34,7 @@ export function useEvents() {
// can still browse, they just won't see their own pending events. // can still browse, they just won't see their own pending events.
// Log so a flaky probe is debuggable from the console without // Log so a flaky probe is debuggable from the console without
// toast-spamming the user on every transient failure. // 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 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() { export function useBatchProfiles() {
function fetchProfiles(pubkeys: string[]) { function fetchProfiles(pubkeys: string[]) {

View file

@ -2,12 +2,12 @@ import { computed, ref, watch } from 'vue'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' 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 * (== 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 * + the detail page + the feed filter share ONE underlying fetch
* instead of each instance hitting the API. * instead of each instance hitting the API.
* *
@ -18,7 +18,7 @@ import type { ActivityTicket } from '../types/ticket'
* atomically. * atomically.
*/ */
const tickets = ref<ActivityTicket[]>([]) const tickets = ref<EventTicket[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<Error | null>(null) const error = ref<Error | null>(null)
let hasAutoLoaded = false let hasAutoLoaded = false
@ -49,36 +49,36 @@ async function fetchTickets(): Promise<void> {
} }
} }
const ticketsByActivity = computed<Map<string, ActivityTicket[]>>(() => { const ticketsByEvent = computed<Map<string, EventTicket[]>>(() => {
const m = new Map<string, ActivityTicket[]>() const m = new Map<string, EventTicket[]>()
for (const ticket of tickets.value) { for (const ticket of tickets.value) {
const existing = m.get(ticket.activityId) const existing = m.get(ticket.eventId)
if (existing) { if (existing) {
existing.push(ticket) existing.push(ticket)
} else { } else {
m.set(ticket.activityId, [ticket]) m.set(ticket.eventId, [ticket])
} }
} }
return m return m
}) })
const ownedActivityIds = computed<Set<string>>(() => { const ownedEventIds = computed<Set<string>>(() => {
const s = new Set<string>() const s = new Set<string>()
for (const ticket of tickets.value) { for (const ticket of tickets.value) {
if (ticket.paid) s.add(ticket.activityId) if (ticket.paid) s.add(ticket.eventId)
} }
return s return s
}) })
function getTickets(activityId: string): ActivityTicket[] { function getTickets(eventId: string): EventTicket[] {
return ticketsByActivity.value.get(activityId) ?? [] 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), * multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2),
* this matches the number of attendees / scannable QRs. */ * this matches the number of attendees / scannable QRs. */
function paidCount(activityId: string): number { function paidCount(eventId: string): number {
return getTickets(activityId).filter(t => t.paid).length return getTickets(eventId).filter(t => t.paid).length
} }
export function useOwnedTickets() { export function useOwnedTickets() {
@ -115,8 +115,8 @@ export function useOwnedTickets() {
return { return {
tickets, tickets,
ticketsByActivity, ticketsByEvent,
ownedActivityIds, ownedEventIds,
getTickets, getTickets,
paidCount, paidCount,
refresh: fetchTickets, refresh: fetchTickets,

View file

@ -20,11 +20,11 @@ interface RSVPEntry {
createdAt: number 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()) 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 // 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* // "going" count is derived from this map (count of pubkeys whose *latest*
// RSVP has status === 'accepted'), not by summing every accepted event seen // RSVP has status === 'accepted'), not by summing every accepted event seen
// — that would double-count replacements and never decrement on flip. // — 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 if (existing && existing.createdAt >= entry.createdAt) return
inner.set(pubkey, entry) inner.set(pubkey, entry)
// Re-set on the outer map so the ref's reactive proxy notifies dependents // 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) rsvpStates.value.set(coord, inner)
} }
@ -60,19 +60,19 @@ export function useRSVP() {
let unsubscribe: (() => void) | null = null 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 { function getMyRSVP(eventKind: number, pubkey: string, dTag: string): RSVPStatus | null {
const coord = `${activityKind}:${pubkey}:${dTag}` const coord = `${eventKind}:${pubkey}:${dTag}`
return rsvpCache.value.get(coord)?.status ?? null return rsvpCache.value.get(coord)?.status ?? null
} }
/** /**
* RSVP count for an activity = number of pubkeys whose latest RSVP for * RSVP count for an event = number of pubkeys whose latest RSVP for
* this activity has status 'accepted'. * this event has status 'accepted'.
*/ */
function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number { function getRSVPCount(eventKind: number, pubkey: string, dTag: string): number {
const coord = `${activityKind}:${pubkey}:${dTag}` const coord = `${eventKind}:${pubkey}:${dTag}`
const inner = rsvpStates.value.get(coord) const inner = rsvpStates.value.get(coord)
if (!inner) return 0 if (!inner) return 0
let count = 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() { function loadRSVPs() {
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB) 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. * to the RSVP buttons' `:disabled` so users can't queue racing clicks.
*/ */
function isPending(activityKind: number, pubkey: string, dTag: string): boolean { function isPending(eventKind: number, pubkey: string, dTag: string): boolean {
const coord = `${activityKind}:${pubkey}:${dTag}` const coord = `${eventKind}:${pubkey}:${dTag}`
return pendingCoords.value.has(coord) 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'). * Clicking the same status again removes the RSVP (publishes 'declined').
* *
* Returns the status that was published on success, or null if the publish * Returns the status that was published on success, or null if the publish
* was rejected, blocked, or threw caller should toast accordingly. * was rejected, blocked, or threw caller should toast accordingly.
*/ */
async function setRSVP( async function setRSVP(
activityKind: number, eventKind: number,
activityPubkey: string, eventPubkey: string,
activityDTag: string, eventDTag: string,
status: RSVPStatus status: RSVPStatus
): Promise<RSVPStatus | null> { ): Promise<RSVPStatus | null> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return 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. // Throttle: refuse a second click while the first is still publishing.
if (pendingCoords.value.has(coord)) return null if (pendingCoords.value.has(coord)) return null
// Toggle: if already this status, decline instead. // 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 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 // Strictly-monotonic created_at per coord so two clicks in the same
// wall-clock second don't both stamp the same timestamp (relays would // wall-clock second don't both stamp the same timestamp (relays would
@ -181,7 +181,7 @@ export function useRSVP() {
['status', newStatus], ['status', newStatus],
['L', 'status'], ['L', 'status'],
['l', newStatus, '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 * route via HTTP rather than the kind-21000 nostr-transport RPC
* because post-#9 the webapp no longer holds a raw user prvkey. * 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 ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
const { currentUser } = useAuth() const { currentUser } = useAuth()
@ -80,7 +80,7 @@ export function useTicketScanner(activityId: Ref<string>) {
const statsError = ref<string | null>(null) const statsError = ref<string | null>(null)
/** Session-local dedup. Hidden from UI; only guards repeat decodes. */ /** Session-local dedup. Hidden from UI; only guards repeat decodes. */
const scanned = useLocalStorage<ScanRecord[]>( 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> { async function refreshStats(): Promise<void> {
if (!activityId.value) return if (!eventId.value) return
const adminKey = currentUser.value?.wallets?.[0]?.adminkey const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) { if (!adminKey) {
statsError.value = 'No wallet admin key available' statsError.value = 'No wallet admin key available'
@ -100,7 +100,7 @@ export function useTicketScanner(activityId: Ref<string>) {
statsLoading.value = true statsLoading.value = true
statsError.value = null statsError.value = null
try { try {
const data = await ticketApi.getEventStats(activityId.value, adminKey) const data = await ticketApi.getEventStats(eventId.value, adminKey)
eventStats.value = { eventStats.value = {
sold: data.sold, sold: data.sold,
registered: data.registered, registered: data.registered,

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import type { import type {
ActivityTicket, EventTicket,
ActivityTicketExtra, EventTicketExtra,
CreateTicketRequest, CreateTicketRequest,
PaymentMethod, PaymentMethod,
TicketPurchaseInvoice, TicketPurchaseInvoice,
@ -27,7 +27,7 @@ export class TicketApiService {
/** /**
* Fetch all public events from the LNbits events extension. * 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[]> { async fetchTicketedEvents(): Promise<any[]> {
const response = await this.request( const response = await this.request(
@ -133,7 +133,7 @@ export class TicketApiService {
async fetchUserTickets( async fetchUserTickets(
userId: string, userId: string,
accessToken: string accessToken: string
): Promise<ActivityTicket[]> { ): Promise<EventTicket[]> {
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/user/${userId}`, `/events/api/v1/tickets/user/${userId}`,
{ {
@ -147,7 +147,7 @@ export class TicketApiService {
return (data as any[]).map(t => ({ return (data as any[]).map(t => ({
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
activityId: t.event, eventId: t.event,
name: t.name, name: t.name,
email: t.email, email: t.email,
userId: t.user_id, userId: t.user_id,
@ -155,14 +155,14 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
})) }))
} }
/** /**
* Validate/register a ticket at the door (scan). * 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( const data = await this.request(
`/events/api/v1/register/ticket/${ticketId}`, `/events/api/v1/register/ticket/${ticketId}`,
{ method: 'GET' } { method: 'GET' }
@ -171,7 +171,7 @@ export class TicketApiService {
return (data as any[]).map(t => ({ return (data as any[]).map(t => ({
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
activityId: t.event, eventId: t.event,
name: t.name, name: t.name,
email: t.email, email: t.email,
userId: t.user_id, userId: t.user_id,
@ -179,7 +179,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, 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( async resendTicketEmail(
ticketId: string, ticketId: string,
adminKey: string, adminKey: string,
): Promise<ActivityTicket> { ): Promise<EventTicket> {
const t = await this.request( const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`, `/events/api/v1/tickets/${ticketId}/resend-email`,
{ {
@ -240,7 +240,7 @@ export class TicketApiService {
return { return {
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
activityId: t.event, eventId: t.event,
name: t.name, name: t.name,
email: t.email, email: t.email,
userId: t.user_id, userId: t.user_id,
@ -248,7 +248,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, 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 * unpaid / already-registered / not-owned cases with HTTP errors
* whose `detail` becomes the thrown Error message. * 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}`, { const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
method: 'PUT', method: 'PUT',
headers: { 'X-API-KEY': adminKey }, headers: { 'X-API-KEY': adminKey },
@ -294,7 +294,7 @@ export class TicketApiService {
return { return {
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
activityId: t.event, eventId: t.event,
name: t.name, name: t.name,
email: t.email, email: t.email,
userId: t.user_id, userId: t.user_id,
@ -302,7 +302,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, 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 { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Activity } from '../types/activity' import type { Event } from '../types/event'
import type { TicketedEvent } from '../types/ticket' 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). * Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
*/ */
export const useActivitiesStore = defineStore('activities', () => { export const useEventsStore = defineStore('events', () => {
// State // State
const activitiesMap = ref<Map<string, Activity>>(new Map()) const eventsMap = ref<Map<string, Event>>(new Map())
const isLoading = ref(false) const isLoading = ref(false)
const lastUpdated = ref<Date | null>(null) const lastUpdated = ref<Date | null>(null)
/** Toggle by the standalone bottom-nav Create tab; mounted dialog lives /** 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) const showCreateDialog = ref(false)
/** When set, the shell-mounted CreateEventDialog opens in edit mode /** When set, the shell-mounted CreateEventDialog opens in edit mode
* for this LNbits event. Cleared when the dialog closes. */ * for this LNbits event. Cleared when the dialog closes. */
const editingEvent = ref<TicketedEvent | null>(null) const editingEvent = ref<TicketedEvent | null>(null)
// Computed // 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() const now = new Date()
return activities.value return events.value
.filter(a => a.startDate >= now || (a.endDate && a.endDate >= now)) .filter(a => a.startDate >= now || (a.endDate && a.endDate >= now))
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()) .sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
}) })
const pastActivities = computed(() => { const pastEvents = computed(() => {
const now = new Date() const now = new Date()
return activities.value return events.value
.filter(a => { .filter(a => {
const endOrStart = a.endDate ?? a.startDate const endOrStart = a.endDate ?? a.startDate
return endOrStart < now return endOrStart < now
@ -42,68 +42,68 @@ export const useActivitiesStore = defineStore('activities', () => {
// Actions // 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. * Deduplicates by id (d-tag). Newer events replace older ones.
*/ */
function upsertActivity(activity: Activity) { function upsertEvent(event: Event) {
const existing = activitiesMap.value.get(activity.id) const existing = eventsMap.value.get(event.id)
// Only update if this is a newer version // Only update if this is a newer version
if (!existing || activity.createdAt >= existing.createdAt) { if (!existing || event.createdAt >= existing.createdAt) {
activitiesMap.value.set(activity.id, activity) eventsMap.value.set(event.id, event)
lastUpdated.value = new Date() lastUpdated.value = new Date()
} }
} }
/** /**
* Add multiple activities (batch upsert). * Add multiple events (batch upsert).
*/ */
function upsertActivities(newActivities: Activity[]) { function upsertEvents(newEvents: Event[]) {
for (const activity of newActivities) { for (const event of newEvents) {
upsertActivity(activity) upsertEvent(event)
} }
} }
/** /**
* Remove an activity from the store. * Remove an event from the store.
*/ */
function removeActivity(id: string) { function removeEvent(id: string) {
activitiesMap.value.delete(id) eventsMap.value.delete(id)
} }
/** /**
* Clear all cached activities. * Clear all cached events.
*/ */
function clearAll() { function clearAll() {
activitiesMap.value.clear() eventsMap.value.clear()
lastUpdated.value = null 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 { function getEventById(id: string): Event | undefined {
return activitiesMap.value.get(id) return eventsMap.value.get(id)
} }
return { return {
// State // State
activitiesMap, eventsMap,
isLoading, isLoading,
lastUpdated, lastUpdated,
showCreateDialog, showCreateDialog,
editingEvent, editingEvent,
// Computed // Computed
activities, events,
upcomingActivities, upcomingEvents,
pastActivities, pastEvents,
// Actions // Actions
upsertActivity, upsertEvent,
upsertActivities, upsertEvents,
removeActivity, removeEvent,
clearAll, 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 * Mapped to NIP-52 't' (hashtag) tags
*/ */
export const ACTIVITY_CATEGORIES = { export const EVENT_CATEGORIES = {
concert: 'concert', concert: 'concert',
workshop: 'workshop', workshop: 'workshop',
market: 'market', market: 'market',
@ -30,6 +30,6 @@ export const ACTIVITY_CATEGORIES = {
other: 'other', other: 'other',
} as const } 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 ngeohash from 'ngeohash'
import type { ActivityCategory } from './category' import type { EventCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52' import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
import type { TicketedEvent } from './ticket' 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. * Created from NIP-52 CalendarTimeEvent or CalendarDateEvent.
*/ */
export interface Activity { export interface Event {
/** Unique identifier (NIP-52 d-tag) */ /** Unique identifier (NIP-52 d-tag) */
id: string id: string
/** Nostr event ID */ /** Nostr event ID */
@ -16,7 +16,7 @@ export interface Activity {
type: 'date' | 'time' type: 'date' | 'time'
/** Organizer information */ /** Organizer information */
organizer: OrganizerInfo organizer: OrganizerInfo
/** Activity title */ /** Event title */
title: string title: string
/** Brief summary */ /** Brief summary */
summary?: string summary?: string
@ -37,18 +37,18 @@ export interface Activity {
/** NIP-52 geohash (g tag) */ /** NIP-52 geohash (g tag) */
geohash?: string geohash?: string
/** Primary category */ /** Primary category */
category?: ActivityCategory category?: EventCategory
/** All hashtags/tags */ /** All hashtags/tags */
tags: string[] tags: string[]
/** Ticket pricing info (if ticketed) */ /** Ticket pricing info (if ticketed) */
ticketInfo?: ActivityTicketInfo ticketInfo?: EventTicketInfo
/** Whether this is a private/invite-only event */ /** Whether this is a private/invite-only event */
isPrivate: boolean isPrivate: boolean
/** Nostr event created_at timestamp */ /** Nostr event created_at timestamp */
createdAt: Date createdAt: Date
/** /**
* LNbits approval status, when the activity came from the events * LNbits approval status, when the event came from the events
* extension rather than a Nostr relay. Undefined for activities * extension rather than a Nostr relay. Undefined for events
* sourced from Nostr (approved by definition only published * sourced from Nostr (approved by definition only published
* events make it onto relays). Used to render a "Pending review" * events make it onto relays). Used to render a "Pending review"
* badge for the creator's own non-approved drafts. * badge for the creator's own non-approved drafts.
@ -56,7 +56,7 @@ export interface Activity {
lnbitsStatus?: 'approved' | 'proposed' | 'rejected' lnbitsStatus?: 'approved' | 'proposed' | 'rejected'
/** /**
* Belongs to the current user. Set by the adapter for own LNbits * 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 * organizer pubkey matches the logged-in user. Used to render a
* "Yours" badge on the feed so the creator can spot their events * "Yours" badge on the feed so the creator can spot their events
* at a glance. * at a glance.
@ -71,7 +71,7 @@ export interface OrganizerInfo {
nip05?: string nip05?: string
} }
export interface ActivityTicketInfo { export interface EventTicketInfo {
price: number price: number
currency: string currency: string
/** Remaining capacity. Undefined means unlimited. */ /** Remaining capacity. Undefined means unlimited. */
@ -84,7 +84,7 @@ export interface ActivityTicketInfo {
fiatCurrency?: string fiatCurrency?: string
} }
function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo | undefined { function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
if (!ticket) return undefined if (!ticket) return undefined
return { return {
price: ticket.price, 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 { export function calendarTimeEventToEvent(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Event {
const category = event.hashtags[0] as ActivityCategory | undefined const category = event.hashtags[0] as EventCategory | undefined
return { return {
id: event.dTag, 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 { export function calendarDateEventToEvent(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Event {
const category = event.hashtags[0] as ActivityCategory | undefined const category = event.hashtags[0] as EventCategory | undefined
// Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC // Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC
const parseIsoDate = (dateStr: string): Date => { 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 * Used to surface the caller's own pending events on the events
* feed alongside Nostr-published activities. Once an event is approved * feed alongside Nostr-published events. Once an event is approved
* and published, the Nostr-derived Activity (newer createdAt) wins on * and published, the Nostr-derived Event (newer createdAt) wins on
* upsert in the activities store and this draft version is replaced. * upsert in the events store and this draft version is replaced.
* *
* The wire format for dates mirrors how nostr_publisher emits NIP-52: * The wire format for dates mirrors how nostr_publisher emits NIP-52:
* - "YYYY-MM-DD" date-based (kind 31922 on publish) * - "YYYY-MM-DD" date-based (kind 31922 on publish)
* - "YYYY-MM-DDTHH:MM..." time-based (kind 31923 on publish) * - "YYYY-MM-DDTHH:MM..." time-based (kind 31923 on publish)
*/ */
export function ticketedEventToActivity( export function ticketedEventToEvent(
event: TicketedEvent, event: TicketedEvent,
organizer?: Partial<OrganizerInfo>, organizer?: Partial<OrganizerInfo>,
): Activity { ): Event {
const hasTime = event.event_start_date.includes('T') const hasTime = event.event_start_date.includes('T')
const startDate = hasTime const startDate = hasTime
? new Date(event.event_start_date) ? new Date(event.event_start_date)
@ -192,7 +192,7 @@ export function ticketedEventToActivity(
: parseDateOnly(endRaw) : parseDateOnly(endRaw)
: undefined : undefined
const category = event.categories?.[0] as ActivityCategory | undefined const category = event.categories?.[0] as EventCategory | undefined
return { return {
id: event.id, id: event.id,
@ -204,7 +204,7 @@ export function ticketedEventToActivity(
organizer: { organizer: {
// Pending events have no Nostr pubkey yet. Empty string is fine // Pending events have no Nostr pubkey yet. Empty string is fine
// — the card layer falls back gracefully and the OrganizerCard // — 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: '', pubkey: '',
...organizer, ...organizer,
}, },
@ -221,7 +221,7 @@ export function ticketedEventToActivity(
// FastAPI serialization). new Date() handles both ISO strings and // FastAPI serialization). new Date() handles both ISO strings and
// numeric epoch — same shape used in useEvents sorting. // numeric epoch — same shape used in useEvents sorting.
createdAt: new Date(event.time) || new Date(), 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 // fetchMyEvents only returns the caller's own events, so anything
// reaching this adapter is by definition mine. // reaching this adapter is by definition mine.
isMine: true, 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) * 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' 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 temporal: TemporalFilter
categories: ActivityCategory[] categories: EventCategory[]
/** Free text search */ /** Free text search */
search?: string search?: string
/** Geohash prefix for geographic filtering */ /** Geohash prefix for geographic filtering */
@ -22,7 +22,7 @@ export interface ActivityFilters {
/** /**
* Default filter state * Default filter state
*/ */
export const DEFAULT_FILTERS: ActivityFilters = { export const DEFAULT_FILTERS: EventFilters = {
temporal: 'all', temporal: 'all',
categories: [], categories: [],
} }

View file

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

View file

@ -10,14 +10,14 @@ import { Separator } from '@/components/ui/separator'
import { import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History, Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail' import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue' import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue' import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52' import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '../stores/activities' import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets' import { useOwnedTickets } from '../composables/useOwnedTickets'
import { toastService } from '@/core/services/ToastService' import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
@ -28,16 +28,16 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const activityId = route.params.id as string const eventId = route.params.id as string
const { activity, isLoading, error, reload } = useActivityDetail(activityId) const { event, isLoading, error, reload } = useEventDetail(eventId)
const { dateLocale } = useDateLocale() 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 // the same as the LNbits event id (set at publish time in
// nostr_publisher.build_nip52_event). Look the user's own events up // nostr_publisher.build_nip52_event). Look the user's own events up
// once and offer an Edit button on a match. // once and offer an Edit button on a match.
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const activitiesStore = useActivitiesStore() const eventsStore = useEventsStore()
const ownedLnbitsEvent = ref<TicketedEvent | null>(null) const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
async function loadOwnedEvent() { async function loadOwnedEvent() {
@ -49,7 +49,7 @@ async function loadOwnedEvent() {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const mine = await ticketApi.fetchMyEvents(invoiceKey) const mine = await ticketApi.fetchMyEvents(invoiceKey)
ownedLnbitsEvent.value = ownedLnbitsEvent.value =
(mine as TicketedEvent[]).find((e) => e.id === activityId) ?? null (mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null
} catch { } catch {
ownedLnbitsEvent.value = null ownedLnbitsEvent.value = null
} }
@ -60,17 +60,17 @@ watch(isAuthenticated, () => loadOwnedEvent())
function openEditDialog() { function openEditDialog() {
if (!ownedLnbitsEvent.value) return if (!ownedLnbitsEvent.value) return
activitiesStore.editingEvent = ownedLnbitsEvent.value eventsStore.editingEvent = ownedLnbitsEvent.value
activitiesStore.showCreateDialog = true eventsStore.showCreateDialog = true
} }
function openScannerPage() { function openScannerPage() {
router.push({ name: 'scan-tickets', params: { activityId } }) router.push({ name: 'scan-tickets', params: { eventId } })
} }
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
if (!activity.value) return '' if (!event.value) return ''
const a = activity.value const a = event.value
const opts = { locale: dateLocale.value } const opts = { locale: dateLocale.value }
if (a.type === 'date') { if (a.type === 'date') {
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts) const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
@ -94,22 +94,22 @@ const dateDisplay = computed(() => {
}) })
const categoryLabel = computed(() => { const categoryLabel = computed(() => {
if (!activity.value?.category) return null if (!event.value?.category) return null
return t(`activities.categories.${activity.value.category}`, activity.value.category) return t(`events.categories.${event.value.category}`, event.value.category)
}) })
function goBack() { function goBack() {
router.push({ name: 'activities' }) router.push({ name: 'events' })
} }
// --- Ticket purchase + owned-tickets surface ---------------------- // --- Ticket purchase + owned-tickets surface ----------------------
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets() const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
const ownedPaidCount = computed(() => paidCount(activityId)) const ownedPaidCount = computed(() => paidCount(eventId))
const purchaseEvent = computed(() => { const purchaseEvent = computed(() => {
const a = activity.value const a = event.value
if (!a || !a.ticketInfo) return null if (!a || !a.ticketInfo) return null
return { return {
id: a.id, id: a.id,
@ -125,7 +125,7 @@ const purchaseEvent = computed(() => {
// available === 0 sold out, button hidden // available === 0 sold out, button hidden
// available > 0 button shown // available > 0 button shown
const canBuyTicket = computed(() => { const canBuyTicket = computed(() => {
const info = activity.value?.ticketInfo const info = event.value?.ticketInfo
if (!info) return false if (!info) return false
return info.available === undefined || info.available > 0 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 // buy CTA so the flow is unambiguous date alone is easy to miss
// on a long detail page. // on a long detail page.
const isPast = computed(() => { const isPast = computed(() => {
const a = activity.value const a = event.value
if (!a) return false if (!a) return false
const end = a.endDate ?? a.startDate const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false if (!end || isNaN(end.getTime())) return false
@ -145,9 +145,9 @@ const showPurchaseDialog = ref(false)
function openPurchaseDialog() { function openPurchaseDialog() {
if (!isAuthenticated.value) { if (!isAuthenticated.value) {
toastService.info(t('activities.detail.loginToBuyTickets'), { toastService.info(t('events.detail.loginToBuyTickets'), {
action: { action: {
label: t('activities.detail.logIn'), label: t('events.detail.logIn'),
onClick: () => router.push('/login'), onClick: () => router.push('/login'),
}, },
}) })
@ -200,9 +200,9 @@ function goToMyTickets() {
Edit Edit
</Button> </Button>
<BookmarkButton <BookmarkButton
v-if="activity" v-if="event"
:pubkey="activity.organizer.pubkey" :pubkey="event.organizer.pubkey"
:d-tag="activity.id" :d-tag="event.id"
/> />
</div> </div>
</div> </div>
@ -217,18 +217,18 @@ function goToMyTickets() {
<!-- Error --> <!-- Error -->
<div v-else-if="error" class="text-center py-16"> <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> <p class="text-muted-foreground mb-4">{{ error }}</p>
<Button variant="outline" @click="reload">Retry</Button> <Button variant="outline" @click="reload">Retry</Button>
</div> </div>
<!-- Detail content --> <!-- Detail content -->
<div v-else-if="activity" class="space-y-6"> <div v-else-if="event" class="space-y-6">
<!-- Hero image --> <!-- Hero image -->
<div v-if="activity.image" class="rounded-lg overflow-hidden"> <div v-if="event.image" class="rounded-lg overflow-hidden">
<img <img
:src="activity.image" :src="event.image"
:alt="activity.title" :alt="event.title"
class="w-full aspect-[16/9] object-cover" class="w-full aspect-[16/9] object-cover"
/> />
</div> </div>
@ -240,28 +240,28 @@ function goToMyTickets() {
{{ categoryLabel }} {{ categoryLabel }}
</Badge> </Badge>
<Badge <Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'" v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'" :variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="shrink-0 mt-1 capitalize" class="shrink-0 mt-1 capitalize"
> >
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge> </Badge>
<Badge <Badge
v-if="activity.isMine" v-if="event.isMine"
variant="outline" variant="outline"
class="shrink-0 mt-1" class="shrink-0 mt-1"
> >
Yours Yours
</Badge> </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> <Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div> </div>
</div> </div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground"> <h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ activity.title }} {{ event.title }}
</h1> </h1>
<p v-if="activity.summary" class="text-muted-foreground mt-2"> <p v-if="event.summary" class="text-muted-foreground mt-2">
{{ activity.summary }} {{ event.summary }}
</p> </p>
</div> </div>
@ -273,52 +273,52 @@ function goToMyTickets() {
<div class="bg-muted/50 rounded-lg p-4 space-y-1"> <div class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground"> <div class="flex items-center gap-2 text-sm font-medium text-foreground">
<Calendar class="w-4 h-4" /> <Calendar class="w-4 h-4" />
{{ t('activities.detail.when') }} {{ t('events.detail.when') }}
</div> </div>
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p> <p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
<p v-if="activity.timezone" class="text-xs text-muted-foreground/70"> <p v-if="event.timezone" class="text-xs text-muted-foreground/70">
{{ activity.timezone }} {{ event.timezone }}
</p> </p>
</div> </div>
<!-- Where --> <!-- Where -->
<div v-if="activity.location" class="bg-muted/50 rounded-lg p-4 space-y-1"> <div v-if="event.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground"> <div class="flex items-center gap-2 text-sm font-medium text-foreground">
<MapPin class="w-4 h-4" /> <MapPin class="w-4 h-4" />
{{ t('activities.detail.location') }} {{ t('events.detail.location') }}
</div> </div>
<p class="text-sm text-muted-foreground">{{ activity.location }}</p> <p class="text-sm text-muted-foreground">{{ event.location }}</p>
</div> </div>
</div> </div>
<!-- RSVP --> <!-- RSVP -->
<!-- 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 (31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every activity, leaving RSVPs button would default to time-based for every event, leaving RSVPs
on date-based activities pointing at a non-existent event coord. --> on date-based events pointing at a non-existent event coord. -->
<RSVPButton <RSVPButton
:pubkey="activity.organizer.pubkey" :pubkey="event.organizer.pubkey"
:d-tag="activity.id" :d-tag="event.id"
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT" :kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/> />
<!-- Tickets gated on the activity carrying ticketInfo (set <!-- Tickets gated on the event carrying ticketInfo (set
by the calendarActivity converter from the AIO custom by the calendarEvent converter from the AIO custom
tickets_* tags on the published event). Sections render tickets_* tags on the published event). Sections render
bottom-up: availability count, then existing owned bottom-up: availability count, then existing owned
tickets (when count > 0) above a Purchase CTA (when tickets (when count > 0) above a Purchase CTA (when
capacity remains). --> capacity remains). -->
<div v-if="activity.ticketInfo" class="space-y-3"> <div v-if="event.ticketInfo" class="space-y-3">
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground"> <div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Ticket class="w-4 h-4 shrink-0" /> <Ticket class="w-4 h-4 shrink-0" />
<span v-if="activity.ticketInfo.available === undefined"> <span v-if="event.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }} {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span> </span>
<span v-else-if="activity.ticketInfo.available > 0"> <span v-else-if="event.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }} {{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
</span> </span>
<span v-else class="text-destructive font-medium"> <span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }} {{ t('events.detail.soldOut') }}
</span> </span>
</div> </div>
@ -328,11 +328,11 @@ function goToMyTickets() {
> >
<div class="flex items-center gap-2 text-sm font-medium text-foreground"> <div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" /> <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> </div>
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets"> <Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" /> <Ticket class="w-4 h-4" />
{{ t('activities.detail.viewMyTickets', 'View in My Tickets') }} {{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
</Button> </Button>
</div> </div>
@ -341,7 +341,7 @@ function goToMyTickets() {
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3" 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" /> <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>
<div v-else-if="canBuyTicket"> <div v-else-if="canBuyTicket">
<Button <Button
@ -351,12 +351,12 @@ function goToMyTickets() {
> >
<Ticket class="w-4 h-4" /> <Ticket class="w-4 h-4" />
{{ ownedPaidCount > 0 {{ ownedPaidCount > 0
? t('activities.detail.buyAnotherTicket', 'Buy another ticket') ? t('events.detail.buyAnotherTicket', 'Buy another ticket')
: t('activities.detail.buyTicket', 'Buy ticket') }} : t('events.detail.buyTicket', 'Buy ticket') }}
<span class="ml-2 opacity-80 font-normal"> <span class="ml-2 opacity-80 font-normal">
{{ activity.ticketInfo.price === 0 {{ event.ticketInfo.price === 0
? t('activities.detail.free') ? t('events.detail.free')
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }} : `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
</span> </span>
</Button> </Button>
</div> </div>
@ -364,7 +364,7 @@ function goToMyTickets() {
v-else-if="ownedPaidCount === 0" v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center" class="text-sm text-destructive text-center"
> >
{{ t('activities.detail.soldOut') }} {{ t('events.detail.soldOut') }}
</p> </p>
</div> </div>
@ -378,20 +378,20 @@ function goToMyTickets() {
<!-- Organizer --> <!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4"> <div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
{{ t('activities.detail.organizer') }} {{ t('events.detail.organizer') }}
</p> </p>
<OrganizerCard :pubkey="activity.organizer.pubkey" /> <OrganizerCard :pubkey="event.organizer.pubkey" />
</div> </div>
<Separator /> <Separator />
<!-- Description --> <!-- Description -->
<div class="prose prose-sm max-w-none text-foreground"> <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> </div>
<!-- External references --> <!-- 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 --> <!-- References would go here in future phases -->
</div> </div>
</div> </div>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useEvents } from '../composables/useEvents'
import EventCalendarView from '../components/EventCalendarView.vue'
import type { Event } from '../types/event'
const router = useRouter()
const { allEvents, subscribe } = useEvents()
onMounted(() => {
subscribe()
})
function handleSelectEvent(event: Event) {
router.push({ name: 'event-detail', params: { id: event.id } })
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<EventCalendarView
:events="allEvents"
@select-event="handleSelectEvent"
/>
</div>
</template>

View file

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

@ -9,20 +9,20 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next' import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities' import { useEvents } from '../composables/useEvents'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue' import EventSearchOverlay from '../components/EventSearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue' import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import DatePickerStrip from '../components/DatePickerStrip.vue' import DatePickerStrip from '../components/DatePickerStrip.vue'
import ActivityList from '../components/ActivityList.vue' import EventList from '../components/EventList.vue'
import type { Activity } from '../types/activity' import type { Event } from '../types/event'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { const {
activities, events,
isLoading, isLoading,
error, error,
temporal, temporal,
@ -41,7 +41,7 @@ const {
togglePast, togglePast,
resetFilters, resetFilters,
subscribe, subscribe,
} = useActivities() } = useEvents()
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
@ -51,8 +51,8 @@ onMounted(() => {
subscribe() subscribe()
}) })
function handleSelectActivity(activity: Activity) { function handleSelectEvent(event: Event) {
router.push({ name: 'activity-detail', params: { id: activity.id } }) router.push({ name: 'event-detail', params: { id: event.id } })
} }
</script> </script>
@ -61,15 +61,15 @@ function handleSelectActivity(activity: Activity) {
<!-- Page header --> <!-- Page header -->
<div class="mb-4"> <div class="mb-4">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground"> <h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ t('activities.title') }} {{ t('events.title') }}
</h1> </h1>
</div> </div>
<!-- Search with dropdown overlay --> <!-- Search with dropdown overlay -->
<div class="mb-4"> <div class="mb-4">
<ActivitySearchOverlay <EventSearchOverlay
:activities="activities" :events="events"
@select="handleSelectActivity" @select="handleSelectEvent"
/> />
</div> </div>
@ -84,7 +84,7 @@ function handleSelectActivity(activity: Activity) {
</div> </div>
<!-- Role + past-events filter chips. The role chips ("My tickets", <!-- Role + past-events filter chips. The role chips ("My tickets",
"Hosting") narrow the feed to activities the signed-in user "Hosting") narrow the feed to events the signed-in user
has skin in and are hidden when logged out. The "Past events" has skin in and are hidden when logged out. The "Past events"
chip is always visible since past-browsing doesn't require an chip is always visible since past-browsing doesn't require an
account. --> account. -->
@ -97,7 +97,7 @@ function handleSelectActivity(activity: Activity) {
@click="toggleOwnedTickets" @click="toggleOwnedTickets"
> >
<Ticket class="w-3.5 h-3.5" /> <Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }} {{ t('events.filters.myTickets', 'My tickets') }}
</Button> </Button>
<Button <Button
:variant="onlyHosting ? 'default' : 'outline'" :variant="onlyHosting ? 'default' : 'outline'"
@ -106,7 +106,7 @@ function handleSelectActivity(activity: Activity) {
@click="toggleHosting" @click="toggleHosting"
> >
<Megaphone class="w-3.5 h-3.5" /> <Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }} {{ t('events.filters.hosting', 'Hosting') }}
</Button> </Button>
</template> </template>
<Button <Button
@ -116,7 +116,7 @@ function handleSelectActivity(activity: Activity) {
@click="togglePast" @click="togglePast"
> >
<History class="w-3.5 h-3.5" /> <History class="w-3.5 h-3.5" />
{{ t('activities.filters.pastEvents', 'Past events') }} {{ t('events.filters.pastEvents', 'Past events') }}
</Button> </Button>
</div> </div>
@ -154,11 +154,11 @@ function handleSelectActivity(activity: Activity) {
{{ error }} {{ error }}
</div> </div>
<!-- Activity feed --> <!-- Event feed -->
<ActivityList <EventList
:activities="activities" :events="events"
:is-loading="isLoading" :is-loading="isLoading"
@select="handleSelectActivity" @select="handleSelectEvent"
/> />
</div> </div>
</template> </template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useEvents } from '../composables/useEvents' import { useMyEvents } from '../composables/useMyEvents'
import { useApprovalState } from '../composables/useApprovalState' import { useApprovalState } from '../composables/useApprovalState'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' 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 { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket' 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 { isAuthenticated, userDisplay, currentUser } = useAuth()
const { isAdmin, autoApprove } = useApprovalState() const { isAdmin, autoApprove } = useApprovalState()

View file

@ -152,7 +152,7 @@ onMounted(async () => {
</div> </div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2> <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> <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>
<div v-else-if="tickets.length > 0"> <div v-else-if="tickets.length > 0">

View file

@ -17,13 +17,13 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue' import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner' import { useTicketScanner } from '../composables/useTicketScanner'
import { useActivityDetail } from '../composables/useActivityDetail' import { useEventDetail } from '../composables/useEventDetail'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const activityId = ref(route.params.activityId as string) const eventId = ref(route.params.eventId as string)
const { activity } = useActivityDetail(activityId.value) const { event } = useEventDetail(eventId.value)
const { const {
isProcessing, isProcessing,
@ -35,7 +35,7 @@ const {
refreshStats, refreshStats,
onDecode, onDecode,
resume, resume,
} = useTicketScanner(activityId) } = useTicketScanner(eventId)
const scannerOpen = ref(true) const scannerOpen = ref(true)
const activeTab = ref<'scanner' | 'list'>('scanner') const activeTab = ref<'scanner' | 'list'>('scanner')
@ -53,10 +53,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. // event's `tickets_sold` tag if the RPC hasn't completed yet.
const soldCount = computed( 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 registeredCount = computed(() => eventStats.value?.registered ?? 0)
const remainingCount = computed(() => { const remainingCount = computed(() => {
@ -78,7 +78,7 @@ function handleResult(qrText: string) {
function goBack() { function goBack() {
if (window.history.length > 1) router.back() 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) { function fmtTime(iso: string) {
@ -111,8 +111,8 @@ function fmtTime(iso: string) {
</div> </div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1> <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"> <p v-if="event" class="text-sm text-muted-foreground mb-4">
{{ activity.title }} {{ event.title }}
</p> </p>
<!-- Counts strip backend-authoritative. Source: the <!-- 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 // Logged-in user has no published market event yet — show their
// namespace as "My Market". Avoids leaking VITE_APP_NAME (which // namespace as "My Market". Avoids leaking VITE_APP_NAME (which
// is the brand of whichever standalone app is bundled, e.g. // is the brand of whichever standalone app is bundled, e.g.
// "Sortir" for activities) into the market label. // "Sortir" for the events app) into the market label.
name: 'My Market', name: 'My Market',
description: 'A communal market to sell your goods', description: 'A communal market to sell your goods',
merchants: [], merchants: [],

View file

@ -451,7 +451,7 @@ const placeOrder = async () => {
// Try to get pubkey from main auth first, fallback to auth service // Try to get pubkey from main auth first, fallback to auth service
const userPubkey = auth.currentUser.value?.pubkey || authService.user.value?.pubkey 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) { if (!auth.isAuthenticated.value) {
toast.info(t('market.auth.loginPrompt'), { toast.info(t('market.auth.loginPrompt'), {
action: { 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: '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: '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: '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: '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: '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 }, { 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' import { visualizer } from 'rollup-plugin-visualizer'
/** /**
* Plugin to rewrite dev server requests to activities.html * Plugin to rewrite dev server requests to events.html
* (SPA fallback for the standalone activities app entry point) * (SPA fallback for the standalone events app entry point)
*/ */
function activitiesHtmlPlugin(): Plugin { function eventsHtmlPlugin(): Plugin {
return { return {
name: 'activities-html-rewrite', name: 'events-html-rewrite',
configureServer(server) { configureServer(server) {
server.middlewares.use((req, _res, next) => { 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=...) // Strip query before checking for an extension — JWTs (e.g. ?token=...)
// contain dots and would otherwise get mistaken for an asset request. // contain dots and would otherwise get mistaken for an asset request.
const path = req.url ? req.url.split('?')[0] : '' const path = req.url ? req.url.split('?')[0] : ''
@ -26,7 +26,7 @@ function activitiesHtmlPlugin(): Plugin {
!req.url.startsWith('/node_modules/') && !req.url.startsWith('/node_modules/') &&
!path.includes('.') !path.includes('.')
) { ) {
req.url = '/activities.html' req.url = '/events.html'
} }
next() next()
}) })
@ -35,7 +35,7 @@ function activitiesHtmlPlugin(): Plugin {
} }
/** /**
* Vite config for the standalone Sortir activities app. * Vite config for the standalone standalone events app.
* *
* Set VITE_BASE_PATH to deploy under a path prefix: * Set VITE_BASE_PATH to deploy under a path prefix:
* VITE_BASE_PATH=/sortir/ app.ariege.io/sortir/ (shared auth) * VITE_BASE_PATH=/sortir/ app.ariege.io/sortir/ (shared auth)
@ -44,13 +44,13 @@ function activitiesHtmlPlugin(): Plugin {
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
base: process.env.VITE_BASE_PATH || '/', base: process.env.VITE_BASE_PATH || '/',
// Per-app dep cache so concurrent dev servers don't race on .vite/deps // 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: { server: {
port: 5181, port: 5181,
strictPort: true, strictPort: true,
}, },
plugins: [ plugins: [
activitiesHtmlPlugin(), eventsHtmlPlugin(),
vue(), vue(),
tailwindcss(), tailwindcss(),
VitePWA({ VitePWA({
@ -61,7 +61,7 @@ export default defineConfig(({ mode }) => ({
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'], globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
// Scope the service worker to only handle requests within this app's path // Scope the service worker to only handle requests within this app's path
navigateFallback: 'activities.html', navigateFallback: 'events.html',
navigateFallbackAllowlist: [ navigateFallbackAllowlist: [
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
], ],
@ -104,7 +104,7 @@ export default defineConfig(({ mode }) => ({
mode === 'analyze' && mode === 'analyze' &&
visualizer({ visualizer({
open: true, open: true,
filename: 'dist-activities/stats.html', filename: 'dist-events/stats.html',
gzipSize: true, gzipSize: true,
brotliSize: true, brotliSize: true,
}), }),
@ -115,9 +115,9 @@ export default defineConfig(({ mode }) => ({
}, },
}, },
build: { build: {
outDir: 'dist-activities', outDir: 'dist-events',
rollupOptions: { rollupOptions: {
input: 'activities.html', input: 'events.html',
output: { output: {
manualChunks: { manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'], 'vue-vendor': ['vue', 'vue-router', 'pinia'],