Compare commits

..

No commits in common. "f7eb6336895f185ffb6c34d5b4840c219a57cdf3" and "1f20d5f00ce6964535102f9b64843d6b1c2a7304" have entirely different histories.

75 changed files with 1082 additions and 822 deletions

View file

@ -1,10 +1,4 @@
# App Configuration
# Per-standalone display name — sets browser tab title, PWA install
# name/short_name, and the brand string in console logs. Each standalone
# (events, wallet, chat, market, …) gets its own VITE_APP_NAME at build
# time via NixOS `services.webapp-standalones.<app>.displayName` (see
# server-deploy). cfaun ships the events app as "Sortir"; defaults to
# "Events" / "Wallet" / etc. when unset.
VITE_APP_NAME=MyApp
# Nostr Configuration
@ -70,7 +64,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
#
# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined
# in the vite configs):
# VITE_HUB_EVENTS_URL=http://localhost:5181
# VITE_HUB_ACTIVITIES_URL=http://localhost:5181
# VITE_HUB_LIBRA_URL=http://localhost:5180
# VITE_HUB_WALLET_URL=http://localhost:5182
# VITE_HUB_CHAT_URL=http://localhost:5183
@ -80,7 +74,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
# VITE_HUB_RESTAURANT_URL=http://localhost:5187
#
# In PATH-MODE production (recommended for demo) — note the trailing slash:
# VITE_HUB_EVENTS_URL=https://demo.example.com/events/
# VITE_HUB_ACTIVITIES_URL=https://demo.example.com/activities/
# VITE_HUB_LIBRA_URL=https://demo.example.com/libra/
# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/
# VITE_HUB_CHAT_URL=https://demo.example.com/chat/
@ -90,11 +84,11 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
# VITE_HUB_RESTAURANT_URL=https://demo.example.com/restaurant/
#
# In SUBDOMAIN-MODE production:
# VITE_HUB_EVENTS_URL=https://sortir.example.com
# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com
# VITE_HUB_LIBRA_URL=https://libra.example.com
# ...etc
# ───────────────────────────────────────────────────────────────────────
VITE_HUB_EVENTS_URL=
VITE_HUB_ACTIVITIES_URL=
VITE_HUB_LIBRA_URL=
VITE_HUB_WALLET_URL=
VITE_HUB_CHAT_URL=

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,278 @@
<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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,27 @@
<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

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

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { Map } from 'lucide-vue-next'
import { 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

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

View file

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

View file

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

View file

@ -152,7 +152,7 @@ onMounted(async () => {
</div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
<p class="text-muted-foreground mb-4">You haven't purchased any tickets yet</p>
<Button @click="$router.push('/events')">Browse Events</Button>
<Button @click="$router.push('/activities')">Browse Activities</Button>
</div>
<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 QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner'
import { useEventDetail } from '../composables/useEventDetail'
import { useActivityDetail } from '../composables/useActivityDetail'
const route = useRoute()
const router = useRouter()
const eventId = ref(route.params.eventId as string)
const { event } = useEventDetail(eventId.value)
const activityId = ref(route.params.activityId as string)
const { activity } = useActivityDetail(activityId.value)
const {
isProcessing,
@ -35,7 +35,7 @@ const {
refreshStats,
onDecode,
resume,
} = useTicketScanner(eventId)
} = useTicketScanner(activityId)
const scannerOpen = ref(true)
const activeTab = ref<'scanner' | 'list'>('scanner')
@ -53,10 +53,10 @@ const lastScanVariant = computed(() => {
}
})
// Backend-authoritative roster. Falls back to the event nostr
// Backend-authoritative roster. Falls back to the activity nostr
// event's `tickets_sold` tag if the RPC hasn't completed yet.
const soldCount = computed(
() => eventStats.value?.sold ?? event.value?.ticketInfo?.sold,
() => eventStats.value?.sold ?? activity.value?.ticketInfo?.sold,
)
const registeredCount = computed(() => eventStats.value?.registered ?? 0)
const remainingCount = computed(() => {
@ -78,7 +78,7 @@ function handleResult(qrText: string) {
function goBack() {
if (window.history.length > 1) router.back()
else router.push({ name: 'event-detail', params: { id: eventId.value } })
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
}
function fmtTime(iso: string) {
@ -111,8 +111,8 @@ function fmtTime(iso: string) {
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
<p v-if="event" class="text-sm text-muted-foreground mb-4">
{{ event.title }}
<p v-if="activity" class="text-sm text-muted-foreground mb-4">
{{ activity.title }}
</p>
<!-- Counts strip backend-authoritative. Source: the

View file

@ -1,27 +0,0 @@
<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

@ -1,55 +0,0 @@
<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

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

View file

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

View file

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

View file

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