refactor(events): rename activities module to events + wire VITE_APP_NAME for per-deployment branding #94
70 changed files with 781 additions and 1057 deletions
refactor(events): rename activities module to events
The module name "activities" was originally chosen to avoid colliding
with Nostr's `Event` type. In practice the defense added friction without
preventing confusion: the backend extension is named `events`, NIP-52
calls them "Calendar Events", and the UI already displayed "Events".
The collision with `nostr-tools` `Event` is handled cleanly by the
existing `import { Event as NostrEvent }` alias pattern (already in 5
files inside the module).
Renames `src/modules/activities` → `src/modules/events` and
`src/activities-app` → `src/events-app`. Touches:
- Module dir + 48 internal files (types, services, composables, views,
components, store) renamed with case-sensitive replacement
(`Activity`→`Event`, `Activities`→`Events`).
- Route paths: `/activities/*` → `/events/*`. The existing legacy
`/events` route (ticketing management view) moves to `/my-events`,
freeing `/events` for the canonical feed/discovery page. A new
"My Events" entry is added to the user dropdown menu for access.
- Standalone PWA entry: `activities.html` → `events.html`,
`vite.activities.config.ts` → `vite.events.config.ts`.
- npm scripts: `dev:activities`/`build:activities`/`preview:activities`
→ `:events`. `build:demo` and `dev:all` updated accordingly.
- DI tokens: `SERVICE_TOKENS.ACTIVITIES_*` → `EVENTS_*`.
- Hub config: `appConfig.modules.activities` → `modules.events`.
- i18n: `activities.*` namespace → `events.*` in en/fr/es locales;
English domain strings updated ("Activities"→"Events", "Search
activities..."→"Search events..."). French/Spanish display values
realigned to "Événements"/"Eventos" at the title key; deeper
translation cleanup left for a follow-up.
- Hub icon label: "Activities" → "Events"; env key
`VITE_HUB_ACTIVITIES_URL` → `VITE_HUB_EVENTS_URL` (also updated in
`.env.example`).
- Stale `CreateActivityDialog.vue` removed (only referenced from a
defunct comment; `CreateEventDialog.vue` is the live one).
Build-output dir renamed `dist-activities/` → `dist-events/`; the
`build:events` npm script reflects the new name. server-deploy still
hardcodes `buildScript = "build:activities"` and `distDir =
"dist-activities"` in `modules/services/standalones.nix:46-47`; a
matching update there needs to land before this branch's webapp commit
gets a flake-input bump.
`Event` naming inside the module no longer collides with the Nostr
`Event` import — those 5 files already use the `NostrEvent` alias.
Disambiguation inside `EventsNostrService.queryCalendarEvents` was
needed: local `events`/`event` variables for the domain side, with
`nostrEvent`/`nostrEvents` for the protocol side.
vue-tsc passes. PWA manifest still hardcodes "Sortir" branding; that
is templated through `VITE_APP_NAME` in a follow-up commit on the
same branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit
131eef88ec
|
|
@ -64,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_ACTIVITIES_URL=http://localhost:5181
|
||||
# VITE_HUB_EVENTS_URL=http://localhost:5181
|
||||
# VITE_HUB_LIBRA_URL=http://localhost:5180
|
||||
# VITE_HUB_WALLET_URL=http://localhost:5182
|
||||
# VITE_HUB_CHAT_URL=http://localhost:5183
|
||||
|
|
@ -74,7 +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_ACTIVITIES_URL=https://demo.example.com/activities/
|
||||
# VITE_HUB_EVENTS_URL=https://demo.example.com/events/
|
||||
# VITE_HUB_LIBRA_URL=https://demo.example.com/libra/
|
||||
# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/
|
||||
# VITE_HUB_CHAT_URL=https://demo.example.com/chat/
|
||||
|
|
@ -84,11 +84,11 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
|
|||
# VITE_HUB_RESTAURANT_URL=https://demo.example.com/restaurant/
|
||||
#
|
||||
# In SUBDOMAIN-MODE production:
|
||||
# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com
|
||||
# VITE_HUB_EVENTS_URL=https://sortir.example.com
|
||||
# VITE_HUB_LIBRA_URL=https://libra.example.com
|
||||
# ...etc
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
VITE_HUB_ACTIVITIES_URL=
|
||||
VITE_HUB_EVENTS_URL=
|
||||
VITE_HUB_LIBRA_URL=
|
||||
VITE_HUB_WALLET_URL=
|
||||
VITE_HUB_CHAT_URL=
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@
|
|||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Sortir — Activités</title>
|
||||
<title>Sortir — Events</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">
|
||||
<meta name="description" content="Découvrez les événements près de chez vous">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/activities-app/main.ts"></script>
|
||||
<script type="module" src="/src/events-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
package.json
10
package.json
|
|
@ -9,9 +9,9 @@
|
|||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview --host",
|
||||
"analyze": "vite build --mode analyze",
|
||||
"dev:activities": "vite --host --config vite.activities.config.ts",
|
||||
"build:activities": "vue-tsc -b && vite build --config vite.activities.config.ts",
|
||||
"preview:activities": "vite preview --host --config vite.activities.config.ts",
|
||||
"dev:events": "vite --host --config vite.events.config.ts",
|
||||
"build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
|
||||
"preview:events": "vite preview --host --config vite.events.config.ts",
|
||||
"dev:libra": "vite --host --config vite.libra.config.ts",
|
||||
"build:libra": "vue-tsc -b && vite build --config vite.libra.config.ts",
|
||||
"preview:libra": "vite preview --host --config vite.libra.config.ts",
|
||||
|
|
@ -33,8 +33,8 @@
|
|||
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
|
||||
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
|
||||
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
|
||||
"dev:all": "concurrently -n hub,libra,sortir,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
|
||||
"build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
|
||||
"dev:all": "concurrently -n hub,libra,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",
|
||||
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
||||
"electron:package": "electron-builder",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { AppConfig } from './core/types'
|
|||
/**
|
||||
* Minimal AIO hub configuration.
|
||||
* The all-in-one app at app.${domain} ships only the base module —
|
||||
* each feature module (wallet, chat, market, tasks, forum, activities,
|
||||
* each feature module (wallet, chat, market, tasks, forum, events,
|
||||
* libra) is now its own standalone PWA at its own subdomain.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/rou
|
|||
*
|
||||
* The all-in-one app at app.${domain} now ships only the base module
|
||||
* plus a chakra icon hub linking out to the standalone module apps
|
||||
* (wallet, chat, market, tasks, forum, activities, libra).
|
||||
* (wallet, chat, market, tasks, forum, events, libra).
|
||||
*/
|
||||
export async function createAppInstance() {
|
||||
console.log('🚀 Starting AIO hub...')
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function useModularNavigation() {
|
|||
items.push({ name: t('nav.home'), href: '/', requiresAuth: true })
|
||||
|
||||
// Add navigation items based on enabled modules
|
||||
if (appConfig.modules.activities?.enabled) {
|
||||
if (appConfig.modules.events?.enabled) {
|
||||
items.push({
|
||||
name: t('nav.events'),
|
||||
href: '/events',
|
||||
|
|
@ -67,14 +67,20 @@ export function useModularNavigation() {
|
|||
const userMenuItems = computed<NavigationItem[]>(() => {
|
||||
const items: NavigationItem[] = []
|
||||
|
||||
// Activities module items (events + tickets)
|
||||
if (appConfig.modules.activities?.enabled) {
|
||||
// Events module items (tickets + my events)
|
||||
if (appConfig.modules.events?.enabled) {
|
||||
items.push({
|
||||
name: 'My Tickets',
|
||||
href: '/my-tickets',
|
||||
icon: 'Ticket',
|
||||
requiresAuth: true
|
||||
})
|
||||
items.push({
|
||||
name: 'My Events',
|
||||
href: '/my-events',
|
||||
icon: 'CalendarPlus',
|
||||
requiresAuth: true
|
||||
})
|
||||
}
|
||||
|
||||
// Market module items
|
||||
|
|
|
|||
|
|
@ -147,9 +147,9 @@ export const SERVICE_TOKENS = {
|
|||
// Nostr transport (kind-21000 RPC over relays — LNbits backend)
|
||||
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),
|
||||
|
||||
// Activities services (Nostr-native events + ticketing module)
|
||||
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
||||
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
|
||||
// Events services (Nostr-native NIP-52 calendar events + LNbits ticketing)
|
||||
EVENTS_NOSTR_SERVICE: Symbol('eventsNostrService'),
|
||||
EVENTS_TICKET_API: Symbol('eventsTicketApi'),
|
||||
TICKET_API: Symbol('ticketApi'),
|
||||
|
||||
// Invoice services
|
||||
|
|
|
|||
|
|
@ -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 { useActivitiesStore } from '@/modules/activities/stores/activities'
|
||||
import { useActivities } from '@/modules/activities/composables/useActivities'
|
||||
import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
|
||||
import { useEventsStore } from '@/modules/events/stores/events'
|
||||
import { useEvents } from '@/modules/events/composables/useEvents'
|
||||
import { useApprovalState } from '@/modules/events/composables/useApprovalState'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
||||
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
|
||||
import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue'
|
||||
import type { TicketApiService } from '@/modules/events/services/TicketApiService'
|
||||
import type { CreateEventRequest } from '@/modules/events/types/ticket'
|
||||
import CreateEventDialog from '@/modules/events/components/CreateEventDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const activitiesStore = useActivitiesStore()
|
||||
const eventsStore = useEventsStore()
|
||||
const { isAdmin, autoApprove } = useApprovalState()
|
||||
// Used to merge own LNbits drafts into the activities feed right after
|
||||
// Used to merge own LNbits drafts into the events feed right after
|
||||
// the user creates or edits an event — otherwise the new draft only
|
||||
// surfaces on the next ActivitiesPage subscribe cycle.
|
||||
const { loadOwnEvents } = useActivities()
|
||||
// surfaces on the next EventsPage subscribe cycle.
|
||||
const { loadOwnEvents } = useEvents()
|
||||
|
||||
// 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('activities.nav.feed'), icon: Search, path: '/activities' },
|
||||
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
|
||||
{ name: t('events.nav.feed'), icon: Search, path: '/events' },
|
||||
{ name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' },
|
||||
{
|
||||
name: t('activities.createNew'),
|
||||
name: t('events.createNew'),
|
||||
icon: Plus,
|
||||
onClick: () => {
|
||||
if (!isAuthenticated.value) {
|
||||
toast.info('Log in to create an activity', {
|
||||
toast.info('Log in to create an event', {
|
||||
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.
|
||||
activitiesStore.editingEvent = null
|
||||
activitiesStore.showCreateDialog = true
|
||||
eventsStore.editingEvent = null
|
||||
eventsStore.showCreateDialog = true
|
||||
},
|
||||
disabled: !isAuthenticated.value,
|
||||
},
|
||||
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
||||
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
||||
{
|
||||
name: t('activities.nav.favorites'),
|
||||
name: t('events.nav.favorites'),
|
||||
icon: Heart,
|
||||
// path kept so the tab stays active-highlighted while the user is
|
||||
// on /activities/favorites; onClick wins for the actual tap so we
|
||||
// on /events/favorites; onClick wins for the actual tap so we
|
||||
// can gate on auth (mirrors the Create tab pattern above).
|
||||
path: '/activities/favorites',
|
||||
path: '/events/favorites',
|
||||
onClick: () => {
|
||||
if (!isAuthenticated.value) {
|
||||
toast.info(t('activities.favorites.loginPrompt'), {
|
||||
toast.info(t('events.favorites.loginPrompt'), {
|
||||
action: {
|
||||
label: t('activities.favorites.logIn'),
|
||||
label: t('events.favorites.logIn'),
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push('/activities/favorites')
|
||||
router.push('/events/favorites')
|
||||
},
|
||||
disabled: !isAuthenticated.value,
|
||||
},
|
||||
])
|
||||
|
||||
// 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).
|
||||
// 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).
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/activities') {
|
||||
if (path === '/events') {
|
||||
return (
|
||||
route.path === '/activities' ||
|
||||
(route.path.startsWith('/activities/') &&
|
||||
!route.path.startsWith('/activities/calendar') &&
|
||||
!route.path.startsWith('/activities/map') &&
|
||||
!route.path.startsWith('/activities/favorites'))
|
||||
route.path === '/events' ||
|
||||
(route.path.startsWith('/events/') &&
|
||||
!route.path.startsWith('/events/calendar') &&
|
||||
!route.path.startsWith('/events/map') &&
|
||||
!route.path.startsWith('/events/favorites'))
|
||||
)
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
// Dialog mount lives at shell level so the Create tab works from any route
|
||||
// within the activities standalone, not just /activities.
|
||||
// within the events standalone, not just /events.
|
||||
async function handleCreateEvent(eventData: CreateEventRequest) {
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
||||
|
|
@ -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 === activitiesStore.editingEvent?.wallet,
|
||||
(w) => w.id === eventsStore.editingEvent?.wallet,
|
||||
)
|
||||
const adminKey = wallet?.adminkey
|
||||
if (!adminKey) {
|
||||
|
|
@ -115,18 +115,18 @@ async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest)
|
|||
}
|
||||
|
||||
function handleDialogOpenChange(open: boolean) {
|
||||
activitiesStore.showCreateDialog = open
|
||||
eventsStore.showCreateDialog = open
|
||||
// Closing always clears the edit selection so the next "+ Create"
|
||||
// opens clean instead of inheriting the last-edited event.
|
||||
if (!open) activitiesStore.editingEvent = null
|
||||
if (!open) eventsStore.editingEvent = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive">
|
||||
<CreateEventDialog
|
||||
:open="activitiesStore.showCreateDialog"
|
||||
:event="activitiesStore.editingEvent"
|
||||
:open="eventsStore.showCreateDialog"
|
||||
:event="eventsStore.editingEvent"
|
||||
:is-admin="isAdmin"
|
||||
:auto-approve="autoApprove"
|
||||
:on-create-event="handleCreateEvent"
|
||||
|
|
@ -8,8 +8,8 @@ function parseMapCenter(envValue: string | undefined, fallback: { lat: number; l
|
|||
}
|
||||
|
||||
/**
|
||||
* Standalone activities app configuration.
|
||||
* Only enables base + activities modules.
|
||||
* Standalone events app configuration.
|
||||
* Only enables base + events modules.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
|
|
@ -34,8 +34,8 @@ export const appConfig: AppConfig = {
|
|||
}
|
||||
}
|
||||
},
|
||||
activities: {
|
||||
name: 'activities',
|
||||
events: {
|
||||
name: 'events',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
|
|
@ -7,7 +7,7 @@ import { container } from '@/core/di-container'
|
|||
|
||||
import appConfig from './app.config'
|
||||
import baseModule from '@/modules/base'
|
||||
import activitiesModule from '@/modules/activities'
|
||||
import eventsModule from '@/modules/events'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
|
|
@ -17,10 +17,10 @@ import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/rou
|
|||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
/**
|
||||
* Initialize the standalone activities app
|
||||
* Initialize the standalone events app
|
||||
*/
|
||||
export async function createAppInstance() {
|
||||
console.log('🚀 Starting Sortir — Activities App...')
|
||||
console.log('🚀 Starting Sortir — Events App...')
|
||||
|
||||
// Accept token from URL before anything else (cross-subdomain auth relay)
|
||||
acceptTokenFromUrl('Sortir')
|
||||
|
|
@ -30,16 +30,16 @@ export async function createAppInstance() {
|
|||
// Collect routes from enabled modules only
|
||||
const moduleRoutes = [
|
||||
...baseModule.routes || [],
|
||||
...activitiesModule.routes || [],
|
||||
...eventsModule.routes || [],
|
||||
].filter(Boolean)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
// Activities page is the home page in standalone mode
|
||||
// Events page is the home page in standalone mode
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/activities'
|
||||
redirect: '/events'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
|
|
@ -87,9 +87,9 @@ export async function createAppInstance() {
|
|||
)
|
||||
}
|
||||
|
||||
if (appConfig.modules.activities?.enabled) {
|
||||
if (appConfig.modules.events?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(activitiesModule, appConfig.modules.activities)
|
||||
pluginManager.register(eventsModule, appConfig.modules.events)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -35,27 +35,27 @@ async function handleLogout() {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('activities.settings.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('events.settings.title') }}</h1>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.account') }}</h2>
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.account') }}</h2>
|
||||
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p class="text-sm text-foreground font-mono truncate">
|
||||
{{ userPubkey }}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" class="w-full gap-2" @click="handleLogout">
|
||||
<LogOut class="w-4 h-4" />
|
||||
{{ t('activities.settings.logOut') }}
|
||||
{{ t('events.settings.logOut') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="bg-muted/50 rounded-lg p-4">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
{{ t('activities.settings.loginPrompt') }}
|
||||
{{ t('events.settings.loginPrompt') }}
|
||||
</p>
|
||||
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
{{ t('activities.settings.logIn') }}
|
||||
{{ t('events.settings.logIn') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -64,9 +64,9 @@ async function handleLogout() {
|
|||
|
||||
<!-- Appearance -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.appearance') }}</h2>
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.appearance') }}</h2>
|
||||
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
|
||||
<span class="text-sm text-foreground">{{ t('activities.settings.theme') }}</span>
|
||||
<span class="text-sm text-foreground">{{ t('events.settings.theme') }}</span>
|
||||
<Button variant="outline" size="icon" class="h-8 w-8" @click="toggleTheme">
|
||||
<Sun v-if="theme === 'dark'" class="w-4 h-4" />
|
||||
<Moon v-else class="w-4 h-4" />
|
||||
|
|
@ -78,7 +78,7 @@ async function handleLogout() {
|
|||
|
||||
<!-- Language -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.language') }}</h2>
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.language') }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-for="lang in languages"
|
||||
|
|
@ -9,7 +9,6 @@ const messages: LocaleMessages = {
|
|||
events: 'Events',
|
||||
market: 'Market',
|
||||
chat: 'Chat',
|
||||
activities: 'Activities',
|
||||
login: 'Login',
|
||||
logout: 'Logout'
|
||||
},
|
||||
|
|
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
|
|||
de: 'German',
|
||||
zh: 'Chinese'
|
||||
},
|
||||
activities: {
|
||||
title: 'Activities',
|
||||
createNew: 'Create Activity',
|
||||
noActivities: 'No activities found',
|
||||
events: {
|
||||
title: 'Events',
|
||||
createNew: 'Create Event',
|
||||
noEvents: 'No events found',
|
||||
filters: {
|
||||
all: 'All',
|
||||
today: 'Today',
|
||||
|
|
@ -135,20 +134,20 @@ const messages: LocaleMessages = {
|
|||
settings: 'Settings',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Search activities...',
|
||||
noResults: 'No activities found',
|
||||
placeholder: 'Search events...',
|
||||
noResults: 'No events found',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
loginPrompt: 'Log in to save your favorite activities',
|
||||
loginPrompt: 'Log in to save your favorite events',
|
||||
empty: 'No favorites yet',
|
||||
emptyHint: 'Tap the heart icon on any activity to save it here',
|
||||
emptyHint: 'Tap the heart icon on any event to save it here',
|
||||
logIn: 'Log in',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
account: 'Account',
|
||||
loginPrompt: 'Log in to bookmark activities, RSVP, and purchase tickets.',
|
||||
loginPrompt: 'Log in to bookmark events, RSVP, and purchase tickets.',
|
||||
logIn: 'Log in',
|
||||
logOut: 'Log out',
|
||||
appearance: 'Appearance',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ const messages: LocaleMessages = {
|
|||
events: 'Eventos',
|
||||
market: 'Mercado',
|
||||
chat: 'Chat',
|
||||
activities: 'Actividades',
|
||||
login: 'Iniciar Sesión',
|
||||
logout: 'Cerrar Sesión'
|
||||
},
|
||||
|
|
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
|
|||
de: 'Alemán',
|
||||
zh: 'Chino'
|
||||
},
|
||||
activities: {
|
||||
title: 'Actividades',
|
||||
createNew: 'Crear actividad',
|
||||
noActivities: 'No se encontraron actividades',
|
||||
events: {
|
||||
title: 'Eventos',
|
||||
createNew: 'Crear evento',
|
||||
noEvents: 'No se encontraron eventos',
|
||||
filters: {
|
||||
all: 'Todas',
|
||||
today: 'Hoy',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ const messages: LocaleMessages = {
|
|||
events: 'Événements',
|
||||
market: 'Marché',
|
||||
chat: 'Chat',
|
||||
activities: 'Activités',
|
||||
login: 'Connexion',
|
||||
logout: 'Déconnexion'
|
||||
},
|
||||
|
|
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
|
|||
de: 'Allemand',
|
||||
zh: 'Chinois'
|
||||
},
|
||||
activities: {
|
||||
title: 'Activités',
|
||||
createNew: 'Créer une activité',
|
||||
noActivities: 'Aucune activité trouvée',
|
||||
events: {
|
||||
title: 'Événements',
|
||||
createNew: 'Créer un événement',
|
||||
noEvents: 'Aucun événement trouvé',
|
||||
filters: {
|
||||
all: 'Tout',
|
||||
today: "Aujourd'hui",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ export interface LocaleMessages {
|
|||
events: string
|
||||
market: string
|
||||
chat: string
|
||||
activities: string
|
||||
login: string
|
||||
logout: string
|
||||
}
|
||||
|
|
@ -55,11 +54,11 @@ export interface LocaleMessages {
|
|||
de: string
|
||||
zh: string
|
||||
}
|
||||
// Activities module
|
||||
activities?: {
|
||||
// Events module
|
||||
events?: {
|
||||
title: string
|
||||
createNew: string
|
||||
noActivities: string
|
||||
noEvents: string
|
||||
filters: {
|
||||
all: string
|
||||
today: string
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
FormControl, FormField, FormItem, FormLabel, FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CalendarPlus } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { CreateEventRequest } from '../types/ticket'
|
||||
import type { ActivityCategory } from '../types/category'
|
||||
import CategorySelector from './CategorySelector.vue'
|
||||
import LocationPicker from './LocationPicker.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:isOpen': [value: boolean]
|
||||
'created': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { currentUser } = useAuth()
|
||||
|
||||
const isPublishing = ref(false)
|
||||
const selectedCategories = ref<ActivityCategory[]>([])
|
||||
const location = ref('')
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
summary: z.string().max(500).optional(),
|
||||
description: z.string().min(1, 'Description is required').max(5000),
|
||||
startDate: z.string().min(1, 'Start date is required'),
|
||||
startTime: z.string().min(1, 'Start time is required'),
|
||||
endDate: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
image: z.string().url('Must be a valid URL').optional().or(z.literal('')),
|
||||
}))
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
title: '',
|
||||
summary: '',
|
||||
description: '',
|
||||
startDate: '',
|
||||
startTime: '',
|
||||
endDate: '',
|
||||
endTime: '',
|
||||
image: '',
|
||||
},
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => form.meta.value.valid)
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
||||
if (!ticketApi) {
|
||||
toast.error('Activities service not available')
|
||||
return
|
||||
}
|
||||
|
||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
||||
if (!invoiceKey) {
|
||||
toast.error('No wallet available. Please log in first.')
|
||||
return
|
||||
}
|
||||
|
||||
isPublishing.value = true
|
||||
|
||||
try {
|
||||
// Compose ISO 8601 datetime strings the events extension parses.
|
||||
const startIso = `${values.startDate}T${values.startTime}`
|
||||
const endIso =
|
||||
values.endDate && values.endTime
|
||||
? `${values.endDate}T${values.endTime}`
|
||||
: undefined
|
||||
|
||||
// Fold summary + description into `info` since the events extension
|
||||
// CreateEventRequest has no separate summary field.
|
||||
const info =
|
||||
values.summary && values.description
|
||||
? `${values.summary}\n\n${values.description}`
|
||||
: values.description || values.summary || ''
|
||||
|
||||
// Ticket-less activity — amount_tickets and price_per_ticket both
|
||||
// pinned at 0 (events extension treats 0 as "unlimited / not
|
||||
// ticketed" per models.py:45-46). Server-side `signer.sign_event`
|
||||
// produces the kind-31922 calendar event and publishes via the
|
||||
// operator's configured relays — no webapp signing path needed.
|
||||
const eventData: CreateEventRequest = {
|
||||
name: values.title,
|
||||
info,
|
||||
event_start_date: startIso,
|
||||
event_end_date: endIso,
|
||||
location: location.value || null,
|
||||
banner: values.image || null,
|
||||
categories: selectedCategories.value,
|
||||
amount_tickets: 0,
|
||||
price_per_ticket: 0,
|
||||
}
|
||||
|
||||
await ticketApi.createEvent(eventData, invoiceKey)
|
||||
|
||||
// Approval workflow caveat: non-admin users on instances with
|
||||
// `auto_approve=false` (the default) land in the proposal queue;
|
||||
// their event isn't published to relays until an admin approves.
|
||||
// Admins-and-auto-approve-on instances publish immediately.
|
||||
toast.success('Activity created!')
|
||||
emit('created')
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to create activity:', err)
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create activity')
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:isOpen', false)
|
||||
form.resetForm()
|
||||
selectedCategories.value = []
|
||||
location.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="handleClose">
|
||||
<DialogContent class="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<CalendarPlus class="w-5 h-5" />
|
||||
{{ t('activities.createNew') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Publish a new activity to Nostr relays
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit="onSubmit" class="space-y-4 py-2">
|
||||
<!-- Title -->
|
||||
<FormField v-slot="{ componentField }" name="title">
|
||||
<FormItem>
|
||||
<FormLabel>Title *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Marché de Noël de Foix" v-bind="componentField" :disabled="isPublishing" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Summary -->
|
||||
<FormField v-slot="{ componentField }" name="summary">
|
||||
<FormItem>
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Brief one-line description" v-bind="componentField" :disabled="isPublishing" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Description -->
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Description *</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Full details about the activity..."
|
||||
v-bind="componentField"
|
||||
:disabled="isPublishing"
|
||||
rows="4"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Start date/time -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<FormField v-slot="{ componentField }" name="startDate">
|
||||
<FormItem>
|
||||
<FormLabel>Start date *</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" v-bind="componentField" :disabled="isPublishing" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="startTime">
|
||||
<FormItem>
|
||||
<FormLabel>Start time *</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="time" v-bind="componentField" :disabled="isPublishing" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- End date/time -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<FormField v-slot="{ componentField }" name="endDate">
|
||||
<FormItem>
|
||||
<FormLabel>End date</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" v-bind="componentField" :disabled="isPublishing" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="endTime">
|
||||
<FormItem>
|
||||
<FormLabel>End time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="time" v-bind="componentField" :disabled="isPublishing" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<LocationPicker
|
||||
v-model="location"
|
||||
:disabled="isPublishing"
|
||||
/>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium">Categories</label>
|
||||
<CategorySelector v-model="selectedCategories" />
|
||||
</div>
|
||||
|
||||
<!-- Image URL -->
|
||||
<FormField v-slot="{ componentField }" name="image">
|
||||
<FormItem>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/image.jpg"
|
||||
v-bind="componentField"
|
||||
:disabled="isPublishing"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Submit -->
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isPublishing || !isFormValid"
|
||||
class="w-full"
|
||||
>
|
||||
<span v-if="isPublishing" class="animate-spin mr-2">⚡</span>
|
||||
{{ isPublishing ? 'Publishing...' : 'Publish Activity' }}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useActivities } from '../composables/useActivities'
|
||||
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
|
||||
import type { Activity } from '../types/activity'
|
||||
|
||||
const router = useRouter()
|
||||
const { allActivities, subscribe } = useActivities()
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
})
|
||||
|
||||
function handleSelectActivity(activity: Activity) {
|
||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<ActivityCalendarView
|
||||
:activities="allActivities"
|
||||
@select-activity="handleSelectActivity"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { Map } from 'lucide-vue-next'
|
||||
import { useActivities } from '../composables/useActivities'
|
||||
import ActivityMap from '../components/ActivityMap.vue'
|
||||
|
||||
const { allActivities, isLoading, subscribe } = useActivities()
|
||||
|
||||
function parseMapCenter(): { lat: number; lng: number } | undefined {
|
||||
const raw = import.meta.env.VITE_DEFAULT_MAP_CENTER
|
||||
if (!raw) return undefined
|
||||
const [lat, lng] = raw.split(',').map(Number)
|
||||
if (isNaN(lat) || isNaN(lng)) return undefined
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
const mapCenter = parseMapCenter()
|
||||
|
||||
const geoActivities = computed(() =>
|
||||
allActivities.value.filter(a => a.coordinates)
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-[calc(100dvh-3.5rem)]">
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="isLoading && geoActivities.length === 0" class="flex-1 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
|
||||
<!-- No geotagged activities -->
|
||||
<div v-else-if="!isLoading && geoActivities.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<p class="text-muted-foreground">No geotagged activities found</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">Activities with location data will appear as markers on the map</p>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<ActivityMap
|
||||
v-else
|
||||
:activities="geoActivities"
|
||||
:center="mapCenter"
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<!-- Activity count -->
|
||||
<div v-if="geoActivities.length > 0" class="px-4 py-2 text-xs text-muted-foreground border-t bg-background">
|
||||
{{ geoActivities.length }} activit{{ geoActivities.length === 1 ? 'y' : 'ies' }} on map
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -18,8 +18,8 @@ const router = useRouter()
|
|||
const { isAuthenticated } = useAuth()
|
||||
const { isBookmarked, toggleBookmark } = useBookmarks()
|
||||
|
||||
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||
const bookmarked = computed(() => isBookmarked(activityKind.value, props.pubkey, props.dTag))
|
||||
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
|
||||
|
||||
function handleToggle() {
|
||||
if (!isAuthenticated.value) {
|
||||
|
|
@ -31,7 +31,7 @@ function handleToggle() {
|
|||
})
|
||||
return
|
||||
}
|
||||
toggleBookmark(activityKind.value, props.pubkey, props.dTag)
|
||||
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -3,22 +3,22 @@ import { useI18n } from 'vue-i18n'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import type { ActivityCategory } from '../types/category'
|
||||
import type { EventCategory } from '../types/category'
|
||||
import { ALL_CATEGORIES } from '../types/category'
|
||||
|
||||
const props = defineProps<{
|
||||
selected: ActivityCategory[]
|
||||
selected: EventCategory[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [category: ActivityCategory]
|
||||
toggle: [category: EventCategory]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function categoryLabel(cat: ActivityCategory): string {
|
||||
return t(`activities.categories.${cat}`, cat)
|
||||
function categoryLabel(cat: EventCategory): string {
|
||||
return t(`events.categories.${cat}`, cat)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { ActivityCategory } from '../types/category'
|
||||
import type { EventCategory } from '../types/category'
|
||||
import { ALL_CATEGORIES } from '../types/category'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ActivityCategory[]
|
||||
modelValue: EventCategory[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ActivityCategory[]]
|
||||
'update:modelValue': [value: EventCategory[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function toggle(cat: ActivityCategory) {
|
||||
function toggle(cat: EventCategory) {
|
||||
const current = [...props.modelValue]
|
||||
const idx = current.indexOf(cat)
|
||||
if (idx >= 0) {
|
||||
|
|
@ -25,8 +25,8 @@ function toggle(cat: ActivityCategory) {
|
|||
emit('update:modelValue', current)
|
||||
}
|
||||
|
||||
function label(cat: ActivityCategory): string {
|
||||
return t(`activities.categories.${cat}`, cat)
|
||||
function label(cat: EventCategory): string {
|
||||
return t(`events.categories.${cat}`, cat)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -577,7 +577,7 @@ const handleOpenChange = (open: boolean) => {
|
|||
class="cursor-pointer text-xs capitalize"
|
||||
@click="toggleCategory(cat)"
|
||||
>
|
||||
{{ t(`activities.categories.${cat}`, cat) }}
|
||||
{{ t(`events.categories.${cat}`, cat) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -8,15 +8,15 @@ import {
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { useDateLocale } from '../composables/useDateLocale'
|
||||
import type { Activity } from '../types/activity'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const props = defineProps<{
|
||||
activities: Activity[]
|
||||
events: Event[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectDate: [date: Date]
|
||||
selectActivity: [activity: Activity]
|
||||
selectEvent: [event: Event]
|
||||
}>()
|
||||
|
||||
const { dateLocale } = useDateLocale()
|
||||
|
|
@ -47,31 +47,31 @@ const calendarDays = computed(() => {
|
|||
return eachDayOfInterval({ start: calStart, end: calEnd })
|
||||
})
|
||||
|
||||
// Map of date string -> activities on that day
|
||||
const activityDayMap = computed(() => {
|
||||
const map = new Map<string, Activity[]>()
|
||||
for (const activity of props.activities) {
|
||||
if (!activity.startDate || isNaN(activity.startDate.getTime())) continue
|
||||
const key = format(activity.startDate, 'yyyy-MM-dd')
|
||||
// Map of date string -> events on that day
|
||||
const eventDayMap = computed(() => {
|
||||
const map = new Map<string, Event[]>()
|
||||
for (const event of props.events) {
|
||||
if (!event.startDate || isNaN(event.startDate.getTime())) continue
|
||||
const key = format(event.startDate, 'yyyy-MM-dd')
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(activity)
|
||||
map.get(key)!.push(event)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getActivitiesForDay(date: Date): Activity[] {
|
||||
function getEventsForDay(date: Date): Event[] {
|
||||
const key = format(date, 'yyyy-MM-dd')
|
||||
return activityDayMap.value.get(key) ?? []
|
||||
return eventDayMap.value.get(key) ?? []
|
||||
}
|
||||
|
||||
function getDotCount(date: Date): number {
|
||||
return Math.min(getActivitiesForDay(date).length, 3)
|
||||
return Math.min(getEventsForDay(date).length, 3)
|
||||
}
|
||||
|
||||
const selectedDay = ref<Date | null>(null)
|
||||
const selectedDayActivities = computed(() => {
|
||||
const selectedDayEvents = computed(() => {
|
||||
if (!selectedDay.value) return []
|
||||
return getActivitiesForDay(selectedDay.value)
|
||||
return getEventsForDay(selectedDay.value)
|
||||
})
|
||||
|
||||
function selectDay(date: Date) {
|
||||
|
|
@ -133,7 +133,7 @@ function nextMonth() {
|
|||
@click="selectDay(date)"
|
||||
>
|
||||
<span class="text-sm">{{ format(date, 'd') }}</span>
|
||||
<!-- Activity dots -->
|
||||
<!-- Event dots -->
|
||||
<div v-if="getDotCount(date) > 0" class="flex gap-0.5 mt-0.5">
|
||||
<div
|
||||
v-for="i in getDotCount(date)"
|
||||
|
|
@ -145,36 +145,36 @@ function nextMonth() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected day activities -->
|
||||
<!-- Selected day events -->
|
||||
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
||||
<h3 class="text-sm font-medium text-muted-foreground">
|
||||
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
|
||||
<span v-if="selectedDayActivities.length > 0" class="ml-1">
|
||||
({{ selectedDayActivities.length }})
|
||||
<span v-if="selectedDayEvents.length > 0" class="ml-1">
|
||||
({{ selectedDayEvents.length }})
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="selectedDayActivities.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
|
||||
No activities on this day
|
||||
<div v-if="selectedDayEvents.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
|
||||
No events on this day
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="activity in selectedDayActivities"
|
||||
:key="activity.nostrEventId"
|
||||
v-for="event in selectedDayEvents"
|
||||
:key="event.nostrEventId"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-muted cursor-pointer"
|
||||
@click="emit('selectActivity', activity)"
|
||||
@click="emit('selectEvent', event)"
|
||||
>
|
||||
<img
|
||||
v-if="activity.image"
|
||||
:src="activity.image"
|
||||
:alt="activity.title"
|
||||
v-if="event.image"
|
||||
:src="event.image"
|
||||
:alt="event.title"
|
||||
class="w-12 h-12 rounded object-cover shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{{ activity.type === 'time' ? format(activity.startDate, 'HH:mm') : '' }}
|
||||
{{ activity.location ? `· ${activity.location}` : '' }}
|
||||
{{ event.type === 'time' ? format(event.startDate, 'HH:mm') : '' }}
|
||||
{{ event.location ? `· ${event.location}` : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 { Activity } from '../types/activity'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const props = defineProps<{
|
||||
activity: Activity
|
||||
event: Event
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [activity: Activity]
|
||||
click: [event: Event]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { dateLocale } = useDateLocale()
|
||||
const { paidCount } = useOwnedTickets()
|
||||
|
||||
const ownedCount = computed(() => paidCount(props.activity.id))
|
||||
const ownedCount = computed(() => paidCount(props.event.id))
|
||||
|
||||
const dateDisplay = computed(() => {
|
||||
const a = props.activity
|
||||
const a = props.event
|
||||
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
|
||||
try {
|
||||
const opts = { locale: dateLocale.value }
|
||||
|
|
@ -41,26 +41,26 @@ const dateDisplay = computed(() => {
|
|||
})
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
if (!props.activity.category) return null
|
||||
return t(`activities.categories.${props.activity.category}`, props.activity.category)
|
||||
if (!props.event.category) return null
|
||||
return t(`events.categories.${props.event.category}`, props.event.category)
|
||||
})
|
||||
|
||||
const priceDisplay = computed(() => {
|
||||
const info = props.activity.ticketInfo
|
||||
const info = props.event.ticketInfo
|
||||
if (!info) return null
|
||||
if (info.price === 0) return t('activities.detail.free')
|
||||
if (info.price === 0) return t('events.detail.free')
|
||||
return `${info.price} ${info.currency}`
|
||||
})
|
||||
|
||||
const placeholderBg = computed(() => {
|
||||
// Generate a consistent hue from the activity title
|
||||
const hash = props.activity.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
// Generate a consistent hue from the event title
|
||||
const hash = props.event.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.activity
|
||||
const a = props.event
|
||||
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', activity)"
|
||||
@click="emit('click', event)"
|
||||
>
|
||||
<!-- Image / Placeholder -->
|
||||
<div class="relative aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
v-if="activity.image"
|
||||
:src="activity.image"
|
||||
:alt="activity.title"
|
||||
v-if="event.image"
|
||||
:src="event.image"
|
||||
:alt="event.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="activity.isMine"
|
||||
v-if="event.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 activity originated from a
|
||||
local LNbits event (Nostr-sourced activities have no
|
||||
drafts. Only present when the event originated from a
|
||||
local LNbits event (Nostr-sourced events have no
|
||||
lnbitsStatus). -->
|
||||
<Badge
|
||||
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
|
||||
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
class="absolute bottom-2 left-2 text-xs capitalize"
|
||||
>
|
||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</Badge>
|
||||
|
||||
<!-- Past badge — shown when the activity has already ended.
|
||||
<!-- Past badge — shown when the event has already ended.
|
||||
Only relevant on the feed when the "Past events" filter
|
||||
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 && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
|
||||
v-if="isPast && !(event.lnbitsStatus && event.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('activities.filters.past', 'Past') }}
|
||||
{{ t('events.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">
|
||||
{{ activity.title }}
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
<BookmarkButton
|
||||
:pubkey="activity.organizer.pubkey"
|
||||
:d-tag="activity.id"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<p
|
||||
v-if="activity.summary"
|
||||
v-if="event.summary"
|
||||
class="text-sm text-muted-foreground line-clamp-2"
|
||||
>
|
||||
{{ activity.summary }}
|
||||
{{ event.summary }}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto space-y-1.5 pt-2">
|
||||
|
|
@ -175,34 +175,34 @@ const isPast = computed(() => {
|
|||
|
||||
<!-- Location -->
|
||||
<div
|
||||
v-if="activity.location"
|
||||
v-if="event.location"
|
||||
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<MapPin class="w-3.5 h-3.5 shrink-0" />
|
||||
<span class="truncate">{{ activity.location }}</span>
|
||||
<span class="truncate">{{ event.location }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tickets available. `available === undefined` means
|
||||
unlimited capacity (no `tickets_available` tag was
|
||||
published); show the price-only line in that case. -->
|
||||
<div
|
||||
v-if="activity.ticketInfo"
|
||||
v-if="event.ticketInfo"
|
||||
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<Ticket class="w-3.5 h-3.5 shrink-0" />
|
||||
<span v-if="activity.ticketInfo.available === undefined">
|
||||
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else-if="activity.ticketInfo.available > 0">
|
||||
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
|
||||
<span v-else-if="event.ticketInfo.available > 0">
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
</span>
|
||||
<span v-else class="text-destructive font-medium">
|
||||
{{ t('activities.detail.soldOut') }}
|
||||
{{ t('events.detail.soldOut') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Owned tickets — shown when the current user holds at
|
||||
least one paid ticket for this activity. Sits next to
|
||||
least one paid ticket for this event. Sits next to
|
||||
the availability line so the buyer can see at a glance
|
||||
whether they've already bought in. -->
|
||||
<div
|
||||
|
|
@ -211,7 +211,7 @@ const isPast = computed(() => {
|
|||
>
|
||||
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
|
||||
{{ t('events.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CalendarSearch } from 'lucide-vue-next'
|
||||
import ActivityCard from './ActivityCard.vue'
|
||||
import type { Activity } from '../types/activity'
|
||||
import EventCard from './EventCard.vue'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
defineProps<{
|
||||
activities: Activity[]
|
||||
events: Event[]
|
||||
isLoading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [activity: Activity]
|
||||
select: [event: Event]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -35,25 +35,25 @@ const { t } = useI18n()
|
|||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="activities.length === 0"
|
||||
v-else-if="events.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 text-center"
|
||||
>
|
||||
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||
<h3 class="text-lg font-medium text-foreground mb-1">
|
||||
{{ t('activities.noActivities') }}
|
||||
{{ t('events.noEvents') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('activities.search.noResults') }}
|
||||
{{ t('events.search.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Activity grid -->
|
||||
<!-- Event grid -->
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ActivityCard
|
||||
v-for="activity in activities"
|
||||
:key="activity.nostrEventId"
|
||||
:activity="activity"
|
||||
@click="emit('select', activity)"
|
||||
<EventCard
|
||||
v-for="event in events"
|
||||
:key="event.nostrEventId"
|
||||
:event="event"
|
||||
@click="emit('select', event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -3,10 +3,10 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import type { Activity } from '../types/activity'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const props = defineProps<{
|
||||
activities: Activity[]
|
||||
events: Event[]
|
||||
center?: { lat: number; lng: number }
|
||||
zoom?: number
|
||||
}>()
|
||||
|
|
@ -54,19 +54,19 @@ function updateMarkers() {
|
|||
|
||||
markerGroup.clearLayers()
|
||||
|
||||
const geoActivities = props.activities.filter(a => a.coordinates)
|
||||
const geoEvents = props.events.filter(a => a.coordinates)
|
||||
|
||||
for (const activity of geoActivities) {
|
||||
const { lat, lng } = activity.coordinates!
|
||||
for (const event of geoEvents) {
|
||||
const { lat, lng } = event.coordinates!
|
||||
|
||||
const marker = L.marker([lat, lng], { icon: defaultIcon })
|
||||
|
||||
const popupContent = `
|
||||
<div style="min-width: 200px; cursor: pointer;" class="activity-popup">
|
||||
${activity.image ? `<img src="${activity.image}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;" />` : ''}
|
||||
<div style="font-weight: 600; font-size: 14px; margin-bottom: 4px;">${escapeHtml(activity.title)}</div>
|
||||
${activity.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(activity.location)}</div>` : ''}
|
||||
<div style="font-size: 12px; color: #888;">📅 ${activity.startDate.toLocaleDateString()}</div>
|
||||
<div style="min-width: 200px; cursor: pointer;" class="event-popup">
|
||||
${event.image ? `<img src="${event.image}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;" />` : ''}
|
||||
<div style="font-weight: 600; font-size: 14px; margin-bottom: 4px;">${escapeHtml(event.title)}</div>
|
||||
${event.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(event.location)}</div>` : ''}
|
||||
<div style="font-size: 12px; color: #888;">📅 ${event.startDate.toLocaleDateString()}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
|
|
@ -79,10 +79,10 @@ function updateMarkers() {
|
|||
const popup = marker.getPopup()
|
||||
if (popup) {
|
||||
const el = popup.getElement()
|
||||
const content = el?.querySelector('.activity-popup')
|
||||
const content = el?.querySelector('.event-popup')
|
||||
if (content) {
|
||||
(content as HTMLElement).onclick = () => {
|
||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,11 +91,11 @@ function updateMarkers() {
|
|||
markerGroup.addLayer(marker)
|
||||
}
|
||||
|
||||
// Fit bounds only on first load, not when new activities stream in
|
||||
if (!hasFittedBounds && geoActivities.length > 0) {
|
||||
// Fit bounds only on first load, not when new events stream in
|
||||
if (!hasFittedBounds && geoEvents.length > 0) {
|
||||
hasFittedBounds = true
|
||||
const bounds = L.latLngBounds(
|
||||
geoActivities.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
|
||||
geoEvents.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
|
||||
)
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 })
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ function escapeHtml(text: string): string {
|
|||
return div.innerHTML
|
||||
}
|
||||
|
||||
watch(() => props.activities, updateMarkers, { deep: true })
|
||||
watch(() => props.events, updateMarkers, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
|
|
@ -7,14 +7,14 @@ import { Input } from '@/components/ui/input'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
import type { Activity } from '../types/activity'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const props = defineProps<{
|
||||
activities: Activity[]
|
||||
events: Event[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [activity: Activity]
|
||||
select: [event: Event]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -22,7 +22,7 @@ const { dateLocale } = useDateLocale()
|
|||
const isOpen = ref(false)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const searchOptions: FuzzySearchOptions<Activity> = {
|
||||
const searchOptions: FuzzySearchOptions<Event> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.5 },
|
||||
|
|
@ -39,7 +39,7 @@ const searchOptions: FuzzySearchOptions<Activity> = {
|
|||
resultLimit: 8,
|
||||
}
|
||||
|
||||
const activitiesRef = computed(() => props.activities)
|
||||
const eventsRef = computed(() => props.events)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
|
|
@ -47,26 +47,26 @@ const {
|
|||
isSearching,
|
||||
clearSearch,
|
||||
setSearchQuery,
|
||||
} = useFuzzySearch(activitiesRef, searchOptions)
|
||||
} = useFuzzySearch(eventsRef, searchOptions)
|
||||
|
||||
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
||||
|
||||
function formatDate(activity: Activity): string {
|
||||
if (!activity.startDate || isNaN(activity.startDate.getTime())) return ''
|
||||
function formatDate(event: Event): string {
|
||||
if (!event.startDate || isNaN(event.startDate.getTime())) return ''
|
||||
try {
|
||||
const opts = { locale: dateLocale.value }
|
||||
if (activity.type === 'date') return format(activity.startDate, 'MMM d', opts)
|
||||
return format(activity.startDate, 'MMM d · HH:mm', opts)
|
||||
if (event.type === 'date') return format(event.startDate, 'MMM d', opts)
|
||||
return format(event.startDate, 'MMM d · HH:mm', opts)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(activity: Activity) {
|
||||
function handleSelect(event: Event) {
|
||||
clearSearch()
|
||||
isOpen.value = false
|
||||
emit('select', activity)
|
||||
emit('select', event)
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
|
|
@ -110,7 +110,7 @@ watch(isOpen, (open) => {
|
|||
:model-value="searchQuery"
|
||||
@update:model-value="handleInput"
|
||||
@focus="handleFocus"
|
||||
:placeholder="t('activities.search.placeholder')"
|
||||
:placeholder="t('events.search.placeholder')"
|
||||
class="pl-9 pr-9"
|
||||
/>
|
||||
<Button
|
||||
|
|
@ -131,21 +131,21 @@ watch(isOpen, (open) => {
|
|||
>
|
||||
<!-- No results -->
|
||||
<div v-if="showNoResults" class="p-4 text-sm text-muted-foreground text-center">
|
||||
{{ t('activities.search.noResults') }}
|
||||
{{ t('events.search.noResults') }}
|
||||
</div>
|
||||
|
||||
<!-- Result items -->
|
||||
<button
|
||||
v-for="activity in filteredItems"
|
||||
:key="activity.nostrEventId"
|
||||
v-for="event in filteredItems"
|
||||
:key="event.nostrEventId"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-muted transition-colors text-left border-b last:border-b-0"
|
||||
@click="handleSelect(activity)"
|
||||
@click="handleSelect(event)"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<img
|
||||
v-if="activity.image"
|
||||
:src="activity.image"
|
||||
:alt="activity.title"
|
||||
v-if="event.image"
|
||||
:src="event.image"
|
||||
:alt="event.title"
|
||||
class="w-10 h-10 rounded object-cover shrink-0"
|
||||
/>
|
||||
<div v-else class="w-10 h-10 rounded bg-muted flex items-center justify-center shrink-0">
|
||||
|
|
@ -154,12 +154,12 @@ watch(isOpen, (open) => {
|
|||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span v-if="formatDate(activity)" class="truncate">{{ formatDate(activity) }}</span>
|
||||
<span v-if="activity.location" class="flex items-center gap-0.5 truncate">
|
||||
<span v-if="formatDate(event)" class="truncate">{{ formatDate(event) }}</span>
|
||||
<span v-if="event.location" class="flex items-center gap-0.5 truncate">
|
||||
<MapPin class="w-2.5 h-2.5 shrink-0" />
|
||||
{{ activity.location }}
|
||||
{{ event.location }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -20,15 +20,15 @@ const { t } = useI18n()
|
|||
const { isAuthenticated } = useAuth()
|
||||
const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP()
|
||||
|
||||
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||
const myStatus = computed(() => getMyRSVP(activityKind.value, props.pubkey, props.dTag))
|
||||
const goingCount = computed(() => getRSVPCount(activityKind.value, props.pubkey, props.dTag))
|
||||
const pending = computed(() => isPending(activityKind.value, props.pubkey, props.dTag))
|
||||
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||
const myStatus = computed(() => getMyRSVP(eventKind.value, props.pubkey, props.dTag))
|
||||
const goingCount = computed(() => getRSVPCount(eventKind.value, props.pubkey, props.dTag))
|
||||
const pending = computed(() => isPending(eventKind.value, props.pubkey, props.dTag))
|
||||
|
||||
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
|
||||
{ status: 'accepted', labelKey: 'activities.detail.going', icon: Check },
|
||||
{ status: 'tentative', labelKey: 'activities.detail.maybe', icon: HelpCircle },
|
||||
{ status: 'declined', labelKey: 'activities.detail.notGoing', icon: X },
|
||||
{ status: 'accepted', labelKey: 'events.detail.going', icon: Check },
|
||||
{ status: 'tentative', labelKey: 'events.detail.maybe', icon: HelpCircle },
|
||||
{ status: 'declined', labelKey: 'events.detail.notGoing', icon: X },
|
||||
]
|
||||
|
||||
const statusLabel: Record<RSVPStatus, string> = {
|
||||
|
|
@ -47,7 +47,7 @@ async function handleClick(status: RSVPStatus) {
|
|||
})
|
||||
return
|
||||
}
|
||||
const published = await setRSVP(activityKind.value, props.pubkey, props.dTag, status)
|
||||
const published = await setRSVP(eventKind.value, props.pubkey, props.dTag, status)
|
||||
if (published) {
|
||||
toast.success(statusLabel[published])
|
||||
} else if (!pending.value) {
|
||||
|
|
@ -14,11 +14,11 @@ const emit = defineEmits<{
|
|||
const { t } = useI18n()
|
||||
|
||||
const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||
{ value: 'all', labelKey: 'activities.filters.all' },
|
||||
{ value: 'today', labelKey: 'activities.filters.today' },
|
||||
{ value: 'tomorrow', labelKey: 'activities.filters.tomorrow' },
|
||||
{ value: 'this-week', labelKey: 'activities.filters.thisWeek' },
|
||||
{ value: 'this-month', labelKey: 'activities.filters.thisMonth' },
|
||||
{ value: 'all', labelKey: 'events.filters.all' },
|
||||
{ value: 'today', labelKey: 'events.filters.today' },
|
||||
{ value: 'tomorrow', labelKey: 'events.filters.tomorrow' },
|
||||
{ value: 'this-week', labelKey: 'events.filters.thisWeek' },
|
||||
{ value: 'this-month', labelKey: 'events.filters.thisMonth' },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ import type { TicketApiService } from '../services/TicketApiService'
|
|||
* when in doubt). Probe re-runs whenever auth flips to authenticated.
|
||||
*
|
||||
* Used by every surface that opens the edit-mode CreateEventDialog
|
||||
* (activities-app/App.vue shell mount, activities EventsPage). Keeps
|
||||
* (events-app/App.vue shell mount, events EventsPage). Keeps
|
||||
* the probe logic single-source-of-truth.
|
||||
*/
|
||||
export function useApprovalState() {
|
||||
|
|
@ -5,7 +5,7 @@ import { useAuth } from '@/composables/useAuthService'
|
|||
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||
|
||||
/**
|
||||
* NIP-51 Bookmarks (kind 10003) for saving favorite activities.
|
||||
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
|
||||
*
|
||||
* Stores references to NIP-52 calendar events as 'a' tags:
|
||||
* ['a', '<kind>:<pubkey>:<d-tag>']
|
||||
|
|
@ -17,7 +17,7 @@ import { signEventViaLnbits } from '@/lib/nostr/signing'
|
|||
const BOOKMARK_KIND = 10003
|
||||
|
||||
interface BookmarkState {
|
||||
/** Set of bookmarked activity coordinates: "kind:pubkey:d-tag" */
|
||||
/** Set of bookmarked event coordinates: "kind:pubkey:d-tag" */
|
||||
bookmarkedCoords: Set<string>
|
||||
/** The latest bookmark event we've seen */
|
||||
lastEventId: string | null
|
||||
|
|
@ -36,8 +36,8 @@ export function useBookmarks() {
|
|||
|
||||
const bookmarkedIds = computed(() => state.value.bookmarkedCoords)
|
||||
|
||||
function isBookmarked(activityKind: number, pubkey: string, dTag: string): boolean {
|
||||
return state.value.bookmarkedCoords.has(`${activityKind}:${pubkey}:${dTag}`)
|
||||
function isBookmarked(eventKind: number, pubkey: string, dTag: string): boolean {
|
||||
return state.value.bookmarkedCoords.has(`${eventKind}:${pubkey}:${dTag}`)
|
||||
}
|
||||
|
||||
function isBookmarkedByDTag(dTag: string): boolean {
|
||||
|
|
@ -87,12 +87,12 @@ export function useBookmarks() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list.
|
||||
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
|
||||
*/
|
||||
async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) {
|
||||
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
|
||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
||||
|
||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||
const newCoords = new Set(state.value.bookmarkedCoords)
|
||||
|
||||
if (newCoords.has(coord)) {
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
||||
import { useActivitiesStore } from '../stores/activities'
|
||||
import type { Activity } from '../types/activity'
|
||||
import type { EventsNostrService } from '../services/EventsNostrService'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
/**
|
||||
* Composable for loading a single activity by its d-tag identifier.
|
||||
* Composable for loading a single event by its d-tag identifier.
|
||||
* First checks the store cache, then queries relays if not found.
|
||||
*/
|
||||
export function useActivityDetail(activityId: string) {
|
||||
const store = useActivitiesStore()
|
||||
export function useEventDetail(eventId: string) {
|
||||
const store = useEventsStore()
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
const activity = computed<Activity | undefined>(() =>
|
||||
store.getActivityById(activityId)
|
||||
const event = computed<Event | undefined>(() =>
|
||||
store.getEventById(eventId)
|
||||
)
|
||||
|
||||
async function load() {
|
||||
// Already in cache
|
||||
if (activity.value) return
|
||||
if (event.value) return
|
||||
|
||||
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
||||
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||
if (!nostrService) {
|
||||
error.value = 'Activities service not available'
|
||||
error.value = 'Events service not available'
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -33,16 +33,16 @@ export function useActivityDetail(activityId: string) {
|
|||
error.value = null
|
||||
|
||||
// Scope both the subscription and the one-shot query to this
|
||||
// activity's d-tag. Without this scope, the query asks every
|
||||
// event's d-tag. Without this scope, the query asks every
|
||||
// relay for every kind-31922/31923 event and races a 5s timeout
|
||||
// to find ours — on a cold page refresh that race is often lost
|
||||
// even when the activity is reachable.
|
||||
const detailFilters = { dTags: [activityId] }
|
||||
// even when the event is reachable.
|
||||
const detailFilters = { dTags: [eventId] }
|
||||
|
||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||
(incoming) => {
|
||||
store.upsertActivity(incoming)
|
||||
if (incoming.id === activityId) {
|
||||
store.upsertEvent(incoming)
|
||||
if (incoming.id === eventId) {
|
||||
isLoading.value = false
|
||||
}
|
||||
},
|
||||
|
|
@ -50,17 +50,17 @@ export function useActivityDetail(activityId: string) {
|
|||
)
|
||||
|
||||
const results = await nostrService.queryCalendarEvents(detailFilters)
|
||||
store.upsertActivities(results)
|
||||
store.upsertEvents(results)
|
||||
|
||||
// If we still don't have it after query, stop loading
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
if (!activity.value) {
|
||||
error.value = 'Activity not found'
|
||||
if (!event.value) {
|
||||
error.value = 'Event not found'
|
||||
}
|
||||
}, 5000)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load activity'
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load event'
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ export function useActivityDetail(activityId: string) {
|
|||
})
|
||||
|
||||
return {
|
||||
activity,
|
||||
event,
|
||||
isLoading,
|
||||
error,
|
||||
reload: load,
|
||||
|
|
@ -3,61 +3,61 @@ import {
|
|||
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
||||
startOfMonth, endOfMonth, addDays, isSameDay,
|
||||
} from 'date-fns'
|
||||
import type { Activity } from '../types/activity'
|
||||
import type { ActivityCategory } from '../types/category'
|
||||
import type { TemporalFilter, ActivityFilters } from '../types/filters'
|
||||
import type { Event } from '../types/event'
|
||||
import type { EventCategory } from '../types/category'
|
||||
import type { TemporalFilter, EventFilters } from '../types/filters'
|
||||
import { DEFAULT_FILTERS } from '../types/filters'
|
||||
|
||||
/**
|
||||
* Composable for managing activity filter state and applying filters reactively.
|
||||
* Composable for managing event filter state and applying filters reactively.
|
||||
*/
|
||||
export function useActivityFilters() {
|
||||
export function useEventFilters() {
|
||||
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||
const selectedCategories = ref<ActivityCategory[]>([])
|
||||
const selectedCategories = ref<EventCategory[]>([])
|
||||
const selectedDate = ref<Date | undefined>(undefined)
|
||||
/**
|
||||
* When true, the feed is narrowed to activities the current user
|
||||
* When true, the feed is narrowed to events the current user
|
||||
* holds at least one paid ticket for. Crossed with the
|
||||
* `ownedActivityIds` set from useOwnedTickets in useActivities
|
||||
* `ownedEventIds` set from useOwnedTickets in useEvents
|
||||
* (this composable stays free of ticket fetching).
|
||||
*/
|
||||
const onlyOwnedTickets = ref(false)
|
||||
/**
|
||||
* When true, the feed is narrowed to activities the current user
|
||||
* When true, the feed is narrowed to events the current user
|
||||
* is hosting (organizer pubkey matches the signed-in user, or the
|
||||
* row is a local LNbits draft of theirs). Reads `activity.isMine`
|
||||
* which `useActivities.tagOwnership()` populates.
|
||||
* row is a local LNbits draft of theirs). Reads `event.isMine`
|
||||
* which `useEvents.tagOwnership()` populates.
|
||||
*/
|
||||
const onlyHosting = ref(false)
|
||||
/**
|
||||
* When false (default), activities that have already ended are
|
||||
* When false (default), events that have already ended are
|
||||
* hidden from the feed. Toggling on includes them so the user can
|
||||
* browse past events. The date-picker overrides this — picking a
|
||||
* specific past date shows that day's activities regardless,
|
||||
* specific past date shows that day's events regardless,
|
||||
* mirroring how it overrides the temporal pills.
|
||||
*/
|
||||
const showPast = ref(false)
|
||||
|
||||
const filters = computed<ActivityFilters>(() => ({
|
||||
const filters = computed<EventFilters>(() => ({
|
||||
temporal: temporal.value,
|
||||
categories: selectedCategories.value,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Apply the current filters to a list of activities.
|
||||
* Apply the current filters to a list of events.
|
||||
*/
|
||||
function applyFilters(activities: Activity[]): Activity[] {
|
||||
let result = activities
|
||||
function applyFilters(events: Event[]): Event[] {
|
||||
let result = events
|
||||
|
||||
// Specific date filter (from DatePickerStrip) takes priority over
|
||||
// temporal. Picking a date also bypasses the past/upcoming split
|
||||
// so the user can browse activities for any day they choose.
|
||||
// so the user can browse events for any day they choose.
|
||||
if (selectedDate.value) {
|
||||
const dayStart = startOfDay(selectedDate.value)
|
||||
const dayEnd = endOfDay(selectedDate.value)
|
||||
result = result.filter(a => {
|
||||
const activityEnd = a.endDate ?? a.startDate
|
||||
return a.startDate <= dayEnd && activityEnd >= dayStart
|
||||
const eventEnd = a.endDate ?? a.startDate
|
||||
return a.startDate <= dayEnd && eventEnd >= dayStart
|
||||
})
|
||||
} else {
|
||||
// Temporal filter
|
||||
|
|
@ -69,8 +69,8 @@ export function useActivityFilters() {
|
|||
// showPast=true shows only the days already passed this week.
|
||||
const now = new Date()
|
||||
result = result.filter(a => {
|
||||
const activityEnd = a.endDate ?? a.startDate
|
||||
return showPast.value ? activityEnd < now : activityEnd >= now
|
||||
const eventEnd = a.endDate ?? a.startDate
|
||||
return showPast.value ? eventEnd < now : eventEnd >= now
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -81,8 +81,8 @@ export function useActivityFilters() {
|
|||
)
|
||||
}
|
||||
|
||||
// Hosting filter — activities the signed-in user organizes.
|
||||
// Read off `activity.isMine` which `useActivities.tagOwnership()`
|
||||
// Hosting filter — events the signed-in user organizes.
|
||||
// Read off `event.isMine` which `useEvents.tagOwnership()`
|
||||
// populates from organizer-pubkey match + LNbits drafts.
|
||||
if (onlyHosting.value) {
|
||||
result = result.filter(a => a.isMine === true)
|
||||
|
|
@ -105,7 +105,7 @@ export function useActivityFilters() {
|
|||
}
|
||||
}
|
||||
|
||||
function toggleCategory(category: ActivityCategory) {
|
||||
function toggleCategory(category: EventCategory) {
|
||||
const idx = selectedCategories.value.indexOf(category)
|
||||
if (idx >= 0) {
|
||||
selectedCategories.value.splice(idx, 1)
|
||||
|
|
@ -174,8 +174,8 @@ export function useActivityFilters() {
|
|||
|
||||
// --- Helpers ---
|
||||
|
||||
function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Activity[] {
|
||||
if (filter === 'all') return activities
|
||||
function applyTemporalFilter(events: Event[], filter: TemporalFilter): Event[] {
|
||||
if (filter === 'all') return events
|
||||
|
||||
const now = new Date()
|
||||
let start: Date
|
||||
|
|
@ -199,12 +199,12 @@ function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Ac
|
|||
end = endOfMonth(now)
|
||||
break
|
||||
default:
|
||||
return activities
|
||||
return events
|
||||
}
|
||||
|
||||
return activities.filter(a => {
|
||||
const activityEnd = a.endDate ?? a.startDate
|
||||
// Activity overlaps with the filter range
|
||||
return a.startDate <= end && activityEnd >= start
|
||||
return events.filter(a => {
|
||||
const eventEnd = a.endDate ?? a.startDate
|
||||
// Event overlaps with the filter range
|
||||
return a.startDate <= end && eventEnd >= start
|
||||
})
|
||||
}
|
||||
|
|
@ -1,24 +1,24 @@
|
|||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
||||
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
|
||||
import type { EventsNostrService } from '../services/EventsNostrService'
|
||||
import type { CalendarEventFilters } from '../services/EventsNostrService'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { TicketedEvent } from '../types/ticket'
|
||||
import { ticketedEventToActivity } from '../types/activity'
|
||||
import { useActivitiesStore } from '../stores/activities'
|
||||
import { useActivityFilters } from './useActivityFilters'
|
||||
import { ticketedEventToEvent } from '../types/event'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import { useEventFilters } from './useEventFilters'
|
||||
import { useOwnedTickets } from './useOwnedTickets'
|
||||
|
||||
/**
|
||||
* Main composable for activities discovery.
|
||||
* Subscribes to NIP-52 events via ActivitiesNostrService and manages the activity feed.
|
||||
* Main composable for events discovery.
|
||||
* Subscribes to NIP-52 events via EventsNostrService and manages the event feed.
|
||||
*/
|
||||
export function useActivities() {
|
||||
const store = useActivitiesStore()
|
||||
const filters = useActivityFilters()
|
||||
export function useEvents() {
|
||||
const store = useEventsStore()
|
||||
const filters = useEventFilters()
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const { ownedActivityIds } = useOwnedTickets()
|
||||
const { ownedEventIds } = useOwnedTickets()
|
||||
|
||||
const isSubscribed = ref(false)
|
||||
const subscriptionError = ref<string | null>(null)
|
||||
|
|
@ -27,27 +27,27 @@ export function useActivities() {
|
|||
/**
|
||||
* Merge the caller's own LNbits events (any status) into the feed.
|
||||
*
|
||||
* The `/activities` feed is Nostr-driven, so an event that hasn't
|
||||
* The `/events` feed is Nostr-driven, so an event that hasn't
|
||||
* been published yet — typically because it's still `proposed` under
|
||||
* auto_approve=off — would silently vanish from the creator's view
|
||||
* until an admin approves it. Pull own events from the events
|
||||
* extension and upsert them as Activities so users see their own
|
||||
* extension and upsert them as Events so users see their own
|
||||
* drafts with a Pending-review badge.
|
||||
*
|
||||
* Once an event is approved and the Nostr relay delivers the kind
|
||||
* 31922/31923 event, the relay-sourced Activity has a newer
|
||||
* 31922/31923 event, the relay-sourced Event has a newer
|
||||
* createdAt and wins on upsert (it lacks `lnbitsStatus`, so the
|
||||
* badge disappears).
|
||||
*/
|
||||
/**
|
||||
* Stamp `isMine` on a Nostr-sourced activity when the organizer
|
||||
* Stamp `isMine` on a Nostr-sourced event when the organizer
|
||||
* pubkey matches the logged-in user's Nostr key. LNbits drafts come
|
||||
* pre-tagged via the adapter.
|
||||
*/
|
||||
function tagOwnership(activity: { organizer: { pubkey: string }; isMine?: boolean }) {
|
||||
function tagOwnership(event: { organizer: { pubkey: string }; isMine?: boolean }) {
|
||||
const myPubkey = currentUser.value?.pubkey
|
||||
if (myPubkey && activity.organizer.pubkey === myPubkey) {
|
||||
activity.isMine = true
|
||||
if (myPubkey && event.organizer.pubkey === myPubkey) {
|
||||
event.isMine = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,21 +60,21 @@ export function useActivities() {
|
|||
try {
|
||||
const mine = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
|
||||
for (const ev of mine) {
|
||||
store.upsertActivity(ticketedEventToActivity(ev))
|
||||
store.upsertEvent(ticketedEventToEvent(ev))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useActivities] loadOwnEvents failed:', err)
|
||||
console.warn('[useEvents] loadOwnEvents failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Filtered and sorted activities (from all activities, filters handle time range)
|
||||
const filteredActivities = computed(() => {
|
||||
const all = store.activities.sort(
|
||||
// Filtered and sorted events (from all events, filters handle time range)
|
||||
const filteredEvents = computed(() => {
|
||||
const all = store.events.sort(
|
||||
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
||||
)
|
||||
const filtered = filters.applyFilters(all)
|
||||
if (!filters.onlyOwnedTickets.value) return filtered
|
||||
const owned = ownedActivityIds.value
|
||||
const owned = ownedEventIds.value
|
||||
return filtered.filter(a => owned.has(a.id))
|
||||
})
|
||||
|
||||
|
|
@ -84,9 +84,9 @@ export function useActivities() {
|
|||
function subscribe(eventFilters?: CalendarEventFilters) {
|
||||
if (isSubscribed.value) return
|
||||
|
||||
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
||||
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||
if (!nostrService) {
|
||||
subscriptionError.value = 'Activities service not available'
|
||||
subscriptionError.value = 'Events service not available'
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -95,9 +95,9 @@ export function useActivities() {
|
|||
subscriptionError.value = null
|
||||
|
||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||
(activity) => {
|
||||
tagOwnership(activity)
|
||||
store.upsertActivity(activity)
|
||||
(event) => {
|
||||
tagOwnership(event)
|
||||
store.upsertEvent(event)
|
||||
store.isLoading = false
|
||||
},
|
||||
eventFilters
|
||||
|
|
@ -123,20 +123,20 @@ export function useActivities() {
|
|||
* One-shot query for calendar events.
|
||||
*/
|
||||
async function query(eventFilters?: CalendarEventFilters) {
|
||||
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
||||
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||
if (!nostrService) {
|
||||
subscriptionError.value = 'Activities service not available'
|
||||
subscriptionError.value = 'Events service not available'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
store.isLoading = true
|
||||
subscriptionError.value = null
|
||||
const activities = await nostrService.queryCalendarEvents(eventFilters)
|
||||
for (const a of activities) tagOwnership(a)
|
||||
store.upsertActivities(activities)
|
||||
const events = await nostrService.queryCalendarEvents(eventFilters)
|
||||
for (const a of events) tagOwnership(a)
|
||||
store.upsertEvents(events)
|
||||
} catch (err) {
|
||||
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
|
||||
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query events'
|
||||
} finally {
|
||||
store.isLoading = false
|
||||
}
|
||||
|
|
@ -169,8 +169,8 @@ export function useActivities() {
|
|||
|
||||
return {
|
||||
// State
|
||||
activities: filteredActivities,
|
||||
allActivities: computed(() => store.activities),
|
||||
events: filteredEvents,
|
||||
allEvents: computed(() => store.events),
|
||||
isLoading: computed(() => store.isLoading),
|
||||
isSubscribed,
|
||||
error: subscriptionError,
|
||||
|
|
@ -5,7 +5,7 @@ import { useAuth } from '@/composables/useAuthService'
|
|||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { TicketedEvent } from '../types/ticket'
|
||||
|
||||
export function useEvents() {
|
||||
export function useMyEvents() {
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ export function useEvents() {
|
|||
// can still browse, they just won't see their own pending events.
|
||||
// Log so a flaky probe is debuggable from the console without
|
||||
// toast-spamming the user on every transient failure.
|
||||
console.warn('[useEvents] fetchMyEvents failed, showing public feed only:', err)
|
||||
console.warn('[useMyEvents] fetchMyEvents failed, showing public feed only:', err)
|
||||
return publicEvents
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ export function useOrganizerProfile(pubkey: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch profiles for multiple pubkeys (for activity cards).
|
||||
* Batch-fetch profiles for multiple pubkeys (for event cards).
|
||||
*/
|
||||
export function useBatchProfiles() {
|
||||
function fetchProfiles(pubkeys: string[]) {
|
||||
|
|
@ -2,12 +2,12 @@ import { computed, ref, watch } from 'vue'
|
|||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { ActivityTicket } from '../types/ticket'
|
||||
import type { EventTicket } from '../types/ticket'
|
||||
|
||||
/**
|
||||
* Module-level singleton: owned-ticket lookup keyed by activity id
|
||||
* Module-level singleton: owned-ticket lookup keyed by event id
|
||||
* (== LNbits event id == NIP-52 d-tag, all the same string by
|
||||
* extension contract). Lives at module scope so every <ActivityCard>
|
||||
* extension contract). Lives at module scope so every <EventCard>
|
||||
* + the detail page + the feed filter share ONE underlying fetch
|
||||
* instead of each instance hitting the API.
|
||||
*
|
||||
|
|
@ -18,7 +18,7 @@ import type { ActivityTicket } from '../types/ticket'
|
|||
* atomically.
|
||||
*/
|
||||
|
||||
const tickets = ref<ActivityTicket[]>([])
|
||||
const tickets = ref<EventTicket[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
let hasAutoLoaded = false
|
||||
|
|
@ -49,36 +49,36 @@ async function fetchTickets(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
const ticketsByActivity = computed<Map<string, ActivityTicket[]>>(() => {
|
||||
const m = new Map<string, ActivityTicket[]>()
|
||||
const ticketsByEvent = computed<Map<string, EventTicket[]>>(() => {
|
||||
const m = new Map<string, EventTicket[]>()
|
||||
for (const ticket of tickets.value) {
|
||||
const existing = m.get(ticket.activityId)
|
||||
const existing = m.get(ticket.eventId)
|
||||
if (existing) {
|
||||
existing.push(ticket)
|
||||
} else {
|
||||
m.set(ticket.activityId, [ticket])
|
||||
m.set(ticket.eventId, [ticket])
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const ownedActivityIds = computed<Set<string>>(() => {
|
||||
const ownedEventIds = computed<Set<string>>(() => {
|
||||
const s = new Set<string>()
|
||||
for (const ticket of tickets.value) {
|
||||
if (ticket.paid) s.add(ticket.activityId)
|
||||
if (ticket.paid) s.add(ticket.eventId)
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
function getTickets(activityId: string): ActivityTicket[] {
|
||||
return ticketsByActivity.value.get(activityId) ?? []
|
||||
function getTickets(eventId: string): EventTicket[] {
|
||||
return ticketsByEvent.value.get(eventId) ?? []
|
||||
}
|
||||
|
||||
/** Number of paid ticket rows for an activity. With the
|
||||
/** Number of paid ticket rows for an event. With the
|
||||
* multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2),
|
||||
* this matches the number of attendees / scannable QRs. */
|
||||
function paidCount(activityId: string): number {
|
||||
return getTickets(activityId).filter(t => t.paid).length
|
||||
function paidCount(eventId: string): number {
|
||||
return getTickets(eventId).filter(t => t.paid).length
|
||||
}
|
||||
|
||||
export function useOwnedTickets() {
|
||||
|
|
@ -115,8 +115,8 @@ export function useOwnedTickets() {
|
|||
|
||||
return {
|
||||
tickets,
|
||||
ticketsByActivity,
|
||||
ownedActivityIds,
|
||||
ticketsByEvent,
|
||||
ownedEventIds,
|
||||
getTickets,
|
||||
paidCount,
|
||||
refresh: fetchTickets,
|
||||
|
|
@ -20,11 +20,11 @@ interface RSVPEntry {
|
|||
createdAt: number
|
||||
}
|
||||
|
||||
// Cache: activityCoord -> user's own (latest) RSVP entry
|
||||
// Cache: eventCoord -> user's own (latest) RSVP entry
|
||||
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
||||
// Cache: activityCoord -> (pubkey -> latest RSVP entry from that pubkey).
|
||||
// Cache: eventCoord -> (pubkey -> latest RSVP entry from that pubkey).
|
||||
// NIP-52 RSVPs (kind 31925) are replaceable per (kind, pubkey, d-tag), so a
|
||||
// user's earlier RSVP for an activity is superseded by their later one. The
|
||||
// user's earlier RSVP for an event is superseded by their later one. The
|
||||
// "going" count is derived from this map (count of pubkeys whose *latest*
|
||||
// RSVP has status === 'accepted'), not by summing every accepted event seen
|
||||
// — that would double-count replacements and never decrement on flip.
|
||||
|
|
@ -51,7 +51,7 @@ function upsertRSVPState(coord: string, pubkey: string, entry: RSVPEntry) {
|
|||
if (existing && existing.createdAt >= entry.createdAt) return
|
||||
inner.set(pubkey, entry)
|
||||
// Re-set on the outer map so the ref's reactive proxy notifies dependents
|
||||
// (Vue 3's deep reactivity doesn't reach into nested Map values).
|
||||
// (Vue 3's deep reevent doesn't reach into nested Map values).
|
||||
rsvpStates.value.set(coord, inner)
|
||||
}
|
||||
|
||||
|
|
@ -60,19 +60,19 @@ export function useRSVP() {
|
|||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Get the user's RSVP status for an activity.
|
||||
* Get the user's RSVP status for an event.
|
||||
*/
|
||||
function getMyRSVP(activityKind: number, pubkey: string, dTag: string): RSVPStatus | null {
|
||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||
function getMyRSVP(eventKind: number, pubkey: string, dTag: string): RSVPStatus | null {
|
||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||
return rsvpCache.value.get(coord)?.status ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* RSVP count for an activity = number of pubkeys whose latest RSVP for
|
||||
* this activity has status 'accepted'.
|
||||
* RSVP count for an event = number of pubkeys whose latest RSVP for
|
||||
* this event has status 'accepted'.
|
||||
*/
|
||||
function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number {
|
||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||
function getRSVPCount(eventKind: number, pubkey: string, dTag: string): number {
|
||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||
const inner = rsvpStates.value.get(coord)
|
||||
if (!inner) return 0
|
||||
let count = 0
|
||||
|
|
@ -83,7 +83,7 @@ export function useRSVP() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Load the user's RSVPs and counts for visible activities from relays.
|
||||
* Load the user's RSVPs and counts for visible events from relays.
|
||||
*/
|
||||
function loadRSVPs() {
|
||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||
|
|
@ -130,39 +130,39 @@ export function useRSVP() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether a publish is currently in flight for the given activity. Bind
|
||||
* Whether a publish is currently in flight for the given event. Bind
|
||||
* to the RSVP buttons' `:disabled` so users can't queue racing clicks.
|
||||
*/
|
||||
function isPending(activityKind: number, pubkey: string, dTag: string): boolean {
|
||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||
function isPending(eventKind: number, pubkey: string, dTag: string): boolean {
|
||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||
return pendingCoords.value.has(coord)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an RSVP for an activity.
|
||||
* Publish an RSVP for an event.
|
||||
* Clicking the same status again removes the RSVP (publishes 'declined').
|
||||
*
|
||||
* Returns the status that was published on success, or null if the publish
|
||||
* was rejected, blocked, or threw — caller should toast accordingly.
|
||||
*/
|
||||
async function setRSVP(
|
||||
activityKind: number,
|
||||
activityPubkey: string,
|
||||
activityDTag: string,
|
||||
eventKind: number,
|
||||
eventPubkey: string,
|
||||
eventDTag: string,
|
||||
status: RSVPStatus
|
||||
): Promise<RSVPStatus | null> {
|
||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
|
||||
|
||||
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
||||
const coord = `${eventKind}:${eventPubkey}:${eventDTag}`
|
||||
|
||||
// Throttle: refuse a second click while the first is still publishing.
|
||||
if (pendingCoords.value.has(coord)) return null
|
||||
|
||||
// Toggle: if already this status, decline instead.
|
||||
const currentStatus = getMyRSVP(activityKind, activityPubkey, activityDTag)
|
||||
const currentStatus = getMyRSVP(eventKind, eventPubkey, eventDTag)
|
||||
const newStatus = currentStatus === status ? 'declined' : status
|
||||
|
||||
const dTag = `rsvp-${activityDTag}`
|
||||
const dTag = `rsvp-${eventDTag}`
|
||||
|
||||
// Strictly-monotonic created_at per coord so two clicks in the same
|
||||
// wall-clock second don't both stamp the same timestamp (relays would
|
||||
|
|
@ -181,7 +181,7 @@ export function useRSVP() {
|
|||
['status', newStatus],
|
||||
['L', 'status'],
|
||||
['l', newStatus, 'status'],
|
||||
['p', activityPubkey],
|
||||
['p', eventPubkey],
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ export interface EventStats {
|
|||
* route via HTTP rather than the kind-21000 nostr-transport RPC
|
||||
* because post-#9 the webapp no longer holds a raw user prvkey.
|
||||
*/
|
||||
export function useTicketScanner(activityId: Ref<string>) {
|
||||
export function useTicketScanner(eventId: Ref<string>) {
|
||||
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
||||
const { currentUser } = useAuth()
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ export function useTicketScanner(activityId: Ref<string>) {
|
|||
const statsError = ref<string | null>(null)
|
||||
/** Session-local dedup. Hidden from UI; only guards repeat decodes. */
|
||||
const scanned = useLocalStorage<ScanRecord[]>(
|
||||
() => `activities_scanned_${activityId.value}`,
|
||||
() => `events_scanned_${eventId.value}`,
|
||||
[],
|
||||
)
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ export function useTicketScanner(activityId: Ref<string>) {
|
|||
}
|
||||
|
||||
async function refreshStats(): Promise<void> {
|
||||
if (!activityId.value) return
|
||||
if (!eventId.value) return
|
||||
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
|
||||
if (!adminKey) {
|
||||
statsError.value = 'No wallet admin key available'
|
||||
|
|
@ -100,7 +100,7 @@ export function useTicketScanner(activityId: Ref<string>) {
|
|||
statsLoading.value = true
|
||||
statsError.value = null
|
||||
try {
|
||||
const data = await ticketApi.getEventStats(activityId.value, adminKey)
|
||||
const data = await ticketApi.getEventStats(eventId.value, adminKey)
|
||||
eventStats.value = {
|
||||
sold: data.sold,
|
||||
registered: data.registered,
|
||||
|
|
@ -3,11 +3,11 @@ import { useAsyncState } from '@vueuse/core'
|
|||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { ActivityTicket } from '../types/ticket'
|
||||
import type { EventTicket } from '../types/ticket'
|
||||
|
||||
interface GroupedTickets {
|
||||
eventId: string
|
||||
tickets: ActivityTicket[]
|
||||
tickets: EventTicket[]
|
||||
paidCount: number
|
||||
pendingCount: number
|
||||
registeredCount: number
|
||||
|
|
@ -26,7 +26,7 @@ export function useUserTickets() {
|
|||
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
||||
return await ticketApi.fetchUserTickets(currentUser.value.id, accessToken)
|
||||
},
|
||||
[] as ActivityTicket[],
|
||||
[] as EventTicket[],
|
||||
{
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
|
|
@ -71,7 +71,7 @@ export function useUserTickets() {
|
|||
const groups = new Map<string, GroupedTickets>()
|
||||
|
||||
sortedTickets.value.forEach(ticket => {
|
||||
const eventKey = ticket.activityId
|
||||
const eventKey = ticket.eventId
|
||||
if (!groups.has(eventKey)) {
|
||||
groups.set(eventKey, {
|
||||
eventId: eventKey,
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
|
||||
import { SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { ActivitiesNostrService } from './services/ActivitiesNostrService'
|
||||
import { EventsNostrService } from './services/EventsNostrService'
|
||||
import { TicketApiService, type TicketApiConfig } from './services/TicketApiService'
|
||||
import PurchaseTicketDialog from './components/PurchaseTicketDialog.vue'
|
||||
|
||||
export interface ActivitiesModuleConfig {
|
||||
export interface EventsModuleConfig {
|
||||
apiConfig: TicketApiConfig
|
||||
defaultMapCenter?: { lat: number; lng: number }
|
||||
maxTicketsPerUser?: number
|
||||
|
|
@ -13,59 +13,59 @@ export interface ActivitiesModuleConfig {
|
|||
}
|
||||
|
||||
/**
|
||||
* Activities Module Plugin
|
||||
* Events Module Plugin
|
||||
*
|
||||
* Nostr-native communal events module using NIP-52 Calendar Events
|
||||
* for discovery, with database-backed ticketing via LNbits.
|
||||
*/
|
||||
export const activitiesModule = createModulePlugin({
|
||||
name: 'activities',
|
||||
export const eventsModule = createModulePlugin({
|
||||
name: 'events',
|
||||
version: '1.0.0',
|
||||
dependencies: ['base'],
|
||||
|
||||
routes: [
|
||||
{
|
||||
path: '/activities',
|
||||
name: 'activities',
|
||||
component: () => import('./views/ActivitiesPage.vue'),
|
||||
path: '/events',
|
||||
name: 'events',
|
||||
component: () => import('./views/EventsPage.vue'),
|
||||
meta: {
|
||||
title: 'Activities',
|
||||
title: 'Events',
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/activities/calendar',
|
||||
name: 'activities-calendar',
|
||||
component: () => import('./views/ActivitiesCalendarPage.vue'),
|
||||
path: '/events/calendar',
|
||||
name: 'events-calendar',
|
||||
component: () => import('./views/EventsCalendarPage.vue'),
|
||||
meta: {
|
||||
title: 'Calendar',
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/activities/map',
|
||||
name: 'activities-map',
|
||||
component: () => import('./views/ActivitiesMapPage.vue'),
|
||||
path: '/events/map',
|
||||
name: 'events-map',
|
||||
component: () => import('./views/EventsMapPage.vue'),
|
||||
meta: {
|
||||
title: 'Map',
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/activities/favorites',
|
||||
name: 'activities-favorites',
|
||||
component: () => import('./views/ActivitiesFavoritesPage.vue'),
|
||||
path: '/events/favorites',
|
||||
name: 'events-favorites',
|
||||
component: () => import('./views/EventsFavoritesPage.vue'),
|
||||
meta: {
|
||||
title: 'Favorites',
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/activities/:id',
|
||||
name: 'activity-detail',
|
||||
component: () => import('./views/ActivityDetailPage.vue'),
|
||||
path: '/events/:id',
|
||||
name: 'event-detail',
|
||||
component: () => import('./views/EventDetailPage.vue'),
|
||||
meta: {
|
||||
title: 'Activity',
|
||||
title: 'Event',
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
|
|
@ -79,7 +79,7 @@ export const activitiesModule = createModulePlugin({
|
|||
},
|
||||
},
|
||||
{
|
||||
path: '/scan/:activityId',
|
||||
path: '/scan/:eventId',
|
||||
name: 'scan-tickets',
|
||||
component: () => import('./views/ScanTicketsPage.vue'),
|
||||
meta: {
|
||||
|
|
@ -88,12 +88,12 @@ export const activitiesModule = createModulePlugin({
|
|||
},
|
||||
},
|
||||
{
|
||||
path: '/events',
|
||||
name: 'events',
|
||||
component: () => import('./views/EventsPage.vue'),
|
||||
path: '/my-events',
|
||||
name: 'my-events',
|
||||
component: () => import('./views/MyEventsPage.vue'),
|
||||
meta: {
|
||||
title: 'Events',
|
||||
requiresAuth: false,
|
||||
title: 'My Events',
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -106,27 +106,27 @@ export const activitiesModule = createModulePlugin({
|
|||
{
|
||||
event: 'payment:completed',
|
||||
handler: (event) => {
|
||||
console.log('Activities module: payment completed', event.data)
|
||||
console.log('Events module: payment completed', event.data)
|
||||
},
|
||||
description: 'Handle payment completion for ticket purchases',
|
||||
},
|
||||
],
|
||||
|
||||
onInstall: async (_app, options) => {
|
||||
const config = options?.config as ActivitiesModuleConfig | undefined
|
||||
const config = options?.config as EventsModuleConfig | undefined
|
||||
if (!config) {
|
||||
throw new Error('Activities module requires configuration')
|
||||
throw new Error('Events module requires configuration')
|
||||
}
|
||||
|
||||
const { container } = await import('@/core/di-container')
|
||||
|
||||
// 1. Create services
|
||||
const nostrService = new ActivitiesNostrService()
|
||||
const nostrService = new EventsNostrService()
|
||||
const ticketApi = new TicketApiService(config.apiConfig)
|
||||
|
||||
// 2. Register in DI container BEFORE initialization
|
||||
container.provide(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE, nostrService)
|
||||
container.provide(SERVICE_TOKENS.ACTIVITIES_TICKET_API, ticketApi)
|
||||
container.provide(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE, nostrService)
|
||||
container.provide(SERVICE_TOKENS.EVENTS_TICKET_API, ticketApi)
|
||||
container.provide(SERVICE_TOKENS.TICKET_API, ticketApi)
|
||||
|
||||
// 3. Initialize the Nostr service (needs RelayHub dependency)
|
||||
|
|
@ -138,16 +138,16 @@ export const activitiesModule = createModulePlugin({
|
|||
|
||||
onUninstall: async () => {
|
||||
const { container } = await import('@/core/di-container')
|
||||
container.remove(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
||||
container.remove(SERVICE_TOKENS.ACTIVITIES_TICKET_API)
|
||||
container.remove(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||
container.remove(SERVICE_TOKENS.EVENTS_TICKET_API)
|
||||
container.remove(SERVICE_TOKENS.TICKET_API)
|
||||
},
|
||||
})
|
||||
|
||||
export default activitiesModule
|
||||
export default eventsModule
|
||||
|
||||
// Re-export types for external use
|
||||
export type { Activity, OrganizerInfo, ActivityTicketInfo } from './types/activity'
|
||||
export type { ActivityTicket, TicketStatus, TicketedEvent, CreateEventRequest } from './types/ticket'
|
||||
export type { ActivityCategory } from './types/category'
|
||||
export type { Event, OrganizerInfo, EventTicketInfo } from './types/event'
|
||||
export type { EventTicket, TicketStatus, TicketedEvent, CreateEventRequest } from './types/ticket'
|
||||
export type { EventCategory } from './types/category'
|
||||
export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52'
|
||||
|
|
@ -7,10 +7,10 @@ import {
|
|||
parseCalendarDateEvent,
|
||||
} from '../types/nip52'
|
||||
import {
|
||||
calendarTimeEventToActivity,
|
||||
calendarDateEventToActivity,
|
||||
type Activity,
|
||||
} from '../types/activity'
|
||||
calendarTimeEventToEvent,
|
||||
calendarDateEventToEvent,
|
||||
type Event,
|
||||
} from '../types/event'
|
||||
|
||||
export interface CalendarEventFilters {
|
||||
/** Only return events created after this timestamp */
|
||||
|
|
@ -35,13 +35,13 @@ export interface CalendarEventFilters {
|
|||
* 66076d6) — `POST /events/api/v1/events` constructs and signs the
|
||||
* event via NostrSigner and broadcasts it to the operator's configured
|
||||
* relays. The webapp constructs only the request payload; see
|
||||
* CreateActivityDialog for the flow.
|
||||
* CreateEventDialog for the flow.
|
||||
*
|
||||
* Extends BaseService for standardized dependency injection and lifecycle.
|
||||
*/
|
||||
export class ActivitiesNostrService extends BaseService {
|
||||
export class EventsNostrService extends BaseService {
|
||||
protected readonly metadata = {
|
||||
name: 'ActivitiesNostrService',
|
||||
name: 'EventsNostrService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['RelayHub'],
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export class ActivitiesNostrService extends BaseService {
|
|||
private activeUnsubscribes: Array<() => void> = []
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
this.debug('ActivitiesNostrService initialized')
|
||||
this.debug('EventsNostrService initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,7 +57,7 @@ export class ActivitiesNostrService extends BaseService {
|
|||
* Returns an unsubscribe function.
|
||||
*/
|
||||
subscribeToCalendarEvents(
|
||||
onActivity: (activity: Activity) => void,
|
||||
onEvent: (event: Event) => void,
|
||||
filters?: CalendarEventFilters
|
||||
): () => void {
|
||||
if (!this.relayHub) {
|
||||
|
|
@ -66,15 +66,15 @@ export class ActivitiesNostrService extends BaseService {
|
|||
|
||||
const nostrFilters = this.buildNostrFilters(filters)
|
||||
|
||||
const subscriptionId = `activities-calendar-${Date.now()}`
|
||||
const subscriptionId = `events-calendar-${Date.now()}`
|
||||
|
||||
const config: SubscriptionConfig = {
|
||||
id: subscriptionId,
|
||||
filters: nostrFilters,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
const activity = this.parseNostrEventToActivity(event)
|
||||
if (activity) {
|
||||
onActivity(activity)
|
||||
onEvent: (nostrEvent: NostrEvent) => {
|
||||
const event = this.parseNostrEventToEvent(nostrEvent)
|
||||
if (event) {
|
||||
onEvent(event)
|
||||
}
|
||||
},
|
||||
onEose: () => {
|
||||
|
|
@ -94,29 +94,29 @@ export class ActivitiesNostrService extends BaseService {
|
|||
/**
|
||||
* Query relays for calendar events (one-shot, not a subscription).
|
||||
*/
|
||||
async queryCalendarEvents(filters?: CalendarEventFilters): Promise<Activity[]> {
|
||||
async queryCalendarEvents(filters?: CalendarEventFilters): Promise<Event[]> {
|
||||
if (!this.relayHub) {
|
||||
throw new Error('RelayHub not available')
|
||||
}
|
||||
|
||||
const nostrFilters = this.buildNostrFilters(filters)
|
||||
const events: NostrEvent[] = await this.relayHub.queryEvents(nostrFilters)
|
||||
const nostrEvents: NostrEvent[] = await this.relayHub.queryEvents(nostrFilters)
|
||||
|
||||
const activities: Activity[] = []
|
||||
for (const event of events) {
|
||||
const activity = this.parseNostrEventToActivity(event)
|
||||
if (activity) {
|
||||
activities.push(activity)
|
||||
const events: Event[] = []
|
||||
for (const nostrEvent of nostrEvents) {
|
||||
const event = this.parseNostrEventToEvent(nostrEvent)
|
||||
if (event) {
|
||||
events.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
return activities
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw Nostr event into an Activity view model.
|
||||
* Parse a raw Nostr event into an Event view model.
|
||||
*/
|
||||
private parseNostrEventToActivity(event: NostrEvent): Activity | null {
|
||||
private parseNostrEventToEvent(event: NostrEvent): Event | null {
|
||||
// Skip task events — they reuse NIP-52 kinds but can be identified by
|
||||
// task-specific tags (event-type:task, status, recurrence)
|
||||
const tags = event.tags ?? []
|
||||
|
|
@ -126,12 +126,12 @@ export class ActivitiesNostrService extends BaseService {
|
|||
|
||||
if (event.kind === NIP52_KINDS.CALENDAR_TIME_EVENT) {
|
||||
const parsed = parseCalendarTimeEvent(event)
|
||||
if (parsed) return calendarTimeEventToActivity(parsed)
|
||||
if (parsed) return calendarTimeEventToEvent(parsed)
|
||||
}
|
||||
|
||||
if (event.kind === NIP52_KINDS.CALENDAR_DATE_EVENT) {
|
||||
const parsed = parseCalendarDateEvent(event)
|
||||
if (parsed) return calendarDateEventToActivity(parsed)
|
||||
if (parsed) return calendarDateEventToEvent(parsed)
|
||||
}
|
||||
|
||||
return null
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type {
|
||||
ActivityTicket,
|
||||
ActivityTicketExtra,
|
||||
EventTicket,
|
||||
EventTicketExtra,
|
||||
CreateTicketRequest,
|
||||
PaymentMethod,
|
||||
TicketPurchaseInvoice,
|
||||
|
|
@ -27,7 +27,7 @@ export class TicketApiService {
|
|||
|
||||
/**
|
||||
* Fetch all public events from the LNbits events extension.
|
||||
* Used to correlate Nostr activities with ticketed events.
|
||||
* Used to correlate Nostr events with ticketed events.
|
||||
*/
|
||||
async fetchTicketedEvents(): Promise<any[]> {
|
||||
const response = await this.request(
|
||||
|
|
@ -133,7 +133,7 @@ export class TicketApiService {
|
|||
async fetchUserTickets(
|
||||
userId: string,
|
||||
accessToken: string
|
||||
): Promise<ActivityTicket[]> {
|
||||
): Promise<EventTicket[]> {
|
||||
const data = await this.request(
|
||||
`/events/api/v1/tickets/user/${userId}`,
|
||||
{
|
||||
|
|
@ -147,7 +147,7 @@ export class TicketApiService {
|
|||
return (data as any[]).map(t => ({
|
||||
id: t.id,
|
||||
wallet: t.wallet,
|
||||
activityId: t.event,
|
||||
eventId: t.event,
|
||||
name: t.name,
|
||||
email: t.email,
|
||||
userId: t.user_id,
|
||||
|
|
@ -155,14 +155,14 @@ export class TicketApiService {
|
|||
paid: t.paid,
|
||||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
extra: t.extra as ActivityTicketExtra | undefined,
|
||||
extra: t.extra as EventTicketExtra | undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate/register a ticket at the door (scan).
|
||||
*/
|
||||
async validateTicket(ticketId: string): Promise<ActivityTicket[]> {
|
||||
async validateTicket(ticketId: string): Promise<EventTicket[]> {
|
||||
const data = await this.request(
|
||||
`/events/api/v1/register/ticket/${ticketId}`,
|
||||
{ method: 'GET' }
|
||||
|
|
@ -171,7 +171,7 @@ export class TicketApiService {
|
|||
return (data as any[]).map(t => ({
|
||||
id: t.id,
|
||||
wallet: t.wallet,
|
||||
activityId: t.event,
|
||||
eventId: t.event,
|
||||
name: t.name,
|
||||
email: t.email,
|
||||
userId: t.user_id,
|
||||
|
|
@ -179,7 +179,7 @@ export class TicketApiService {
|
|||
paid: t.paid,
|
||||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
extra: t.extra as ActivityTicketExtra | undefined,
|
||||
extra: t.extra as EventTicketExtra | undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +229,7 @@ export class TicketApiService {
|
|||
async resendTicketEmail(
|
||||
ticketId: string,
|
||||
adminKey: string,
|
||||
): Promise<ActivityTicket> {
|
||||
): Promise<EventTicket> {
|
||||
const t = await this.request(
|
||||
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
||||
{
|
||||
|
|
@ -240,7 +240,7 @@ export class TicketApiService {
|
|||
return {
|
||||
id: t.id,
|
||||
wallet: t.wallet,
|
||||
activityId: t.event,
|
||||
eventId: t.event,
|
||||
name: t.name,
|
||||
email: t.email,
|
||||
userId: t.user_id,
|
||||
|
|
@ -248,7 +248,7 @@ export class TicketApiService {
|
|||
paid: t.paid,
|
||||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
extra: t.extra as ActivityTicketExtra | undefined,
|
||||
extra: t.extra as EventTicketExtra | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +286,7 @@ export class TicketApiService {
|
|||
* unpaid / already-registered / not-owned cases with HTTP errors
|
||||
* whose `detail` becomes the thrown Error message.
|
||||
*/
|
||||
async registerTicket(ticketId: string, adminKey: string): Promise<ActivityTicket> {
|
||||
async registerTicket(ticketId: string, adminKey: string): Promise<EventTicket> {
|
||||
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-API-KEY': adminKey },
|
||||
|
|
@ -294,7 +294,7 @@ export class TicketApiService {
|
|||
return {
|
||||
id: t.id,
|
||||
wallet: t.wallet,
|
||||
activityId: t.event,
|
||||
eventId: t.event,
|
||||
name: t.name,
|
||||
email: t.email,
|
||||
userId: t.user_id,
|
||||
|
|
@ -302,7 +302,7 @@ export class TicketApiService {
|
|||
paid: t.paid,
|
||||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
extra: t.extra as ActivityTicketExtra | undefined,
|
||||
extra: t.extra as EventTicketExtra | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,37 +1,37 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Activity } from '../types/activity'
|
||||
import type { Event } from '../types/event'
|
||||
import type { TicketedEvent } from '../types/ticket'
|
||||
|
||||
/**
|
||||
* Pinia store for cached activities from Nostr relays.
|
||||
* Pinia store for cached events from Nostr relays.
|
||||
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
|
||||
*/
|
||||
export const useActivitiesStore = defineStore('activities', () => {
|
||||
export const useEventsStore = defineStore('events', () => {
|
||||
// State
|
||||
const activitiesMap = ref<Map<string, Activity>>(new Map())
|
||||
const eventsMap = ref<Map<string, Event>>(new Map())
|
||||
const isLoading = ref(false)
|
||||
const lastUpdated = ref<Date | null>(null)
|
||||
/** Toggle by the standalone bottom-nav Create tab; mounted dialog lives
|
||||
* in activities-app/App.vue so it's available from every route. */
|
||||
* in events-app/App.vue so it's available from every route. */
|
||||
const showCreateDialog = ref(false)
|
||||
/** When set, the shell-mounted CreateEventDialog opens in edit mode
|
||||
* for this LNbits event. Cleared when the dialog closes. */
|
||||
const editingEvent = ref<TicketedEvent | null>(null)
|
||||
|
||||
// Computed
|
||||
const activities = computed(() => Array.from(activitiesMap.value.values()))
|
||||
const events = computed(() => Array.from(eventsMap.value.values()))
|
||||
|
||||
const upcomingActivities = computed(() => {
|
||||
const upcomingEvents = computed(() => {
|
||||
const now = new Date()
|
||||
return activities.value
|
||||
return events.value
|
||||
.filter(a => a.startDate >= now || (a.endDate && a.endDate >= now))
|
||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||
})
|
||||
|
||||
const pastActivities = computed(() => {
|
||||
const pastEvents = computed(() => {
|
||||
const now = new Date()
|
||||
return activities.value
|
||||
return events.value
|
||||
.filter(a => {
|
||||
const endOrStart = a.endDate ?? a.startDate
|
||||
return endOrStart < now
|
||||
|
|
@ -42,68 +42,68 @@ export const useActivitiesStore = defineStore('activities', () => {
|
|||
// Actions
|
||||
|
||||
/**
|
||||
* Add or update an activity in the store.
|
||||
* Add or update an event in the store.
|
||||
* Deduplicates by id (d-tag). Newer events replace older ones.
|
||||
*/
|
||||
function upsertActivity(activity: Activity) {
|
||||
const existing = activitiesMap.value.get(activity.id)
|
||||
function upsertEvent(event: Event) {
|
||||
const existing = eventsMap.value.get(event.id)
|
||||
|
||||
// Only update if this is a newer version
|
||||
if (!existing || activity.createdAt >= existing.createdAt) {
|
||||
activitiesMap.value.set(activity.id, activity)
|
||||
if (!existing || event.createdAt >= existing.createdAt) {
|
||||
eventsMap.value.set(event.id, event)
|
||||
lastUpdated.value = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple activities (batch upsert).
|
||||
* Add multiple events (batch upsert).
|
||||
*/
|
||||
function upsertActivities(newActivities: Activity[]) {
|
||||
for (const activity of newActivities) {
|
||||
upsertActivity(activity)
|
||||
function upsertEvents(newEvents: Event[]) {
|
||||
for (const event of newEvents) {
|
||||
upsertEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an activity from the store.
|
||||
* Remove an event from the store.
|
||||
*/
|
||||
function removeActivity(id: string) {
|
||||
activitiesMap.value.delete(id)
|
||||
function removeEvent(id: string) {
|
||||
eventsMap.value.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached activities.
|
||||
* Clear all cached events.
|
||||
*/
|
||||
function clearAll() {
|
||||
activitiesMap.value.clear()
|
||||
eventsMap.value.clear()
|
||||
lastUpdated.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single activity by its id (d-tag).
|
||||
* Get a single event by its id (d-tag).
|
||||
*/
|
||||
function getActivityById(id: string): Activity | undefined {
|
||||
return activitiesMap.value.get(id)
|
||||
function getEventById(id: string): Event | undefined {
|
||||
return eventsMap.value.get(id)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
activitiesMap,
|
||||
eventsMap,
|
||||
isLoading,
|
||||
lastUpdated,
|
||||
showCreateDialog,
|
||||
editingEvent,
|
||||
|
||||
// Computed
|
||||
activities,
|
||||
upcomingActivities,
|
||||
pastActivities,
|
||||
events,
|
||||
upcomingEvents,
|
||||
pastEvents,
|
||||
|
||||
// Actions
|
||||
upsertActivity,
|
||||
upsertActivities,
|
||||
removeActivity,
|
||||
upsertEvent,
|
||||
upsertEvents,
|
||||
removeEvent,
|
||||
clearAll,
|
||||
getActivityById,
|
||||
getEventById,
|
||||
}
|
||||
})
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Activity categories inspired by p'a semana
|
||||
* Event categories inspired by p'a semana
|
||||
* Mapped to NIP-52 't' (hashtag) tags
|
||||
*/
|
||||
export const ACTIVITY_CATEGORIES = {
|
||||
export const EVENT_CATEGORIES = {
|
||||
concert: 'concert',
|
||||
workshop: 'workshop',
|
||||
market: 'market',
|
||||
|
|
@ -30,6 +30,6 @@ export const ACTIVITY_CATEGORIES = {
|
|||
other: 'other',
|
||||
} as const
|
||||
|
||||
export type ActivityCategory = typeof ACTIVITY_CATEGORIES[keyof typeof ACTIVITY_CATEGORIES]
|
||||
export type EventCategory = typeof EVENT_CATEGORIES[keyof typeof EVENT_CATEGORIES]
|
||||
|
||||
export const ALL_CATEGORIES = Object.values(ACTIVITY_CATEGORIES)
|
||||
export const ALL_CATEGORIES = Object.values(EVENT_CATEGORIES)
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import ngeohash from 'ngeohash'
|
||||
import type { ActivityCategory } from './category'
|
||||
import type { EventCategory } from './category'
|
||||
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
|
||||
import type { TicketedEvent } from './ticket'
|
||||
|
||||
/**
|
||||
* Unified view model for displaying activities in the UI.
|
||||
* Unified view model for displaying events in the UI.
|
||||
* Created from NIP-52 CalendarTimeEvent or CalendarDateEvent.
|
||||
*/
|
||||
export interface Activity {
|
||||
export interface Event {
|
||||
/** Unique identifier (NIP-52 d-tag) */
|
||||
id: string
|
||||
/** Nostr event ID */
|
||||
|
|
@ -16,7 +16,7 @@ export interface Activity {
|
|||
type: 'date' | 'time'
|
||||
/** Organizer information */
|
||||
organizer: OrganizerInfo
|
||||
/** Activity title */
|
||||
/** Event title */
|
||||
title: string
|
||||
/** Brief summary */
|
||||
summary?: string
|
||||
|
|
@ -37,18 +37,18 @@ export interface Activity {
|
|||
/** NIP-52 geohash (g tag) */
|
||||
geohash?: string
|
||||
/** Primary category */
|
||||
category?: ActivityCategory
|
||||
category?: EventCategory
|
||||
/** All hashtags/tags */
|
||||
tags: string[]
|
||||
/** Ticket pricing info (if ticketed) */
|
||||
ticketInfo?: ActivityTicketInfo
|
||||
ticketInfo?: EventTicketInfo
|
||||
/** Whether this is a private/invite-only event */
|
||||
isPrivate: boolean
|
||||
/** Nostr event created_at timestamp */
|
||||
createdAt: Date
|
||||
/**
|
||||
* LNbits approval status, when the activity came from the events
|
||||
* extension rather than a Nostr relay. Undefined for activities
|
||||
* LNbits approval status, when the event came from the events
|
||||
* extension rather than a Nostr relay. Undefined for events
|
||||
* sourced from Nostr (approved by definition — only published
|
||||
* events make it onto relays). Used to render a "Pending review"
|
||||
* badge for the creator's own non-approved drafts.
|
||||
|
|
@ -56,7 +56,7 @@ export interface Activity {
|
|||
lnbitsStatus?: 'approved' | 'proposed' | 'rejected'
|
||||
/**
|
||||
* Belongs to the current user. Set by the adapter for own LNbits
|
||||
* drafts and by the activities-subscribe callback when the Nostr
|
||||
* drafts and by the events-subscribe callback when the Nostr
|
||||
* organizer pubkey matches the logged-in user. Used to render a
|
||||
* "Yours" badge on the feed so the creator can spot their events
|
||||
* at a glance.
|
||||
|
|
@ -71,7 +71,7 @@ export interface OrganizerInfo {
|
|||
nip05?: string
|
||||
}
|
||||
|
||||
export interface ActivityTicketInfo {
|
||||
export interface EventTicketInfo {
|
||||
price: number
|
||||
currency: string
|
||||
/** Remaining capacity. Undefined means unlimited. */
|
||||
|
|
@ -84,7 +84,7 @@ export interface ActivityTicketInfo {
|
|||
fiatCurrency?: string
|
||||
}
|
||||
|
||||
function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo | undefined {
|
||||
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
|
||||
if (!ticket) return undefined
|
||||
return {
|
||||
price: ticket.price,
|
||||
|
|
@ -97,10 +97,10 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo |
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert a CalendarTimeEvent to an Activity view model
|
||||
* Convert a CalendarTimeEvent to an Event view model
|
||||
*/
|
||||
export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Activity {
|
||||
const category = event.hashtags[0] as ActivityCategory | undefined
|
||||
export function calendarTimeEventToEvent(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Event {
|
||||
const category = event.hashtags[0] as EventCategory | undefined
|
||||
|
||||
return {
|
||||
id: event.dTag,
|
||||
|
|
@ -129,10 +129,10 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert a CalendarDateEvent to an Activity view model
|
||||
* Convert a CalendarDateEvent to an Event view model
|
||||
*/
|
||||
export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Activity {
|
||||
const category = event.hashtags[0] as ActivityCategory | undefined
|
||||
export function calendarDateEventToEvent(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Event {
|
||||
const category = event.hashtags[0] as EventCategory | undefined
|
||||
|
||||
// Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC
|
||||
const parseIsoDate = (dateStr: string): Date => {
|
||||
|
|
@ -166,21 +166,21 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert an LNbits TicketedEvent to an Activity view model.
|
||||
* Convert an LNbits TicketedEvent to an Event view model.
|
||||
*
|
||||
* Used to surface the caller's own pending events on the activities
|
||||
* feed alongside Nostr-published activities. Once an event is approved
|
||||
* and published, the Nostr-derived Activity (newer createdAt) wins on
|
||||
* upsert in the activities store and this draft version is replaced.
|
||||
* Used to surface the caller's own pending events on the events
|
||||
* feed alongside Nostr-published events. Once an event is approved
|
||||
* and published, the Nostr-derived Event (newer createdAt) wins on
|
||||
* upsert in the events store and this draft version is replaced.
|
||||
*
|
||||
* The wire format for dates mirrors how nostr_publisher emits NIP-52:
|
||||
* - "YYYY-MM-DD" → date-based (kind 31922 on publish)
|
||||
* - "YYYY-MM-DDTHH:MM..." → time-based (kind 31923 on publish)
|
||||
*/
|
||||
export function ticketedEventToActivity(
|
||||
export function ticketedEventToEvent(
|
||||
event: TicketedEvent,
|
||||
organizer?: Partial<OrganizerInfo>,
|
||||
): Activity {
|
||||
): Event {
|
||||
const hasTime = event.event_start_date.includes('T')
|
||||
const startDate = hasTime
|
||||
? new Date(event.event_start_date)
|
||||
|
|
@ -192,7 +192,7 @@ export function ticketedEventToActivity(
|
|||
: parseDateOnly(endRaw)
|
||||
: undefined
|
||||
|
||||
const category = event.categories?.[0] as ActivityCategory | undefined
|
||||
const category = event.categories?.[0] as EventCategory | undefined
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
|
|
@ -204,7 +204,7 @@ export function ticketedEventToActivity(
|
|||
organizer: {
|
||||
// Pending events have no Nostr pubkey yet. Empty string is fine
|
||||
// — the card layer falls back gracefully and the OrganizerCard
|
||||
// is only shown for approved (Nostr-sourced) activities anyway.
|
||||
// is only shown for approved (Nostr-sourced) events anyway.
|
||||
pubkey: '',
|
||||
...organizer,
|
||||
},
|
||||
|
|
@ -221,7 +221,7 @@ export function ticketedEventToActivity(
|
|||
// FastAPI serialization). new Date() handles both ISO strings and
|
||||
// numeric epoch — same shape used in useEvents sorting.
|
||||
createdAt: new Date(event.time) || new Date(),
|
||||
lnbitsStatus: event.status as Activity['lnbitsStatus'],
|
||||
lnbitsStatus: event.status as Event['lnbitsStatus'],
|
||||
// fetchMyEvents only returns the caller's own events, so anything
|
||||
// reaching this adapter is by definition mine.
|
||||
isMine: true,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ActivityCategory } from './category'
|
||||
import type { EventCategory } from './category'
|
||||
|
||||
/**
|
||||
* Temporal filter presets (p'a semana style)
|
||||
|
|
@ -6,11 +6,11 @@ import type { ActivityCategory } from './category'
|
|||
export type TemporalFilter = 'all' | 'today' | 'tomorrow' | 'this-week' | 'this-month'
|
||||
|
||||
/**
|
||||
* Combined filter state for activity discovery
|
||||
* Combined filter state for event discovery
|
||||
*/
|
||||
export interface ActivityFilters {
|
||||
export interface EventFilters {
|
||||
temporal: TemporalFilter
|
||||
categories: ActivityCategory[]
|
||||
categories: EventCategory[]
|
||||
/** Free text search */
|
||||
search?: string
|
||||
/** Geohash prefix for geographic filtering */
|
||||
|
|
@ -22,7 +22,7 @@ export interface ActivityFilters {
|
|||
/**
|
||||
* Default filter state
|
||||
*/
|
||||
export const DEFAULT_FILTERS: ActivityFilters = {
|
||||
export const DEFAULT_FILTERS: EventFilters = {
|
||||
temporal: 'all',
|
||||
categories: [],
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* Database-backed ticket types (via LNbits events extension).
|
||||
*
|
||||
* Wire-format types — names match the snake_case fields the events
|
||||
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
|
||||
* extension serves over HTTP. Camel-cased aliases (e.g. EventTicket
|
||||
* below) are the webapp-internal view models after adapter conversion.
|
||||
*/
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ export interface EventExtra {
|
|||
notification_body: string
|
||||
}
|
||||
|
||||
export interface ActivityTicketExtra {
|
||||
export interface EventTicketExtra {
|
||||
applied_promo_code?: string | null
|
||||
sats_paid?: number | null
|
||||
refund_address?: string | null
|
||||
|
|
@ -39,11 +39,11 @@ export interface ActivityTicketExtra {
|
|||
refunded: boolean
|
||||
}
|
||||
|
||||
export interface ActivityTicket {
|
||||
export interface EventTicket {
|
||||
id: string
|
||||
wallet: string
|
||||
/** Reference to the activity (LNbits event ID) */
|
||||
activityId: string
|
||||
/** Reference to the event (LNbits event ID) */
|
||||
eventId: string
|
||||
/** Ticket holder name */
|
||||
name: string | null
|
||||
/** Ticket holder email */
|
||||
|
|
@ -60,7 +60,7 @@ export interface ActivityTicket {
|
|||
regTimestamp: string
|
||||
/** Optional metadata — promo code applied, sats paid, notification
|
||||
* delivery flags, refund state. May be absent on older tickets. */
|
||||
extra?: ActivityTicketExtra
|
||||
extra?: EventTicketExtra
|
||||
}
|
||||
|
||||
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
||||
|
|
@ -68,7 +68,7 @@ export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
|||
export type PaymentMethod = 'lightning' | 'fiat'
|
||||
|
||||
export interface TicketPurchaseRequest {
|
||||
activityId: string
|
||||
eventId: string
|
||||
userId: string
|
||||
accessToken: string
|
||||
/** Lightning (default) or fiat. Only meaningful if the event has
|
||||
|
|
@ -10,14 +10,14 @@ import { Separator } from '@/components/ui/separator'
|
|||
import {
|
||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
|
||||
} from 'lucide-vue-next'
|
||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||
import { useEventDetail } from '../composables/useEventDetail'
|
||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||
import RSVPButton from '../components/RSVPButton.vue'
|
||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||
import { NIP52_KINDS } from '../types/nip52'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useActivitiesStore } from '../stores/activities'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||
import { toastService } from '@/core/services/ToastService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
|
|
@ -28,16 +28,16 @@ const route = useRoute()
|
|||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activityId = route.params.id as string
|
||||
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
|
||||
const eventId = route.params.id as string
|
||||
const { event, isLoading, error, reload } = useEventDetail(eventId)
|
||||
const { dateLocale } = useDateLocale()
|
||||
|
||||
// Owner-edit affordance: the NIP-52 d-tag we use for the activity id is
|
||||
// Owner-edit affordance: the NIP-52 d-tag we use for the event id is
|
||||
// the same as the LNbits event id (set at publish time in
|
||||
// nostr_publisher.build_nip52_event). Look the user's own events up
|
||||
// once and offer an Edit button on a match.
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const activitiesStore = useActivitiesStore()
|
||||
const eventsStore = useEventsStore()
|
||||
const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
|
||||
|
||||
async function loadOwnedEvent() {
|
||||
|
|
@ -49,7 +49,7 @@ async function loadOwnedEvent() {
|
|||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
const mine = await ticketApi.fetchMyEvents(invoiceKey)
|
||||
ownedLnbitsEvent.value =
|
||||
(mine as TicketedEvent[]).find((e) => e.id === activityId) ?? null
|
||||
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null
|
||||
} catch {
|
||||
ownedLnbitsEvent.value = null
|
||||
}
|
||||
|
|
@ -60,17 +60,17 @@ watch(isAuthenticated, () => loadOwnedEvent())
|
|||
|
||||
function openEditDialog() {
|
||||
if (!ownedLnbitsEvent.value) return
|
||||
activitiesStore.editingEvent = ownedLnbitsEvent.value
|
||||
activitiesStore.showCreateDialog = true
|
||||
eventsStore.editingEvent = ownedLnbitsEvent.value
|
||||
eventsStore.showCreateDialog = true
|
||||
}
|
||||
|
||||
function openScannerPage() {
|
||||
router.push({ name: 'scan-tickets', params: { activityId } })
|
||||
router.push({ name: 'scan-tickets', params: { eventId } })
|
||||
}
|
||||
|
||||
const dateDisplay = computed(() => {
|
||||
if (!activity.value) return ''
|
||||
const a = activity.value
|
||||
if (!event.value) return ''
|
||||
const a = event.value
|
||||
const opts = { locale: dateLocale.value }
|
||||
if (a.type === 'date') {
|
||||
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
|
||||
|
|
@ -94,22 +94,22 @@ const dateDisplay = computed(() => {
|
|||
})
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
if (!activity.value?.category) return null
|
||||
return t(`activities.categories.${activity.value.category}`, activity.value.category)
|
||||
if (!event.value?.category) return null
|
||||
return t(`events.categories.${event.value.category}`, event.value.category)
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'activities' })
|
||||
router.push({ name: 'events' })
|
||||
}
|
||||
|
||||
// --- Ticket purchase + owned-tickets surface ----------------------
|
||||
|
||||
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||
|
||||
const ownedPaidCount = computed(() => paidCount(activityId))
|
||||
const ownedPaidCount = computed(() => paidCount(eventId))
|
||||
|
||||
const purchaseEvent = computed(() => {
|
||||
const a = activity.value
|
||||
const a = event.value
|
||||
if (!a || !a.ticketInfo) return null
|
||||
return {
|
||||
id: a.id,
|
||||
|
|
@ -125,7 +125,7 @@ const purchaseEvent = computed(() => {
|
|||
// available === 0 → sold out, button hidden
|
||||
// available > 0 → button shown
|
||||
const canBuyTicket = computed(() => {
|
||||
const info = activity.value?.ticketInfo
|
||||
const info = event.value?.ticketInfo
|
||||
if (!info) return false
|
||||
return info.available === undefined || info.available > 0
|
||||
})
|
||||
|
|
@ -134,7 +134,7 @@ const canBuyTicket = computed(() => {
|
|||
// buy CTA so the flow is unambiguous — date alone is easy to miss
|
||||
// on a long detail page.
|
||||
const isPast = computed(() => {
|
||||
const a = activity.value
|
||||
const a = event.value
|
||||
if (!a) return false
|
||||
const end = a.endDate ?? a.startDate
|
||||
if (!end || isNaN(end.getTime())) return false
|
||||
|
|
@ -145,9 +145,9 @@ const showPurchaseDialog = ref(false)
|
|||
|
||||
function openPurchaseDialog() {
|
||||
if (!isAuthenticated.value) {
|
||||
toastService.info(t('activities.detail.loginToBuyTickets'), {
|
||||
toastService.info(t('events.detail.loginToBuyTickets'), {
|
||||
action: {
|
||||
label: t('activities.detail.logIn'),
|
||||
label: t('events.detail.logIn'),
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
|
|
@ -200,9 +200,9 @@ function goToMyTickets() {
|
|||
Edit
|
||||
</Button>
|
||||
<BookmarkButton
|
||||
v-if="activity"
|
||||
:pubkey="activity.organizer.pubkey"
|
||||
:d-tag="activity.id"
|
||||
v-if="event"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.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">Activity not found</h2>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-2">Event not found</h2>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button variant="outline" @click="reload">Retry</Button>
|
||||
</div>
|
||||
|
||||
<!-- Detail content -->
|
||||
<div v-else-if="activity" class="space-y-6">
|
||||
<div v-else-if="event" class="space-y-6">
|
||||
<!-- Hero image -->
|
||||
<div v-if="activity.image" class="rounded-lg overflow-hidden">
|
||||
<div v-if="event.image" class="rounded-lg overflow-hidden">
|
||||
<img
|
||||
:src="activity.image"
|
||||
:alt="activity.title"
|
||||
:src="event.image"
|
||||
:alt="event.title"
|
||||
class="w-full aspect-[16/9] object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -240,28 +240,28 @@ function goToMyTickets() {
|
|||
{{ categoryLabel }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
|
||||
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
class="shrink-0 mt-1 capitalize"
|
||||
>
|
||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="activity.isMine"
|
||||
v-if="event.isMine"
|
||||
variant="outline"
|
||||
class="shrink-0 mt-1"
|
||||
>
|
||||
Yours
|
||||
</Badge>
|
||||
<div v-for="tag in activity.tags.slice(1)" :key="tag">
|
||||
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||
{{ activity.title }}
|
||||
{{ event.title }}
|
||||
</h1>
|
||||
<p v-if="activity.summary" class="text-muted-foreground mt-2">
|
||||
{{ activity.summary }}
|
||||
<p v-if="event.summary" class="text-muted-foreground mt-2">
|
||||
{{ event.summary }}
|
||||
</p>
|
||||
</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('activities.detail.when') }}
|
||||
{{ t('events.detail.when') }}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
|
||||
<p v-if="activity.timezone" class="text-xs text-muted-foreground/70">
|
||||
{{ activity.timezone }}
|
||||
<p v-if="event.timezone" class="text-xs text-muted-foreground/70">
|
||||
{{ event.timezone }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Where -->
|
||||
<div v-if="activity.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
|
||||
<div v-if="event.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<MapPin class="w-4 h-4" />
|
||||
{{ t('activities.detail.location') }}
|
||||
{{ t('events.detail.location') }}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ activity.location }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ event.location }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP -->
|
||||
<!-- The NIP-52 RSVP `a` tag must reference the activity's actual kind
|
||||
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
|
||||
(31922 for date-based, 31923 for time-based). Without this prop the
|
||||
button would default to time-based for every activity, leaving RSVPs
|
||||
on date-based activities pointing at a non-existent event coord. -->
|
||||
button would default to time-based for every event, leaving RSVPs
|
||||
on date-based events pointing at a non-existent event coord. -->
|
||||
<RSVPButton
|
||||
:pubkey="activity.organizer.pubkey"
|
||||
:d-tag="activity.id"
|
||||
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
||||
/>
|
||||
|
||||
<!-- Tickets — gated on the activity carrying ticketInfo (set
|
||||
by the calendar→Activity converter from the AIO custom
|
||||
<!-- Tickets — gated on the event carrying ticketInfo (set
|
||||
by the calendar→Event 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="activity.ticketInfo" class="space-y-3">
|
||||
<div v-if="event.ticketInfo" class="space-y-3">
|
||||
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Ticket class="w-4 h-4 shrink-0" />
|
||||
<span v-if="activity.ticketInfo.available === undefined">
|
||||
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else-if="activity.ticketInfo.available > 0">
|
||||
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
|
||||
<span v-else-if="event.ticketInfo.available > 0">
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
</span>
|
||||
<span v-else class="text-destructive font-medium">
|
||||
{{ t('activities.detail.soldOut') }}
|
||||
{{ t('events.detail.soldOut') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -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('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
||||
{{ t('events.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('activities.detail.viewMyTickets', 'View in My Tickets') }}
|
||||
{{ t('events.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('activities.detail.pastEvent', 'This event has already happened') }}
|
||||
{{ t('events.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('activities.detail.buyAnotherTicket', 'Buy another ticket')
|
||||
: t('activities.detail.buyTicket', 'Buy ticket') }}
|
||||
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
||||
: t('events.detail.buyTicket', 'Buy ticket') }}
|
||||
<span class="ml-2 opacity-80 font-normal">
|
||||
{{ activity.ticketInfo.price === 0
|
||||
? t('activities.detail.free')
|
||||
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
|
||||
{{ event.ticketInfo.price === 0
|
||||
? t('events.detail.free')
|
||||
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -364,7 +364,7 @@ function goToMyTickets() {
|
|||
v-else-if="ownedPaidCount === 0"
|
||||
class="text-sm text-destructive text-center"
|
||||
>
|
||||
{{ t('activities.detail.soldOut') }}
|
||||
{{ t('events.detail.soldOut') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -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('activities.detail.organizer') }}
|
||||
{{ t('events.detail.organizer') }}
|
||||
</p>
|
||||
<OrganizerCard :pubkey="activity.organizer.pubkey" />
|
||||
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Description -->
|
||||
<div class="prose prose-sm max-w-none text-foreground">
|
||||
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
|
||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- External references -->
|
||||
<div v-if="activity.tags.length > 0" class="space-y-2">
|
||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||
<!-- References would go here in future phases -->
|
||||
</div>
|
||||
</div>
|
||||
27
src/modules/events/views/EventsCalendarPage.vue
Normal file
27
src/modules/events/views/EventsCalendarPage.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import EventCalendarView from '../components/EventCalendarView.vue'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const router = useRouter()
|
||||
const { allEvents, subscribe } = useEvents()
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
})
|
||||
|
||||
function handleSelectEvent(event: Event) {
|
||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<EventCalendarView
|
||||
:events="allEvents"
|
||||
@select-event="handleSelectEvent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -7,29 +7,29 @@ import { Button } from '@/components/ui/button'
|
|||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useBookmarks } from '../composables/useBookmarks'
|
||||
import { useActivitiesStore } from '../stores/activities'
|
||||
import ActivityList from '../components/ActivityList.vue'
|
||||
import type { Activity } from '../types/activity'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import EventList from '../components/EventList.vue'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { isBookmarkedByDTag, isLoaded } = useBookmarks()
|
||||
const store = useActivitiesStore()
|
||||
const store = useEventsStore()
|
||||
|
||||
const favoriteActivities = computed(() => {
|
||||
return store.activities.filter(a => isBookmarkedByDTag(a.id))
|
||||
const favoriteEvents = computed(() => {
|
||||
return store.events.filter(a => isBookmarkedByDTag(a.id))
|
||||
})
|
||||
|
||||
function handleSelect(activity: Activity) {
|
||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||
function handleSelect(event: Event) {
|
||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAuthenticated.value) {
|
||||
toast.info(t('activities.favorites.loginPrompt'), {
|
||||
toast.info(t('events.favorites.loginPrompt'), {
|
||||
action: {
|
||||
label: t('activities.favorites.logIn'),
|
||||
label: t('events.favorites.logIn'),
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
|
|
@ -39,14 +39,14 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-4">{{ t('activities.favorites.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold text-foreground mb-4">{{ t('events.favorites.title') }}</h1>
|
||||
|
||||
<!-- Not authenticated -->
|
||||
<div v-if="!isAuthenticated" class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Heart class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||
<p class="text-muted-foreground mb-3">{{ t('activities.favorites.loginPrompt') }}</p>
|
||||
<p class="text-muted-foreground mb-3">{{ t('events.favorites.loginPrompt') }}</p>
|
||||
<Button variant="outline" size="sm" @click="router.push('/login')">
|
||||
{{ t('activities.favorites.logIn') }}
|
||||
{{ t('events.favorites.logIn') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -56,16 +56,16 @@ onMounted(() => {
|
|||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="favoriteActivities.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div v-else-if="favoriteEvents.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Heart class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||
<p class="text-muted-foreground">{{ t('activities.favorites.empty') }}</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">{{ t('activities.favorites.emptyHint') }}</p>
|
||||
<p class="text-muted-foreground">{{ t('events.favorites.empty') }}</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">{{ t('events.favorites.emptyHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Favorites list -->
|
||||
<ActivityList
|
||||
<EventList
|
||||
v-else
|
||||
:activities="favoriteActivities"
|
||||
:events="favoriteEvents"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
55
src/modules/events/views/EventsMapPage.vue
Normal file
55
src/modules/events/views/EventsMapPage.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { Map } from 'lucide-vue-next'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import EventMap from '../components/EventMap.vue'
|
||||
|
||||
const { allEvents, isLoading, subscribe } = useEvents()
|
||||
|
||||
function parseMapCenter(): { lat: number; lng: number } | undefined {
|
||||
const raw = import.meta.env.VITE_DEFAULT_MAP_CENTER
|
||||
if (!raw) return undefined
|
||||
const [lat, lng] = raw.split(',').map(Number)
|
||||
if (isNaN(lat) || isNaN(lng)) return undefined
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
const mapCenter = parseMapCenter()
|
||||
|
||||
const geoEvents = computed(() =>
|
||||
allEvents.value.filter(a => a.coordinates)
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-[calc(100dvh-3.5rem)]">
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="isLoading && geoEvents.length === 0" class="flex-1 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
|
||||
<!-- No geotagged events -->
|
||||
<div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<p class="text-muted-foreground">No geotagged events found</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<EventMap
|
||||
v-else
|
||||
:events="geoEvents"
|
||||
:center="mapCenter"
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<!-- Event count -->
|
||||
<div v-if="geoEvents.length > 0" class="px-4 py-2 text-xs text-muted-foreground border-t bg-background">
|
||||
{{ geoEvents.length }} event{{ geoEvents.length === 1 ? '' : 's' }} on map
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -9,20 +9,20 @@ import {
|
|||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
|
||||
import { useActivities } from '../composables/useActivities'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
|
||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
import DatePickerStrip from '../components/DatePickerStrip.vue'
|
||||
import ActivityList from '../components/ActivityList.vue'
|
||||
import type { Activity } from '../types/activity'
|
||||
import EventList from '../components/EventList.vue'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
activities,
|
||||
events,
|
||||
isLoading,
|
||||
error,
|
||||
temporal,
|
||||
|
|
@ -41,7 +41,7 @@ const {
|
|||
togglePast,
|
||||
resetFilters,
|
||||
subscribe,
|
||||
} = useActivities()
|
||||
} = useEvents()
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
|
|
@ -51,8 +51,8 @@ onMounted(() => {
|
|||
subscribe()
|
||||
})
|
||||
|
||||
function handleSelectActivity(activity: Activity) {
|
||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||
function handleSelectEvent(event: Event) {
|
||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -61,15 +61,15 @@ function handleSelectActivity(activity: Activity) {
|
|||
<!-- Page header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||
{{ t('activities.title') }}
|
||||
{{ t('events.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Search with dropdown overlay -->
|
||||
<div class="mb-4">
|
||||
<ActivitySearchOverlay
|
||||
:activities="activities"
|
||||
@select="handleSelectActivity"
|
||||
<EventSearchOverlay
|
||||
:events="events"
|
||||
@select="handleSelectEvent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ function handleSelectActivity(activity: Activity) {
|
|||
</div>
|
||||
|
||||
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
||||
"Hosting") narrow the feed to activities the signed-in user
|
||||
"Hosting") narrow the feed to events the signed-in user
|
||||
has skin in and are hidden when logged out. The "Past events"
|
||||
chip is always visible since past-browsing doesn't require an
|
||||
account. -->
|
||||
|
|
@ -97,7 +97,7 @@ function handleSelectActivity(activity: Activity) {
|
|||
@click="toggleOwnedTickets"
|
||||
>
|
||||
<Ticket class="w-3.5 h-3.5" />
|
||||
{{ t('activities.filters.myTickets', 'My tickets') }}
|
||||
{{ t('events.filters.myTickets', 'My tickets') }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="onlyHosting ? 'default' : 'outline'"
|
||||
|
|
@ -106,7 +106,7 @@ function handleSelectActivity(activity: Activity) {
|
|||
@click="toggleHosting"
|
||||
>
|
||||
<Megaphone class="w-3.5 h-3.5" />
|
||||
{{ t('activities.filters.hosting', 'Hosting') }}
|
||||
{{ t('events.filters.hosting', 'Hosting') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
|
|
@ -116,7 +116,7 @@ function handleSelectActivity(activity: Activity) {
|
|||
@click="togglePast"
|
||||
>
|
||||
<History class="w-3.5 h-3.5" />
|
||||
{{ t('activities.filters.pastEvents', 'Past events') }}
|
||||
{{ t('events.filters.pastEvents', 'Past events') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -154,11 +154,11 @@ function handleSelectActivity(activity: Activity) {
|
|||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Activity feed -->
|
||||
<ActivityList
|
||||
:activities="activities"
|
||||
<!-- Event feed -->
|
||||
<EventList
|
||||
:events="events"
|
||||
:is-loading="isLoading"
|
||||
@select="handleSelectActivity"
|
||||
@select="handleSelectEvent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useMyEvents } from '../composables/useMyEvents'
|
||||
import { useApprovalState } from '../composables/useApprovalState'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
|
@ -17,7 +17,7 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
||||
|
||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useMyEvents()
|
||||
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
||||
const { isAdmin, autoApprove } = useApprovalState()
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ onMounted(async () => {
|
|||
</div>
|
||||
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
|
||||
<p class="text-muted-foreground mb-4">You haven't purchased any tickets yet</p>
|
||||
<Button @click="$router.push('/activities')">Browse Activities</Button>
|
||||
<Button @click="$router.push('/events')">Browse Events</Button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tickets.length > 0">
|
||||
|
|
@ -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 { useActivityDetail } from '../composables/useActivityDetail'
|
||||
import { useEventDetail } from '../composables/useEventDetail'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activityId = ref(route.params.activityId as string)
|
||||
const { activity } = useActivityDetail(activityId.value)
|
||||
const eventId = ref(route.params.eventId as string)
|
||||
const { event } = useEventDetail(eventId.value)
|
||||
|
||||
const {
|
||||
isProcessing,
|
||||
|
|
@ -35,7 +35,7 @@ const {
|
|||
refreshStats,
|
||||
onDecode,
|
||||
resume,
|
||||
} = useTicketScanner(activityId)
|
||||
} = useTicketScanner(eventId)
|
||||
|
||||
const scannerOpen = ref(true)
|
||||
const activeTab = ref<'scanner' | 'list'>('scanner')
|
||||
|
|
@ -53,10 +53,10 @@ const lastScanVariant = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
// Backend-authoritative roster. Falls back to the activity nostr
|
||||
// Backend-authoritative roster. Falls back to the event nostr
|
||||
// event's `tickets_sold` tag if the RPC hasn't completed yet.
|
||||
const soldCount = computed(
|
||||
() => eventStats.value?.sold ?? activity.value?.ticketInfo?.sold,
|
||||
() => eventStats.value?.sold ?? event.value?.ticketInfo?.sold,
|
||||
)
|
||||
const registeredCount = computed(() => eventStats.value?.registered ?? 0)
|
||||
const remainingCount = computed(() => {
|
||||
|
|
@ -78,7 +78,7 @@ function handleResult(qrText: string) {
|
|||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
|
||||
else router.push({ name: 'event-detail', params: { id: eventId.value } })
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
|
|
@ -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="activity" class="text-sm text-muted-foreground mb-4">
|
||||
{{ activity.title }}
|
||||
<p v-if="event" class="text-sm text-muted-foreground mb-4">
|
||||
{{ event.title }}
|
||||
</p>
|
||||
|
||||
<!-- Counts strip — backend-authoritative. Source: the
|
||||
|
|
@ -204,7 +204,7 @@ export function useMarket() {
|
|||
// Logged-in user has no published market event yet — show their
|
||||
// namespace as "My Market". Avoids leaking VITE_APP_NAME (which
|
||||
// is the brand of whichever standalone app is bundled, e.g.
|
||||
// "Sortir" for activities) into the market label.
|
||||
// "Sortir" for the events app) into the market label.
|
||||
name: 'My Market',
|
||||
description: 'A communal market to sell your goods',
|
||||
merchants: [],
|
||||
|
|
|
|||
|
|
@ -451,7 +451,7 @@ const placeOrder = async () => {
|
|||
// Try to get pubkey from main auth first, fallback to auth service
|
||||
const userPubkey = auth.currentUser.value?.pubkey || authService.user.value?.pubkey
|
||||
|
||||
// Friendly toast instead of throw — same pattern as Activities favorites prompt.
|
||||
// Friendly toast instead of throw — same pattern as Events favorites prompt.
|
||||
if (!auth.isAuthenticated.value) {
|
||||
toast.info(t('market.auth.loginPrompt'), {
|
||||
action: {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const modules: Module[] = [
|
|||
{ label: 'Restaurant', chakra: 'Muladhara', icon: UtensilsCrossed, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_RESTAURANT_URL', status: 'alpha' },
|
||||
{ label: 'Market', chakra: 'Muladhara', icon: Store, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_MARKET_URL', status: 'alpha' },
|
||||
{ label: 'Wallet', chakra: 'Manipura', icon: Wallet, bgClass: '', glow: 'rgba(255,200,0,0.5)', envKey: 'VITE_HUB_WALLET_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Activities', chakra: 'Swadhisthana', icon: CalendarDays, bgClass: '', glow: 'rgba(255,165,0,0.5)', envKey: 'VITE_HUB_ACTIVITIES_URL', status: 'beta' },
|
||||
{ label: 'Events', chakra: 'Swadhisthana', icon: CalendarDays, bgClass: '', glow: 'rgba(255,165,0,0.5)', envKey: 'VITE_HUB_EVENTS_URL', status: 'beta' },
|
||||
{ label: 'Chat', chakra: 'Anahata', icon: MessageCircle, bgClass: '', glow: 'rgba(0,200,80,0.5)', envKey: 'VITE_HUB_CHAT_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Forum', chakra: 'Vishuddha', icon: Newspaper, bgClass: '', glow: 'rgba(60,120,255,0.5)', envKey: 'VITE_HUB_FORUM_URL', status: 'alpha' },
|
||||
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
|||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to activities.html
|
||||
* (SPA fallback for the standalone activities app entry point)
|
||||
* Plugin to rewrite dev server requests to events.html
|
||||
* (SPA fallback for the standalone events app entry point)
|
||||
*/
|
||||
function activitiesHtmlPlugin(): Plugin {
|
||||
function eventsHtmlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'activities-html-rewrite',
|
||||
name: 'events-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Rewrite all non-asset requests to activities.html.
|
||||
// Rewrite all non-asset requests to events.html.
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
|
|
@ -26,7 +26,7 @@ function activitiesHtmlPlugin(): Plugin {
|
|||
!req.url.startsWith('/node_modules/') &&
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/activities.html'
|
||||
req.url = '/events.html'
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
|
@ -35,7 +35,7 @@ function activitiesHtmlPlugin(): Plugin {
|
|||
}
|
||||
|
||||
/**
|
||||
* Vite config for the standalone Sortir activities app.
|
||||
* Vite config for the standalone standalone events app.
|
||||
*
|
||||
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||
* VITE_BASE_PATH=/sortir/ → app.ariege.io/sortir/ (shared auth)
|
||||
|
|
@ -44,13 +44,13 @@ function activitiesHtmlPlugin(): Plugin {
|
|||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-activities',
|
||||
cacheDir: 'node_modules/.vite-events',
|
||||
server: {
|
||||
port: 5181,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
activitiesHtmlPlugin(),
|
||||
eventsHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
|
|
@ -61,7 +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: 'activities.html',
|
||||
navigateFallback: 'events.html',
|
||||
navigateFallbackAllowlist: [
|
||||
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
],
|
||||
|
|
@ -104,7 +104,7 @@ export default defineConfig(({ mode }) => ({
|
|||
mode === 'analyze' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'dist-activities/stats.html',
|
||||
filename: 'dist-events/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
|
|
@ -115,9 +115,9 @@ export default defineConfig(({ mode }) => ({
|
|||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-activities',
|
||||
outDir: 'dist-events',
|
||||
rollupOptions: {
|
||||
input: 'activities.html',
|
||||
input: 'events.html',
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
Loading…
Add table
Add a link
Reference in a new issue