diff --git a/CLAUDE.md b/CLAUDE.md index 665e214..6fd5d02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -723,31 +723,6 @@ 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) @@ -794,19 +769,8 @@ quantity: productData.quantity ?? 1 - Electron Forge configured for cross-platform packaging - TailwindCSS v4 integration via Vite plugin -**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 +**Environment:** +- Nostr relay configuration via `VITE_NOSTR_RELAYS` environment variable - PWA manifest configured for standalone app experience - Service worker with automatic updates every hour diff --git a/activities.html b/activities.html deleted file mode 100644 index d227e52..0000000 --- a/activities.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - Sortir — Activités - - - - -
- - - diff --git a/package.json b/package.json index ef5de0b..304528e 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,6 @@ "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 deleted file mode 100644 index 4aaa827..0000000 --- a/src/activities-app/App.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - diff --git a/src/activities-app/app.config.ts b/src/activities-app/app.config.ts deleted file mode 100644 index 30cbb22..0000000 --- a/src/activities-app/app.config.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index e997b9b..0000000 --- a/src/activities-app/app.ts +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index c9c8429..0000000 --- a/src/activities-app/main.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 007264c..0000000 --- a/src/activities-app/views/SettingsPage.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/src/app.config.ts b/src/app.config.ts index 11e2df8..8060762 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -75,21 +75,6 @@ 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 1d11e26..459283e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,7 +16,6 @@ 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' @@ -44,8 +43,7 @@ export async function createAppInstance() { ...chatModule.routes || [], ...eventsModule.routes || [], ...marketModule.routes || [], - ...walletModule.routes || [], - ...activitiesModule.routes || [] + ...walletModule.routes || [] ].filter(Boolean) // Create router with all routes available immediately @@ -128,13 +126,6 @@ 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 c57c10c..af47f66 100644 --- a/src/composables/useModularNavigation.ts +++ b/src/composables/useModularNavigation.ts @@ -42,19 +42,11 @@ 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 aa16b78..32221f5 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -142,10 +142,6 @@ 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 70fe924..7c39b78 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -9,7 +9,6 @@ const messages: LocaleMessages = { events: 'Events', market: 'Market', chat: 'Chat', - activities: 'Activities', login: 'Login', logout: 'Logout' }, @@ -30,67 +29,6 @@ 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 d0fa65d..303ed46 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -9,7 +9,6 @@ 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 e18f854..2fb2b3c 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -9,7 +9,6 @@ const messages: LocaleMessages = { events: 'Événements', market: 'Marché', chat: 'Chat', - activities: 'Activités', login: 'Connexion', logout: 'Déconnexion' }, @@ -30,67 +29,6 @@ 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 9faf7d7..12747de 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -7,7 +7,6 @@ export interface LocaleMessages { events: string market: string chat: string - activities: string login: string logout: string } @@ -30,42 +29,6 @@ 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 deleted file mode 100644 index 6503536..0000000 --- a/src/modules/activities/components/ActivityCard.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - diff --git a/src/modules/activities/components/ActivityList.vue b/src/modules/activities/components/ActivityList.vue deleted file mode 100644 index 4dac127..0000000 --- a/src/modules/activities/components/ActivityList.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - diff --git a/src/modules/activities/components/CategoryFilterBar.vue b/src/modules/activities/components/CategoryFilterBar.vue deleted file mode 100644 index 8069dcf..0000000 --- a/src/modules/activities/components/CategoryFilterBar.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/src/modules/activities/components/DatePickerStrip.vue b/src/modules/activities/components/DatePickerStrip.vue deleted file mode 100644 index b9ea5cb..0000000 --- a/src/modules/activities/components/DatePickerStrip.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/src/modules/activities/components/TemporalFilterBar.vue b/src/modules/activities/components/TemporalFilterBar.vue deleted file mode 100644 index 2ccb4ec..0000000 --- a/src/modules/activities/components/TemporalFilterBar.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/src/modules/activities/composables/useActivities.ts b/src/modules/activities/composables/useActivities.ts deleted file mode 100644 index 7d4186b..0000000 --- a/src/modules/activities/composables/useActivities.ts +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index e29a272..0000000 --- a/src/modules/activities/composables/useActivityDetail.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index a42016c..0000000 --- a/src/modules/activities/composables/useActivityFilters.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index 2816a86..0000000 --- a/src/modules/activities/index.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 8f9008e..0000000 --- a/src/modules/activities/services/ActivitiesNostrService.ts +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index 140bad4..0000000 --- a/src/modules/activities/services/LnbitsPaymentProvider.ts +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index e35fda0..0000000 --- a/src/modules/activities/services/PaymentProviderInterface.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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 deleted file mode 100644 index 61d5572..0000000 --- a/src/modules/activities/services/TicketApiService.ts +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index 1faec7b..0000000 --- a/src/modules/activities/stores/activities.ts +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 7785725..0000000 --- a/src/modules/activities/types/activity.ts +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 23e2a21..0000000 --- a/src/modules/activities/types/category.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4585b58..0000000 --- a/src/modules/activities/types/filters.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index a83cc51..0000000 --- a/src/modules/activities/types/nip52.ts +++ /dev/null @@ -1,215 +0,0 @@ -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 deleted file mode 100644 index 709b24f..0000000 --- a/src/modules/activities/types/ticket.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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 deleted file mode 100644 index e09a719..0000000 --- a/src/modules/activities/views/ActivitiesCalendarPage.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/src/modules/activities/views/ActivitiesFavoritesPage.vue b/src/modules/activities/views/ActivitiesFavoritesPage.vue deleted file mode 100644 index 6c5dee5..0000000 --- a/src/modules/activities/views/ActivitiesFavoritesPage.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/src/modules/activities/views/ActivitiesMapPage.vue b/src/modules/activities/views/ActivitiesMapPage.vue deleted file mode 100644 index 85e053a..0000000 --- a/src/modules/activities/views/ActivitiesMapPage.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/src/modules/activities/views/ActivitiesPage.vue b/src/modules/activities/views/ActivitiesPage.vue deleted file mode 100644 index f07e21f..0000000 --- a/src/modules/activities/views/ActivitiesPage.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue deleted file mode 100644 index ea6fa5a..0000000 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ /dev/null @@ -1,156 +0,0 @@ - - - diff --git a/src/modules/activities/views/MyTicketsPage.vue b/src/modules/activities/views/MyTicketsPage.vue deleted file mode 100644 index e10c5f8..0000000 --- a/src/modules/activities/views/MyTicketsPage.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/vite.activities.config.ts b/vite.activities.config.ts deleted file mode 100644 index 59329b3..0000000 --- a/vite.activities.config.ts +++ /dev/null @@ -1,113 +0,0 @@ -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, - }, -}))