diff --git a/CLAUDE.md b/CLAUDE.md index 6fd5d02..665e214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -723,6 +723,31 @@ export function useMyModule() { - Handle connection recovery in `onResume()` callback - Implement battery-conscious pausing in `onPause()` callback +### **CSS and Styling Guidelines** + +**CRITICAL: Always use semantic, theme-aware CSS classes over hard-coded colors.** + +The app supports light/dark themes. All styling must adapt automatically: + +```vue + +
+

Secondary text

+
Card content
+ + + +
+

Secondary text

+``` + +**Semantic class mapping:** +- Backgrounds: `bg-background`, `bg-card`, `bg-muted` (not `bg-white`, `bg-gray-100`) +- Text: `text-foreground`, `text-muted-foreground` (not `text-gray-900`, `text-gray-600`) +- Borders: `border-border`, `border-input` (not `border-gray-200`, `border-gray-300`) +- Focus: `focus:ring-ring`, `focus:border-ring` (not `focus:ring-blue-500`) +- Use opacity modifiers for subtle variations: `bg-primary/10`, `text-muted-foreground/70` + ### **Code Conventions:** - Use TypeScript interfaces over types for extendability - Prefer functional and declarative patterns over classes (except for services) @@ -769,8 +794,19 @@ quantity: productData.quantity ?? 1 - Electron Forge configured for cross-platform packaging - TailwindCSS v4 integration via Vite plugin -**Environment:** -- Nostr relay configuration via `VITE_NOSTR_RELAYS` environment variable +**Environment Variables** (see `.env.example`): +- `VITE_APP_NAME` - Application display name +- `VITE_NOSTR_RELAYS` - JSON array of Nostr relay WebSocket URLs +- `VITE_ADMIN_PUBKEYS` - JSON array of admin public keys +- `VITE_LNBITS_BASE_URL` - LNbits server URL for Lightning wallet +- `VITE_API_KEY` - API key for LNbits authentication +- `VITE_LNBITS_DEBUG` - Enable LNbits debug logging +- `VITE_WEBSOCKET_ENABLED` - Enable real-time WebSocket balance updates +- `VITE_LIGHTNING_DOMAIN` - Override domain for Lightning Addresses (optional, defaults to domain from `VITE_LNBITS_BASE_URL`) +- `VITE_VAPID_PUBLIC_KEY` - VAPID key for push notifications +- `VITE_PUSH_NOTIFICATIONS_ENABLED` - Enable push notifications +- `VITE_PICTRS_BASE_URL` - pict-rs server URL for image uploads +- `VITE_MARKET_NADDR` - Nostr address for market configuration - PWA manifest configured for standalone app experience - Service worker with automatic updates every hour diff --git a/activities.html b/activities.html new file mode 100644 index 0000000..d227e52 --- /dev/null +++ b/activities.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Sortir — Activités + + + + +
+ + + diff --git a/package.json b/package.json index 304528e..ef5de0b 100644 --- a/package.json +++ b/package.json @@ -9,6 +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", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", diff --git a/src/activities-app/App.vue b/src/activities-app/App.vue new file mode 100644 index 0000000..4aaa827 --- /dev/null +++ b/src/activities-app/App.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/activities-app/app.config.ts b/src/activities-app/app.config.ts new file mode 100644 index 0000000..30cbb22 --- /dev/null +++ b/src/activities-app/app.config.ts @@ -0,0 +1,55 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone activities app configuration. + * Only enables base + activities modules. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true + }, + imageUpload: { + baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] + } + } + }, + activities: { + name: 'activities', + enabled: true, + lazy: false, + config: { + apiConfig: { + baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000', + apiKey: import.meta.env.VITE_API_KEY || '' + }, + defaultMapCenter: { lat: 42.9667, lng: 1.6000 }, // Ariège, France + maxTicketsPerUser: 10, + enableMap: true, + enablePrivateEvents: false + } + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig diff --git a/src/activities-app/app.ts b/src/activities-app/app.ts new file mode 100644 index 0000000..e997b9b --- /dev/null +++ b/src/activities-app/app.ts @@ -0,0 +1,132 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import activitiesModule from '@/modules/activities' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n } from '@/i18n' + +/** + * Initialize the standalone activities app + */ +export async function createAppInstance() { + console.log('🚀 Starting Sortir — Activities App...') + + const app = createApp(App) + + // Collect routes from enabled modules only + const moduleRoutes = [ + ...baseModule.routes || [], + ...activitiesModule.routes || [], + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(), + routes: [ + // Activities page is the home page in standalone mode + { + path: '/', + redirect: '/activities' + }, + { + path: '/login', + name: 'login', + component: () => import('@/pages/Login.vue'), + meta: { requiresAuth: false } + }, + ...moduleRoutes, + // App-specific routes + { + path: '/settings', + name: 'settings', + component: () => import('./views/SettingsPage.vue'), + meta: { requiresAuth: false } + }, + ] + }) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + // Initialize plugin manager + pluginManager.init(app, router) + + // Register modules + const moduleRegistrations = [] + + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + if (appConfig.modules.activities?.enabled) { + moduleRegistrations.push( + pluginManager.register(activitiesModule, appConfig.modules.activities) + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + // Initialize auth + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + + // Auth guard — only redirect for routes that explicitly require auth + router.beforeEach(async (to, _from, next) => { + const requiresAuth = to.meta.requiresAuth === true + + if (requiresAuth && !auth.isAuthenticated.value) { + next('/login') + } else if (to.path === '/login' && auth.isAuthenticated.value) { + next('/') + } else { + next() + } + }) + + // Global error handling + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log('✅ Sortir app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('🎉 Sortir app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('💥 Failed to start Sortir app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/activities-app/main.ts b/src/activities-app/main.ts new file mode 100644 index 0000000..c9c8429 --- /dev/null +++ b/src/activities-app/main.ts @@ -0,0 +1,18 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import 'vue-sonner/style.css' + +// PWA service worker with periodic updates +const intervalMS = 60 * 60 * 1000 // 1 hour +registerSW({ + onRegistered(r) { + r && setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log('Sortir app ready to work offline') + } +}) + +startApp() diff --git a/src/activities-app/views/SettingsPage.vue b/src/activities-app/views/SettingsPage.vue new file mode 100644 index 0000000..007264c --- /dev/null +++ b/src/activities-app/views/SettingsPage.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/app.config.ts b/src/app.config.ts index 8060762..11e2df8 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -75,6 +75,21 @@ export const appConfig: AppConfig = { maxTicketsPerUser: 10 } }, + activities: { + name: 'activities', + enabled: true, + lazy: false, + config: { + apiConfig: { + baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000', + apiKey: import.meta.env.VITE_API_KEY || '' + }, + defaultMapCenter: { lat: 46.6034, lng: 1.8883 }, + maxTicketsPerUser: 10, + enableMap: true, + enablePrivateEvents: false + } + }, wallet: { name: 'wallet', enabled: true, diff --git a/src/app.ts b/src/app.ts index 459283e..1d11e26 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import chatModule from './modules/chat' import eventsModule from './modules/events' import marketModule from './modules/market' import walletModule from './modules/wallet' +import activitiesModule from './modules/activities' // Root component import App from './App.vue' @@ -43,7 +44,8 @@ export async function createAppInstance() { ...chatModule.routes || [], ...eventsModule.routes || [], ...marketModule.routes || [], - ...walletModule.routes || [] + ...walletModule.routes || [], + ...activitiesModule.routes || [] ].filter(Boolean) // Create router with all routes available immediately @@ -126,6 +128,13 @@ export async function createAppInstance() { ) } + // Register activities module (Nostr-native events) + if (appConfig.modules.activities?.enabled) { + moduleRegistrations.push( + pluginManager.register(activitiesModule, appConfig.modules.activities) + ) + } + // Wait for all modules to register await Promise.all(moduleRegistrations) diff --git a/src/composables/useModularNavigation.ts b/src/composables/useModularNavigation.ts index af47f66..c57c10c 100644 --- a/src/composables/useModularNavigation.ts +++ b/src/composables/useModularNavigation.ts @@ -42,11 +42,19 @@ export function useModularNavigation() { }) } + if (appConfig.modules.activities?.enabled) { + items.push({ + name: t('nav.activities'), + href: '/activities', + requiresAuth: false + }) + } + if (appConfig.modules.chat.enabled) { - items.push({ - name: t('nav.chat'), - href: '/chat', - requiresAuth: true + items.push({ + name: t('nav.chat'), + href: '/chat', + requiresAuth: true }) } diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 32221f5..aa16b78 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -142,6 +142,10 @@ export const SERVICE_TOKENS = { // Events services EVENTS_SERVICE: Symbol('eventsService'), + + // Activities services (Nostr-native events module) + ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'), + ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'), // Invoice services INVOICE_SERVICE: Symbol('invoiceService'), diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7c39b78..70fe924 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -9,6 +9,7 @@ const messages: LocaleMessages = { events: 'Events', market: 'Market', chat: 'Chat', + activities: 'Activities', login: 'Login', logout: 'Logout' }, @@ -29,6 +30,67 @@ const messages: LocaleMessages = { de: 'German', zh: 'Chinese' }, + activities: { + title: 'Activities', + createNew: 'Create Activity', + noActivities: 'No activities found', + filters: { + all: 'All', + today: 'Today', + tomorrow: 'Tomorrow', + thisWeek: 'This Week', + thisMonth: 'This Month', + }, + categories: { + concert: 'Concert', + workshop: 'Workshop', + market: 'Market', + festival: 'Festival', + exhibition: 'Exhibition', + sport: 'Sport', + theater: 'Theater', + cinema: 'Cinema', + party: 'Party', + talk: 'Talk', + conference: 'Conference', + meetup: 'Meetup', + food: 'Food', + outdoor: 'Outdoor', + kids: 'Kids', + wellness: 'Wellness', + technology: 'Technology', + art: 'Art', + music: 'Music', + dance: 'Dance', + literature: 'Literature', + comedy: 'Comedy', + charity: 'Charity', + tradition: 'Tradition', + other: 'Other', + }, + detail: { + getTicket: 'Get Ticket', + going: 'Going', + maybe: 'Maybe', + notGoing: 'Not Going', + contactOrganizer: 'Contact Organizer', + organizer: 'Organizer', + location: 'Location', + when: 'When', + tickets: 'Tickets', + ticketsAvailable: '{count} tickets available', + soldOut: 'Sold Out', + free: 'Free', + }, + tickets: { + myTickets: 'My Tickets', + scanTicket: 'Scan Ticket', + noTickets: 'No tickets yet', + paid: 'Paid', + pending: 'Pending', + registered: 'Registered', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 303ed46..d0fa65d 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -9,6 +9,7 @@ const messages: LocaleMessages = { events: 'Eventos', market: 'Mercado', chat: 'Chat', + activities: 'Actividades', login: 'Iniciar Sesión', logout: 'Cerrar Sesión' }, diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 2fb2b3c..e18f854 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -9,6 +9,7 @@ const messages: LocaleMessages = { events: 'Événements', market: 'Marché', chat: 'Chat', + activities: 'Activités', login: 'Connexion', logout: 'Déconnexion' }, @@ -29,6 +30,67 @@ const messages: LocaleMessages = { de: 'Allemand', zh: 'Chinois' }, + activities: { + title: 'Activités', + createNew: 'Créer une activité', + noActivities: 'Aucune activité trouvée', + filters: { + all: 'Tout', + today: "Aujourd'hui", + tomorrow: 'Demain', + thisWeek: 'Cette semaine', + thisMonth: 'Ce mois-ci', + }, + categories: { + concert: 'Concert', + workshop: 'Atelier', + market: 'Marché', + festival: 'Festival', + exhibition: 'Exposition', + sport: 'Sport', + theater: 'Théâtre', + cinema: 'Cinéma', + party: 'Fête', + talk: 'Conférence', + conference: 'Congrès', + meetup: 'Rencontre', + food: 'Gastronomie', + outdoor: 'Plein air', + kids: 'Enfants', + wellness: 'Bien-être', + technology: 'Technologie', + art: 'Art', + music: 'Musique', + dance: 'Danse', + literature: 'Littérature', + comedy: 'Humour', + charity: 'Caritatif', + tradition: 'Tradition', + other: 'Autre', + }, + detail: { + getTicket: 'Obtenir un billet', + going: 'Présent', + maybe: 'Peut-être', + notGoing: 'Absent', + contactOrganizer: "Contacter l'organisateur", + organizer: 'Organisateur', + location: 'Lieu', + when: 'Quand', + tickets: 'Billets', + ticketsAvailable: '{count} billets disponibles', + soldOut: 'Épuisé', + free: 'Gratuit', + }, + tickets: { + myTickets: 'Mes billets', + scanTicket: 'Scanner le billet', + noTickets: 'Pas encore de billets', + paid: 'Payé', + pending: 'En attente', + registered: 'Enregistré', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 12747de..9faf7d7 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -7,6 +7,7 @@ export interface LocaleMessages { events: string market: string chat: string + activities: string login: string logout: string } @@ -29,6 +30,42 @@ export interface LocaleMessages { de: string zh: string } + // Activities module + activities?: { + title: string + createNew: string + noActivities: string + filters: { + all: string + today: string + tomorrow: string + thisWeek: string + thisMonth: string + } + categories: Record + detail: { + getTicket: string + going: string + maybe: string + notGoing: string + contactOrganizer: string + organizer: string + location: string + when: string + tickets: string + ticketsAvailable: string + soldOut: string + free: string + } + tickets: { + myTickets: string + scanTicket: string + noTickets: string + paid: string + pending: string + registered: string + } + } // Add date/time formats dateTimeFormats: { short: { diff --git a/src/modules/activities/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue new file mode 100644 index 0000000..6503536 --- /dev/null +++ b/src/modules/activities/components/ActivityCard.vue @@ -0,0 +1,136 @@ + + + diff --git a/src/modules/activities/components/ActivityList.vue b/src/modules/activities/components/ActivityList.vue new file mode 100644 index 0000000..4dac127 --- /dev/null +++ b/src/modules/activities/components/ActivityList.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/modules/activities/components/CategoryFilterBar.vue b/src/modules/activities/components/CategoryFilterBar.vue new file mode 100644 index 0000000..8069dcf --- /dev/null +++ b/src/modules/activities/components/CategoryFilterBar.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/modules/activities/components/DatePickerStrip.vue b/src/modules/activities/components/DatePickerStrip.vue new file mode 100644 index 0000000..b9ea5cb --- /dev/null +++ b/src/modules/activities/components/DatePickerStrip.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/modules/activities/components/TemporalFilterBar.vue b/src/modules/activities/components/TemporalFilterBar.vue new file mode 100644 index 0000000..2ccb4ec --- /dev/null +++ b/src/modules/activities/components/TemporalFilterBar.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/modules/activities/composables/useActivities.ts b/src/modules/activities/composables/useActivities.ts new file mode 100644 index 0000000..7d4186b --- /dev/null +++ b/src/modules/activities/composables/useActivities.ts @@ -0,0 +1,132 @@ +import { ref, computed, onUnmounted } from 'vue' +import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' +import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' +import type { CalendarEventFilters } from '../services/ActivitiesNostrService' +import { useActivitiesStore } from '../stores/activities' +import { useActivityFilters } from './useActivityFilters' + +/** + * Main composable for activities discovery. + * Subscribes to NIP-52 events via ActivitiesNostrService and manages the activity feed. + */ +export function useActivities() { + const store = useActivitiesStore() + const filters = useActivityFilters() + + const isSubscribed = ref(false) + const subscriptionError = ref(null) + let unsubscribe: (() => void) | null = null + + // Filtered and sorted activities + const filteredActivities = computed(() => { + const upcoming = store.upcomingActivities + return filters.applyFilters(upcoming) + }) + + const pastFilteredActivities = computed(() => { + return filters.applyFilters(store.pastActivities) + }) + + /** + * Subscribe to NIP-52 calendar events from Nostr relays. + */ + function subscribe(eventFilters?: CalendarEventFilters) { + if (isSubscribed.value) return + + const nostrService = tryInjectService(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) + if (!nostrService) { + subscriptionError.value = 'Activities service not available' + return + } + + try { + store.isLoading = true + subscriptionError.value = null + + unsubscribe = nostrService.subscribeToCalendarEvents( + (activity) => { + store.upsertActivity(activity) + store.isLoading = false + }, + eventFilters + ) + + isSubscribed.value = true + + // Set loading to false after a timeout (in case no events arrive) + setTimeout(() => { + store.isLoading = false + }, 5000) + } catch (err) { + subscriptionError.value = err instanceof Error ? err.message : 'Failed to subscribe' + store.isLoading = false + } + } + + /** + * One-shot query for calendar events. + */ + async function query(eventFilters?: CalendarEventFilters) { + const nostrService = tryInjectService(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) + if (!nostrService) { + subscriptionError.value = 'Activities service not available' + return + } + + try { + store.isLoading = true + subscriptionError.value = null + const activities = await nostrService.queryCalendarEvents(eventFilters) + store.upsertActivities(activities) + } catch (err) { + subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities' + } finally { + store.isLoading = false + } + } + + /** + * Unsubscribe from relay events. + */ + function stop() { + if (unsubscribe) { + unsubscribe() + unsubscribe = null + } + isSubscribed.value = false + } + + /** + * Refresh: stop current subscription and re-subscribe. + */ + function refresh(eventFilters?: CalendarEventFilters) { + stop() + store.clearAll() + subscribe(eventFilters) + } + + // Cleanup on unmount + onUnmounted(() => { + stop() + }) + + return { + // State + activities: filteredActivities, + pastActivities: pastFilteredActivities, + allActivities: computed(() => store.activities), + isLoading: computed(() => store.isLoading), + isSubscribed, + error: subscriptionError, + lastUpdated: computed(() => store.lastUpdated), + + // Filter controls (re-exported) + ...filters, + + // Actions + subscribe, + query, + stop, + refresh, + } +} diff --git a/src/modules/activities/composables/useActivityDetail.ts b/src/modules/activities/composables/useActivityDetail.ts new file mode 100644 index 0000000..e29a272 --- /dev/null +++ b/src/modules/activities/composables/useActivityDetail.ts @@ -0,0 +1,78 @@ +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' + +/** + * Composable for loading a single activity by its d-tag identifier. + * First checks the store cache, then queries relays if not found. + */ +export function useActivityDetail(activityId: string) { + const store = useActivitiesStore() + const isLoading = ref(false) + const error = ref(null) + let unsubscribe: (() => void) | null = null + + const activity = computed(() => + store.getActivityById(activityId) + ) + + async function load() { + // Already in cache + if (activity.value) return + + const nostrService = tryInjectService(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) + if (!nostrService) { + error.value = 'Activities service not available' + return + } + + try { + isLoading.value = true + error.value = null + + // Subscribe and wait for this specific event + unsubscribe = nostrService.subscribeToCalendarEvents( + (incoming) => { + store.upsertActivity(incoming) + if (incoming.id === activityId) { + isLoading.value = false + } + } + ) + + // Also do a one-shot query + const results = await nostrService.queryCalendarEvents() + store.upsertActivities(results) + + // If we still don't have it after query, stop loading + setTimeout(() => { + isLoading.value = false + if (!activity.value) { + error.value = 'Activity not found' + } + }, 5000) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to load activity' + isLoading.value = false + } + } + + onMounted(() => { + load() + }) + + onUnmounted(() => { + if (unsubscribe) { + unsubscribe() + } + }) + + return { + activity, + isLoading, + error, + reload: load, + } +} diff --git a/src/modules/activities/composables/useActivityFilters.ts b/src/modules/activities/composables/useActivityFilters.ts new file mode 100644 index 0000000..a42016c --- /dev/null +++ b/src/modules/activities/composables/useActivityFilters.ts @@ -0,0 +1,136 @@ +import { ref, computed } from 'vue' +import { + startOfDay, endOfDay, startOfWeek, endOfWeek, + startOfMonth, endOfMonth, addDays, +} from 'date-fns' +import type { Activity } from '../types/activity' +import type { ActivityCategory } from '../types/category' +import type { TemporalFilter, ActivityFilters } from '../types/filters' +import { DEFAULT_FILTERS } from '../types/filters' + +/** + * Composable for managing activity filter state and applying filters reactively. + */ +export function useActivityFilters() { + const temporal = ref(DEFAULT_FILTERS.temporal) + const selectedCategories = ref([]) + const searchQuery = ref('') + + const filters = computed(() => ({ + temporal: temporal.value, + categories: selectedCategories.value, + search: searchQuery.value || undefined, + })) + + /** + * Apply the current filters to a list of activities. + */ + function applyFilters(activities: Activity[]): Activity[] { + let result = activities + + // Temporal filter + result = applyTemporalFilter(result, temporal.value) + + // Category filter + if (selectedCategories.value.length > 0) { + result = result.filter(a => + a.category && selectedCategories.value.includes(a.category) + ) + } + + // Search filter + if (searchQuery.value.trim()) { + const query = searchQuery.value.toLowerCase().trim() + result = result.filter(a => + a.title.toLowerCase().includes(query) || + a.summary?.toLowerCase().includes(query) || + a.description.toLowerCase().includes(query) || + a.location?.toLowerCase().includes(query) + ) + } + + return result + } + + function setTemporal(value: TemporalFilter) { + temporal.value = value + } + + function toggleCategory(category: ActivityCategory) { + const idx = selectedCategories.value.indexOf(category) + if (idx >= 0) { + selectedCategories.value.splice(idx, 1) + } else { + selectedCategories.value.push(category) + } + } + + function clearCategories() { + selectedCategories.value = [] + } + + function resetFilters() { + temporal.value = DEFAULT_FILTERS.temporal + selectedCategories.value = [] + searchQuery.value = '' + } + + const hasActiveFilters = computed(() => + temporal.value !== 'all' || + selectedCategories.value.length > 0 || + searchQuery.value.trim().length > 0 + ) + + return { + // State + temporal, + selectedCategories, + searchQuery, + filters, + hasActiveFilters, + + // Actions + applyFilters, + setTemporal, + toggleCategory, + clearCategories, + resetFilters, + } +} + +// --- Helpers --- + +function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Activity[] { + if (filter === 'all') return activities + + const now = new Date() + let start: Date + let end: Date + + switch (filter) { + case 'today': + start = startOfDay(now) + end = endOfDay(now) + break + case 'tomorrow': + start = startOfDay(addDays(now, 1)) + end = endOfDay(addDays(now, 1)) + break + case 'this-week': + start = startOfWeek(now, { weekStartsOn: 1 }) + end = endOfWeek(now, { weekStartsOn: 1 }) + break + case 'this-month': + start = startOfMonth(now) + end = endOfMonth(now) + break + default: + return activities + } + + return activities.filter(a => { + const activityEnd = a.endDate ?? a.startDate + // Activity overlaps with the filter range + return a.startDate <= end && activityEnd >= start + }) +} diff --git a/src/modules/activities/index.ts b/src/modules/activities/index.ts new file mode 100644 index 0000000..2816a86 --- /dev/null +++ b/src/modules/activities/index.ts @@ -0,0 +1,128 @@ +import { createModulePlugin } from '@/core/base/BaseModulePlugin' +import { SERVICE_TOKENS } from '@/core/di-container' +import { ActivitiesNostrService } from './services/ActivitiesNostrService' +import { TicketApiService, type TicketApiConfig } from './services/TicketApiService' + +export interface ActivitiesModuleConfig { + apiConfig: TicketApiConfig + defaultMapCenter?: { lat: number; lng: number } + maxTicketsPerUser?: number + enableMap?: boolean + enablePrivateEvents?: boolean +} + +/** + * Activities 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', + version: '1.0.0', + dependencies: ['base'], + + routes: [ + { + path: '/activities', + name: 'activities', + component: () => import('./views/ActivitiesPage.vue'), + meta: { + title: 'Activities', + requiresAuth: false, + }, + }, + { + path: '/activities/calendar', + name: 'activities-calendar', + component: () => import('./views/ActivitiesCalendarPage.vue'), + meta: { + title: 'Calendar', + requiresAuth: false, + }, + }, + { + path: '/activities/map', + name: 'activities-map', + component: () => import('./views/ActivitiesMapPage.vue'), + meta: { + title: 'Map', + requiresAuth: false, + }, + }, + { + path: '/activities/favorites', + name: 'activities-favorites', + component: () => import('./views/ActivitiesFavoritesPage.vue'), + meta: { + title: 'Favorites', + requiresAuth: true, + }, + }, + { + path: '/activities/:id', + name: 'activity-detail', + component: () => import('./views/ActivityDetailPage.vue'), + meta: { + title: 'Activity', + requiresAuth: false, + }, + }, + { + path: '/my-tickets', + name: 'my-tickets-v2', + component: () => import('./views/MyTicketsPage.vue'), + meta: { + title: 'My Tickets', + requiresAuth: true, + }, + }, + ], + + eventListeners: [ + { + event: 'payment:completed', + handler: (event) => { + console.log('Activities module: payment completed', event.data) + }, + description: 'Handle payment completion for ticket purchases', + }, + ], + + onInstall: async (_app, options) => { + const config = options?.config as ActivitiesModuleConfig | undefined + if (!config) { + throw new Error('Activities module requires configuration') + } + + const { container } = await import('@/core/di-container') + + // 1. Create services + const nostrService = new ActivitiesNostrService() + 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) + + // 3. Initialize the Nostr service (needs RelayHub dependency) + await nostrService.initialize({ + waitForDependencies: true, + maxRetries: 3, + }) + }, + + onUninstall: async () => { + const { container } = await import('@/core/di-container') + container.remove(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) + container.remove(SERVICE_TOKENS.ACTIVITIES_TICKET_API) + }, +}) + +export default activitiesModule + +// Re-export types for external use +export type { Activity, OrganizerInfo, ActivityTicketInfo } from './types/activity' +export type { ActivityTicket, TicketStatus } from './types/ticket' +export type { ActivityCategory } from './types/category' +export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52' diff --git a/src/modules/activities/services/ActivitiesNostrService.ts b/src/modules/activities/services/ActivitiesNostrService.ts new file mode 100644 index 0000000..8f9008e --- /dev/null +++ b/src/modules/activities/services/ActivitiesNostrService.ts @@ -0,0 +1,170 @@ +import { BaseService } from '@/core/base/BaseService' +import type { Event as NostrEvent } from 'nostr-tools' +import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub' +import { + NIP52_KINDS, + parseCalendarTimeEvent, + parseCalendarDateEvent, + buildCalendarTimeEventTags, + type CalendarTimeEvent, +} from '../types/nip52' +import { + calendarTimeEventToActivity, + calendarDateEventToActivity, + type Activity, +} from '../types/activity' + +export interface CalendarEventFilters { + /** Only return events created after this timestamp */ + since?: number + /** Only return events created before this timestamp */ + until?: number + /** Filter by specific authors (pubkeys) */ + authors?: string[] + /** Filter by hashtags (NIP-52 't' tags) */ + hashtags?: string[] + /** Filter by geohash prefix (NIP-52 'g' tag) */ + geohash?: string +} + +/** + * Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub. + * Extends BaseService for standardized dependency injection and lifecycle. + */ +export class ActivitiesNostrService extends BaseService { + protected readonly metadata = { + name: 'ActivitiesNostrService', + version: '1.0.0', + dependencies: ['RelayHub'], + } + + private activeUnsubscribes: Array<() => void> = [] + + protected async onInitialize(): Promise { + this.debug('ActivitiesNostrService initialized') + } + + /** + * Subscribe to NIP-52 calendar events from relays. + * Returns an unsubscribe function. + */ + subscribeToCalendarEvents( + onActivity: (activity: Activity) => void, + filters?: CalendarEventFilters + ): () => void { + if (!this.relayHub) { + throw new Error('RelayHub not available') + } + + const nostrFilters = this.buildNostrFilters(filters) + + const subscriptionId = `activities-calendar-${Date.now()}` + + const config: SubscriptionConfig = { + id: subscriptionId, + filters: nostrFilters, + onEvent: (event: NostrEvent) => { + const activity = this.parseNostrEventToActivity(event) + if (activity) { + onActivity(activity) + } + }, + onEose: () => { + this.debug('End of stored events for subscription', subscriptionId) + }, + } + + const unsubscribe = this.relayHub.subscribe(config) + this.activeUnsubscribes.push(unsubscribe) + + return () => { + unsubscribe() + this.activeUnsubscribes = this.activeUnsubscribes.filter(fn => fn !== unsubscribe) + } + } + + /** + * Query relays for calendar events (one-shot, not a subscription). + */ + async queryCalendarEvents(filters?: CalendarEventFilters): Promise { + if (!this.relayHub) { + throw new Error('RelayHub not available') + } + + const nostrFilters = this.buildNostrFilters(filters) + const events: NostrEvent[] = await this.relayHub.queryEvents(nostrFilters) + + const activities: Activity[] = [] + for (const event of events) { + const activity = this.parseNostrEventToActivity(event) + if (activity) { + activities.push(activity) + } + } + + return activities + } + + /** + * Publish a NIP-52 time-based calendar event. + */ + async publishCalendarEvent( + eventData: Partial + ): Promise<{ success: number; total: number }> { + if (!this.relayHub) { + throw new Error('RelayHub not available') + } + + const tags = buildCalendarTimeEventTags(eventData) + const eventTemplate = { + kind: NIP52_KINDS.CALENDAR_TIME_EVENT, + created_at: Math.floor(Date.now() / 1000), + content: eventData.content ?? '', + tags, + } + + return await this.relayHub.publishEvent(eventTemplate) + } + + /** + * Parse a raw Nostr event into an Activity view model. + */ + private parseNostrEventToActivity(event: NostrEvent): Activity | null { + if (event.kind === NIP52_KINDS.CALENDAR_TIME_EVENT) { + const parsed = parseCalendarTimeEvent(event) + if (parsed) return calendarTimeEventToActivity(parsed) + } + + if (event.kind === NIP52_KINDS.CALENDAR_DATE_EVENT) { + const parsed = parseCalendarDateEvent(event) + if (parsed) return calendarDateEventToActivity(parsed) + } + + return null + } + + /** + * Build nostr-tools Filter objects from our CalendarEventFilters. + */ + private buildNostrFilters(filters?: CalendarEventFilters): Array> { + const filter: Record = { + kinds: [NIP52_KINDS.CALENDAR_DATE_EVENT, NIP52_KINDS.CALENDAR_TIME_EVENT], + } + + if (filters?.since) filter.since = filters.since + if (filters?.until) filter.until = filters.until + if (filters?.authors?.length) filter.authors = filters.authors + if (filters?.hashtags?.length) filter['#t'] = filters.hashtags + if (filters?.geohash) filter['#g'] = [filters.geohash] + + return [filter] + } + + protected override async onDispose(): Promise { + // Clean up all active subscriptions + for (const unsub of this.activeUnsubscribes) { + unsub() + } + this.activeUnsubscribes = [] + } +} diff --git a/src/modules/activities/services/LnbitsPaymentProvider.ts b/src/modules/activities/services/LnbitsPaymentProvider.ts new file mode 100644 index 0000000..140bad4 --- /dev/null +++ b/src/modules/activities/services/LnbitsPaymentProvider.ts @@ -0,0 +1,91 @@ +import type { + PaymentProvider, + CreateInvoiceParams, + InvoiceResult, + PaymentStatus, + PayInvoiceResult, +} from './PaymentProviderInterface' + +export interface LnbitsPaymentConfig { + baseUrl: string + apiKey: string +} + +/** + * LNbits implementation of PaymentProvider. + * Talks to the LNbits REST API for invoice creation, payment, and status checks. + */ +export class LnbitsPaymentProvider implements PaymentProvider { + constructor(private config: LnbitsPaymentConfig) {} + + async createInvoice(params: CreateInvoiceParams): Promise { + const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': this.config.apiKey, + }, + body: JSON.stringify({ + out: false, + amount: params.amount, + memo: params.memo, + extra: params.metadata, + }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to create invoice' })) + throw new Error(typeof error.detail === 'string' ? error.detail : 'Failed to create invoice') + } + + const data = await response.json() + return { + paymentHash: data.payment_hash, + paymentRequest: data.payment_request, + } + } + + async checkPaymentStatus(paymentHash: string): Promise { + const response = await fetch(`${this.config.baseUrl}/api/v1/payments/${paymentHash}`, { + headers: { + 'X-Api-Key': this.config.apiKey, + }, + }) + + if (!response.ok) { + throw new Error('Failed to check payment status') + } + + const data = await response.json() + return { + paid: data.paid === true, + preimage: data.preimage, + } + } + + async payInvoice(paymentRequest: string): Promise { + const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': this.config.apiKey, + }, + body: JSON.stringify({ + out: true, + bolt11: paymentRequest, + }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to pay invoice' })) + throw new Error(typeof error.detail === 'string' ? error.detail : 'Failed to pay invoice') + } + + const data = await response.json() + return { + paymentHash: data.payment_hash, + feeMsat: data.fee_msat ?? 0, + preimage: data.preimage ?? '', + } + } +} diff --git a/src/modules/activities/services/PaymentProviderInterface.ts b/src/modules/activities/services/PaymentProviderInterface.ts new file mode 100644 index 0000000..e35fda0 --- /dev/null +++ b/src/modules/activities/services/PaymentProviderInterface.ts @@ -0,0 +1,48 @@ +/** + * Payment Provider Abstraction + * + * Enables swapping between LNbits and lightning.pub (or other providers) + * without changing consuming code. + */ + +export interface CreateInvoiceParams { + /** Amount in the specified currency */ + amount: number + /** Currency code (e.g., 'sats', 'EUR') */ + currency: string + /** Invoice memo/description */ + memo: string + /** Arbitrary metadata attached to the invoice */ + metadata?: Record +} + +export interface InvoiceResult { + paymentHash: string + paymentRequest: string +} + +export interface PaymentStatus { + paid: boolean + preimage?: string +} + +export interface PayInvoiceResult { + paymentHash: string + feeMsat: number + preimage: string +} + +/** + * Abstract payment provider interface. + * Implementations handle the specifics of LNbits, lightning.pub, etc. + */ +export interface PaymentProvider { + /** Create a Lightning invoice for receiving payment */ + createInvoice(params: CreateInvoiceParams): Promise + + /** Check whether an invoice has been paid */ + checkPaymentStatus(paymentHash: string): Promise + + /** Pay a Lightning invoice from the user's wallet */ + payInvoice(paymentRequest: string): Promise +} diff --git a/src/modules/activities/services/TicketApiService.ts b/src/modules/activities/services/TicketApiService.ts new file mode 100644 index 0000000..61d5572 --- /dev/null +++ b/src/modules/activities/services/TicketApiService.ts @@ -0,0 +1,158 @@ +import type { + ActivityTicket, + TicketPurchaseInvoice, + TicketPaymentStatus, +} from '../types/ticket' + +export interface TicketApiConfig { + baseUrl: string + apiKey: string +} + +/** + * Database-backed ticketing API service. + * Talks to the LNbits events extension for ticket inventory, + * purchases, payment status, and validation. + * + * This is NOT a BaseService -- it's a simple API wrapper instantiated + * with config at module install time (same pattern as EventsApiService). + */ +export class TicketApiService { + constructor(private config: TicketApiConfig) {} + + /** + * Fetch all public events from the LNbits events extension. + * Used to correlate Nostr activities with ticketed events. + */ + async fetchTicketedEvents(): Promise { + const response = await this.request( + '/events/api/v1/events/public', + { method: 'GET' } + ) + return response + } + + /** + * Request a ticket purchase (creates a Lightning invoice). + */ + async requestTicket( + eventId: string, + userId: string, + accessToken: string + ): Promise { + const data = await this.request( + `/events/api/v1/tickets/${eventId}/user/${userId}`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + } + ) + + return { + paymentHash: data.payment_hash, + paymentRequest: data.payment_request, + } + } + + /** + * Check whether a ticket payment has been confirmed. + */ + async checkPaymentStatus( + eventId: string, + paymentHash: string + ): Promise { + const data = await this.request( + `/events/api/v1/tickets/${eventId}/${paymentHash}`, + { method: 'POST' } + ) + + return { + paid: data.paid === true, + ticketId: data.ticket_id, + } + } + + /** + * Fetch all tickets for a user. + */ + async fetchUserTickets( + userId: string, + accessToken: string + ): Promise { + const data = await this.request( + `/events/api/v1/tickets/user/${userId}`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + } + ) + + return (data as any[]).map(t => ({ + id: t.id, + wallet: t.wallet, + activityId: t.event, + name: t.name, + email: t.email, + userId: t.user_id, + registered: t.registered, + paid: t.paid, + time: t.time, + regTimestamp: t.reg_timestamp, + })) + } + + /** + * Validate/register a ticket at the door (scan). + */ + async validateTicket(ticketId: string): Promise { + const data = await this.request( + `/events/api/v1/register/ticket/${ticketId}`, + { method: 'GET' } + ) + + return (data as any[]).map(t => ({ + id: t.id, + wallet: t.wallet, + activityId: t.event, + name: t.name, + email: t.email, + userId: t.user_id, + registered: t.registered, + paid: t.paid, + time: t.time, + regTimestamp: t.reg_timestamp, + })) + } + + /** + * Internal fetch helper with standard headers and error handling. + */ + private async request(path: string, init: RequestInit = {}): Promise { + const headers: Record = { + 'accept': 'application/json', + 'X-API-KEY': this.config.apiKey, + ...(init.headers as Record ?? {}), + } + + const response = await fetch(`${this.config.baseUrl}${path}`, { + ...init, + headers, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: `Request failed: ${path}` })) + const errorMessage = typeof error.detail === 'string' + ? error.detail + : Array.isArray(error.detail) + ? error.detail[0]?.msg ?? 'Request failed' + : 'Request failed' + throw new Error(errorMessage) + } + + return response.json() + } +} diff --git a/src/modules/activities/stores/activities.ts b/src/modules/activities/stores/activities.ts new file mode 100644 index 0000000..1faec7b --- /dev/null +++ b/src/modules/activities/stores/activities.ts @@ -0,0 +1,100 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Activity } from '../types/activity' + +/** + * Pinia store for cached activities from Nostr relays. + * Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag). + */ +export const useActivitiesStore = defineStore('activities', () => { + // State + const activitiesMap = ref>(new Map()) + const isLoading = ref(false) + const lastUpdated = ref(null) + + // Computed + const activities = computed(() => Array.from(activitiesMap.value.values())) + + const upcomingActivities = computed(() => { + const now = new Date() + return activities.value + .filter(a => a.startDate >= now || (a.endDate && a.endDate >= now)) + .sort((a, b) => a.startDate.getTime() - b.startDate.getTime()) + }) + + const pastActivities = computed(() => { + const now = new Date() + return activities.value + .filter(a => { + const endOrStart = a.endDate ?? a.startDate + return endOrStart < now + }) + .sort((a, b) => b.startDate.getTime() - a.startDate.getTime()) + }) + + // Actions + + /** + * Add or update an activity in the store. + * Deduplicates by id (d-tag). Newer events replace older ones. + */ + function upsertActivity(activity: Activity) { + const existing = activitiesMap.value.get(activity.id) + + // Only update if this is a newer version + if (!existing || activity.createdAt >= existing.createdAt) { + activitiesMap.value.set(activity.id, activity) + lastUpdated.value = new Date() + } + } + + /** + * Add multiple activities (batch upsert). + */ + function upsertActivities(newActivities: Activity[]) { + for (const activity of newActivities) { + upsertActivity(activity) + } + } + + /** + * Remove an activity from the store. + */ + function removeActivity(id: string) { + activitiesMap.value.delete(id) + } + + /** + * Clear all cached activities. + */ + function clearAll() { + activitiesMap.value.clear() + lastUpdated.value = null + } + + /** + * Get a single activity by its id (d-tag). + */ + function getActivityById(id: string): Activity | undefined { + return activitiesMap.value.get(id) + } + + return { + // State + activitiesMap, + isLoading, + lastUpdated, + + // Computed + activities, + upcomingActivities, + pastActivities, + + // Actions + upsertActivity, + upsertActivities, + removeActivity, + clearAll, + getActivityById, + } +}) diff --git a/src/modules/activities/types/activity.ts b/src/modules/activities/types/activity.ts new file mode 100644 index 0000000..7785725 --- /dev/null +++ b/src/modules/activities/types/activity.ts @@ -0,0 +1,126 @@ +import type { ActivityCategory } from './category' +import type { CalendarTimeEvent, CalendarDateEvent } from './nip52' + +/** + * Unified view model for displaying activities in the UI. + * Created from NIP-52 CalendarTimeEvent or CalendarDateEvent. + */ +export interface Activity { + /** Unique identifier (NIP-52 d-tag) */ + id: string + /** Nostr event ID */ + nostrEventId: string + /** Whether this is a date-only or time-specific event */ + type: 'date' | 'time' + /** Organizer information */ + organizer: OrganizerInfo + /** Activity title */ + title: string + /** Brief summary */ + summary?: string + /** Full description (markdown) */ + description: string + /** Banner/poster image URL */ + image?: string + /** Start date/time */ + startDate: Date + /** End date/time */ + endDate?: Date + /** Timezone identifier (IANA) */ + timezone?: string + /** Human-readable location */ + location?: string + /** Geographic coordinates (derived from geohash) */ + coordinates?: { lat: number; lng: number } + /** NIP-52 geohash (g tag) */ + geohash?: string + /** Primary category */ + category?: ActivityCategory + /** All hashtags/tags */ + tags: string[] + /** Ticket pricing info (if ticketed) */ + ticketInfo?: ActivityTicketInfo + /** Whether this is a private/invite-only event */ + isPrivate: boolean + /** Nostr event created_at timestamp */ + createdAt: Date +} + +export interface OrganizerInfo { + pubkey: string + name?: string + picture?: string + nip05?: string +} + +export interface ActivityTicketInfo { + price: number + currency: string + available: number + total: number +} + +/** + * Convert a CalendarTimeEvent to an Activity view model + */ +export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?: Partial): Activity { + const category = event.hashtags[0] as ActivityCategory | undefined + + return { + id: event.dTag, + nostrEventId: event.id, + type: 'time', + organizer: { + pubkey: event.pubkey, + ...organizer, + }, + title: event.title, + summary: event.summary, + description: event.content, + image: event.image, + startDate: new Date(event.start * 1000), + endDate: event.end ? new Date(event.end * 1000) : undefined, + timezone: event.startTzid, + location: event.location, + geohash: event.geohash, + category, + tags: event.hashtags, + isPrivate: false, + createdAt: new Date(event.createdAt * 1000), + } +} + +/** + * Convert a CalendarDateEvent to an Activity view model + */ +export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?: Partial): Activity { + const category = event.hashtags[0] as ActivityCategory | undefined + + // Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC + const parseIsoDate = (dateStr: string): Date => { + const [year, month, day] = dateStr.split('-').map(Number) + return new Date(Date.UTC(year, month - 1, day)) + } + + return { + id: event.dTag, + nostrEventId: event.id, + type: 'date', + organizer: { + pubkey: event.pubkey, + ...organizer, + }, + title: event.title, + summary: event.summary, + description: event.content, + image: event.image, + startDate: parseIsoDate(event.start), + endDate: event.end ? parseIsoDate(event.end) : undefined, + location: event.location, + geohash: event.geohash, + category, + tags: event.hashtags, + isPrivate: false, + createdAt: new Date(event.createdAt * 1000), + } +} diff --git a/src/modules/activities/types/category.ts b/src/modules/activities/types/category.ts new file mode 100644 index 0000000..23e2a21 --- /dev/null +++ b/src/modules/activities/types/category.ts @@ -0,0 +1,35 @@ +/** + * Activity categories inspired by p'a semana + * Mapped to NIP-52 't' (hashtag) tags + */ +export const ACTIVITY_CATEGORIES = { + concert: 'concert', + workshop: 'workshop', + market: 'market', + festival: 'festival', + exhibition: 'exhibition', + sport: 'sport', + theater: 'theater', + cinema: 'cinema', + party: 'party', + talk: 'talk', + conference: 'conference', + meetup: 'meetup', + food: 'food', + outdoor: 'outdoor', + kids: 'kids', + wellness: 'wellness', + technology: 'technology', + art: 'art', + music: 'music', + dance: 'dance', + literature: 'literature', + comedy: 'comedy', + charity: 'charity', + tradition: 'tradition', + other: 'other', +} as const + +export type ActivityCategory = typeof ACTIVITY_CATEGORIES[keyof typeof ACTIVITY_CATEGORIES] + +export const ALL_CATEGORIES = Object.values(ACTIVITY_CATEGORIES) diff --git a/src/modules/activities/types/filters.ts b/src/modules/activities/types/filters.ts new file mode 100644 index 0000000..4585b58 --- /dev/null +++ b/src/modules/activities/types/filters.ts @@ -0,0 +1,28 @@ +import type { ActivityCategory } from './category' + +/** + * Temporal filter presets (p'a semana style) + */ +export type TemporalFilter = 'all' | 'today' | 'tomorrow' | 'this-week' | 'this-month' + +/** + * Combined filter state for activity discovery + */ +export interface ActivityFilters { + temporal: TemporalFilter + categories: ActivityCategory[] + /** Free text search */ + search?: string + /** Geohash prefix for geographic filtering */ + geohash?: string + /** Filter by specific organizer pubkey */ + organizerPubkey?: string +} + +/** + * Default filter state + */ +export const DEFAULT_FILTERS: ActivityFilters = { + temporal: 'all', + categories: [], +} diff --git a/src/modules/activities/types/nip52.ts b/src/modules/activities/types/nip52.ts new file mode 100644 index 0000000..a83cc51 --- /dev/null +++ b/src/modules/activities/types/nip52.ts @@ -0,0 +1,215 @@ +import type { Event as NostrEvent } from 'nostr-tools' + +/** + * NIP-52 Calendar Event kinds + * https://github.com/nostr-protocol/nips/blob/master/52.md + */ +export const NIP52_KINDS = { + /** Date-based calendar event (all-day / multi-day) */ + CALENDAR_DATE_EVENT: 31922, + /** Time-based calendar event (specific times with timezone) */ + CALENDAR_TIME_EVENT: 31923, + /** Calendar (collection of calendar events) */ + CALENDAR: 31924, + /** Calendar Event RSVP */ + RSVP: 31925, +} as const + +export type Nip52Kind = typeof NIP52_KINDS[keyof typeof NIP52_KINDS] + +/** + * Parsed NIP-52 date-based calendar event (kind 31922) + */ +export interface CalendarDateEvent { + dTag: string + pubkey: string + title: string + summary?: string + content: string + image?: string + start: string // ISO 8601 date: YYYY-MM-DD + end?: string // ISO 8601 date: YYYY-MM-DD + location?: string + geohash?: string + hashtags: string[] + participants: Participant[] + references: string[] + id: string + createdAt: number +} + +/** + * Parsed NIP-52 time-based calendar event (kind 31923) + */ +export interface CalendarTimeEvent { + dTag: string + pubkey: string + title: string + summary?: string + content: string + image?: string + start: number // Unix timestamp + end?: number // Unix timestamp + startTzid?: string // IANA timezone identifier + endTzid?: string // IANA timezone identifier + location?: string + geohash?: string + hashtags: string[] + participants: Participant[] + references: string[] + id: string + createdAt: number +} + +export interface Participant { + pubkey: string + relayUrl?: string + role?: string // 'organizer' | 'performer' | 'host' | etc. +} + +/** + * RSVP status values per NIP-52 + */ +export type RSVPStatus = 'accepted' | 'declined' | 'tentative' + +/** + * Parsed NIP-52 RSVP (kind 31925) + */ +export interface CalendarRSVP { + dTag: string + pubkey: string + eventCoordinate: string // 'a' tag: kind:pubkey:d-tag + eventId?: string // 'e' tag (optional) + status: RSVPStatus + freebusy?: 'free' | 'busy' + id: string + createdAt: number +} + +// --- Tag parsing helpers --- + +function getTagValue(tags: string[][], tagName: string): string | undefined { + return tags.find(t => t[0] === tagName)?.[1] +} + +function getTagValues(tags: string[][], tagName: string): string[] { + return tags.filter(t => t[0] === tagName).map(t => t[1]) +} + +/** + * Parse a Nostr event into a CalendarTimeEvent (kind 31923) + */ +export function parseCalendarTimeEvent(event: NostrEvent): CalendarTimeEvent | null { + if (event.kind !== NIP52_KINDS.CALENDAR_TIME_EVENT) return null + + const dTag = getTagValue(event.tags, 'd') + const title = getTagValue(event.tags, 'title') + const startStr = getTagValue(event.tags, 'start') + + if (!dTag || !title || !startStr) return null + + const endStr = getTagValue(event.tags, 'end') + + const participants: Participant[] = event.tags + .filter(t => t[0] === 'p') + .map(t => ({ + pubkey: t[1], + relayUrl: t[2] || undefined, + role: t[3] || undefined, + })) + + return { + dTag, + pubkey: event.pubkey, + title, + summary: getTagValue(event.tags, 'summary'), + content: event.content, + image: getTagValue(event.tags, 'image'), + start: parseInt(startStr, 10), + end: endStr ? parseInt(endStr, 10) : undefined, + startTzid: getTagValue(event.tags, 'start_tzid'), + endTzid: getTagValue(event.tags, 'end_tzid'), + location: getTagValue(event.tags, 'location'), + geohash: getTagValue(event.tags, 'g'), + hashtags: getTagValues(event.tags, 't'), + participants, + references: getTagValues(event.tags, 'r'), + id: event.id, + createdAt: event.created_at, + } +} + +/** + * Parse a Nostr event into a CalendarDateEvent (kind 31922) + */ +export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | null { + if (event.kind !== NIP52_KINDS.CALENDAR_DATE_EVENT) return null + + const dTag = getTagValue(event.tags, 'd') + const title = getTagValue(event.tags, 'title') + const start = getTagValue(event.tags, 'start') + + if (!dTag || !title || !start) return null + + const participants: Participant[] = event.tags + .filter(t => t[0] === 'p') + .map(t => ({ + pubkey: t[1], + relayUrl: t[2] || undefined, + role: t[3] || undefined, + })) + + return { + dTag, + pubkey: event.pubkey, + title, + summary: getTagValue(event.tags, 'summary'), + content: event.content, + image: getTagValue(event.tags, 'image'), + start, + end: getTagValue(event.tags, 'end'), + location: getTagValue(event.tags, 'location'), + geohash: getTagValue(event.tags, 'g'), + hashtags: getTagValues(event.tags, 't'), + participants, + references: getTagValues(event.tags, 'r'), + id: event.id, + createdAt: event.created_at, + } +} + +/** + * Build NIP-52 tags for a time-based calendar event + */ +export function buildCalendarTimeEventTags(event: Partial): string[][] { + const tags: string[][] = [] + + if (event.dTag) tags.push(['d', event.dTag]) + if (event.title) tags.push(['title', event.title]) + if (event.summary) tags.push(['summary', event.summary]) + if (event.image) tags.push(['image', event.image]) + if (event.start != null) tags.push(['start', String(event.start)]) + if (event.end != null) tags.push(['end', String(event.end)]) + if (event.startTzid) tags.push(['start_tzid', event.startTzid]) + if (event.endTzid) tags.push(['end_tzid', event.endTzid]) + if (event.location) tags.push(['location', event.location]) + if (event.geohash) tags.push(['g', event.geohash]) + + for (const tag of event.hashtags ?? []) { + tags.push(['t', tag]) + } + + for (const p of event.participants ?? []) { + const pTag = ['p', p.pubkey] + if (p.relayUrl) pTag.push(p.relayUrl) + else if (p.role) pTag.push('') + if (p.role) pTag.push(p.role) + tags.push(pTag) + } + + for (const r of event.references ?? []) { + tags.push(['r', r]) + } + + return tags +} diff --git a/src/modules/activities/types/ticket.ts b/src/modules/activities/types/ticket.ts new file mode 100644 index 0000000..709b24f --- /dev/null +++ b/src/modules/activities/types/ticket.ts @@ -0,0 +1,42 @@ +/** + * Database-backed ticket types (via LNbits events extension) + */ + +export interface ActivityTicket { + id: string + wallet: string + /** Reference to the activity (LNbits event ID) */ + activityId: string + /** Ticket holder name */ + name: string | null + /** Ticket holder email */ + email: string | null + /** Nostr pubkey or LNbits user ID */ + userId: string | null + /** Whether ticket has been scanned/registered at the door */ + registered: boolean + /** Whether payment has been confirmed */ + paid: boolean + /** Ticket creation timestamp */ + time: string + /** Registration/scan timestamp */ + regTimestamp: string +} + +export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled' + +export interface TicketPurchaseRequest { + activityId: string + userId: string + accessToken: string +} + +export interface TicketPurchaseInvoice { + paymentHash: string + paymentRequest: string +} + +export interface TicketPaymentStatus { + paid: boolean + ticketId?: string +} diff --git a/src/modules/activities/views/ActivitiesCalendarPage.vue b/src/modules/activities/views/ActivitiesCalendarPage.vue new file mode 100644 index 0000000..e09a719 --- /dev/null +++ b/src/modules/activities/views/ActivitiesCalendarPage.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/modules/activities/views/ActivitiesFavoritesPage.vue b/src/modules/activities/views/ActivitiesFavoritesPage.vue new file mode 100644 index 0000000..6c5dee5 --- /dev/null +++ b/src/modules/activities/views/ActivitiesFavoritesPage.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/modules/activities/views/ActivitiesMapPage.vue b/src/modules/activities/views/ActivitiesMapPage.vue new file mode 100644 index 0000000..85e053a --- /dev/null +++ b/src/modules/activities/views/ActivitiesMapPage.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/modules/activities/views/ActivitiesPage.vue b/src/modules/activities/views/ActivitiesPage.vue new file mode 100644 index 0000000..f07e21f --- /dev/null +++ b/src/modules/activities/views/ActivitiesPage.vue @@ -0,0 +1,160 @@ + + + diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue new file mode 100644 index 0000000..ea6fa5a --- /dev/null +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -0,0 +1,156 @@ + + + diff --git a/src/modules/activities/views/MyTicketsPage.vue b/src/modules/activities/views/MyTicketsPage.vue new file mode 100644 index 0000000..e10c5f8 --- /dev/null +++ b/src/modules/activities/views/MyTicketsPage.vue @@ -0,0 +1,10 @@ + + + diff --git a/vite.activities.config.ts b/vite.activities.config.ts new file mode 100644 index 0000000..59329b3 --- /dev/null +++ b/vite.activities.config.ts @@ -0,0 +1,113 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +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) + */ +function activitiesHtmlPlugin(): Plugin { + return { + name: 'activities-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Rewrite all non-asset requests to activities.html + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !req.url.includes('.') // skip files with extensions + ) { + req.url = '/activities.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Sortir activities app. + * Deployed to sortir.ariege.io + */ +export default defineConfig(({ mode }) => ({ + plugins: [ + activitiesHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { + enabled: true, + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + }, + includeAssets: [ + 'favicon.ico', + 'apple-touch-icon.png', + 'mask-icon.svg', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-192.png', + 'icon-maskable-512.png', + ], + manifest: { + name: 'Sortir — Activités & Événements', + short_name: 'Sortir', + description: 'Découvrez les activités et événements près de chez vous', + theme_color: '#1f2937', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: '/', + scope: '/', + id: 'sortir-activities', + categories: ['social', 'entertainment', 'lifestyle'], + lang: 'fr', + icons: [ + { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-activities/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist-activities', + rollupOptions: { + input: 'activities.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +}))