From e98356ffa0b56086ed9dde202df7cc6138cb27d6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Apr 2026 18:46:04 +0200 Subject: [PATCH 01/23] Add activities module foundation (Phase 0) Nostr-native communal events module using NIP-52 Calendar Events. Includes types (activity, ticket, category, NIP-52 parsers/builders), services (ActivitiesNostrService, TicketApiService, PaymentProvider abstraction with LNbits implementation), Pinia store with deduplication, module plugin with DI registration, i18n (EN/FR/ES), and placeholder views. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 40 +++- src/app.config.ts | 15 ++ src/app.ts | 11 +- src/core/di-container.ts | 4 + src/i18n/locales/en.ts | 62 +++++ src/i18n/locales/es.ts | 1 + src/i18n/locales/fr.ts | 62 +++++ src/i18n/types.ts | 37 +++ src/modules/activities/index.ts | 101 ++++++++ .../services/ActivitiesNostrService.ts | 170 ++++++++++++++ .../services/LnbitsPaymentProvider.ts | 91 ++++++++ .../services/PaymentProviderInterface.ts | 48 ++++ .../activities/services/TicketApiService.ts | 158 +++++++++++++ src/modules/activities/stores/activities.ts | 100 ++++++++ src/modules/activities/types/activity.ts | 126 ++++++++++ src/modules/activities/types/category.ts | 35 +++ src/modules/activities/types/filters.ts | 28 +++ src/modules/activities/types/nip52.ts | 215 ++++++++++++++++++ src/modules/activities/types/ticket.ts | 42 ++++ .../activities/views/ActivitiesPage.vue | 10 + .../activities/views/ActivityDetailPage.vue | 13 ++ .../activities/views/MyTicketsPage.vue | 10 + 22 files changed, 1376 insertions(+), 3 deletions(-) create mode 100644 src/modules/activities/index.ts create mode 100644 src/modules/activities/services/ActivitiesNostrService.ts create mode 100644 src/modules/activities/services/LnbitsPaymentProvider.ts create mode 100644 src/modules/activities/services/PaymentProviderInterface.ts create mode 100644 src/modules/activities/services/TicketApiService.ts create mode 100644 src/modules/activities/stores/activities.ts create mode 100644 src/modules/activities/types/activity.ts create mode 100644 src/modules/activities/types/category.ts create mode 100644 src/modules/activities/types/filters.ts create mode 100644 src/modules/activities/types/nip52.ts create mode 100644 src/modules/activities/types/ticket.ts create mode 100644 src/modules/activities/views/ActivitiesPage.vue create mode 100644 src/modules/activities/views/ActivityDetailPage.vue create mode 100644 src/modules/activities/views/MyTicketsPage.vue 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/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/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/index.ts b/src/modules/activities/index.ts new file mode 100644 index 0000000..42ec29d --- /dev/null +++ b/src/modules/activities/index.ts @@ -0,0 +1,101 @@ +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/: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/ActivitiesPage.vue b/src/modules/activities/views/ActivitiesPage.vue new file mode 100644 index 0000000..be3503f --- /dev/null +++ b/src/modules/activities/views/ActivitiesPage.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue new file mode 100644 index 0000000..fa33fdf --- /dev/null +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -0,0 +1,13 @@ + + + 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 @@ + + + From eebc1865c927c5707402c09d5babc080a1a4d7bd Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Apr 2026 18:48:53 +0200 Subject: [PATCH 02/23] Add activities event discovery UI (Phase 1) Composables (useActivities, useActivityFilters, useActivityDetail) for subscribing to NIP-52 calendar events, filtering by temporal range and category, and loading single activity details. Components: ActivityCard with image/placeholder, date, location, category badge; ActivityList with responsive grid, loading skeletons, and empty state; TemporalFilterBar (today/tomorrow/week/month pills); CategoryFilterBar (25 categories); DatePickerStrip (horizontal week calendar). Full ActivitiesPage with search, filters, upcoming/past tabs. ActivityDetailPage with hero image, organizer info, and description. Activities nav link added (no auth required). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/composables/useModularNavigation.ts | 16 +- .../activities/components/ActivityCard.vue | 136 +++++++++++++++ .../activities/components/ActivityList.vue | 59 +++++++ .../components/CategoryFilterBar.vue | 52 ++++++ .../activities/components/DatePickerStrip.vue | 70 ++++++++ .../components/TemporalFilterBar.vue | 38 +++++ .../activities/composables/useActivities.ts | 132 +++++++++++++++ .../composables/useActivityDetail.ts | 78 +++++++++ .../composables/useActivityFilters.ts | 136 +++++++++++++++ .../activities/views/ActivitiesPage.vue | 158 +++++++++++++++++- .../activities/views/ActivityDetailPage.vue | 151 ++++++++++++++++- 11 files changed, 1014 insertions(+), 12 deletions(-) create mode 100644 src/modules/activities/components/ActivityCard.vue create mode 100644 src/modules/activities/components/ActivityList.vue create mode 100644 src/modules/activities/components/CategoryFilterBar.vue create mode 100644 src/modules/activities/components/DatePickerStrip.vue create mode 100644 src/modules/activities/components/TemporalFilterBar.vue create mode 100644 src/modules/activities/composables/useActivities.ts create mode 100644 src/modules/activities/composables/useActivityDetail.ts create mode 100644 src/modules/activities/composables/useActivityFilters.ts 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/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/views/ActivitiesPage.vue b/src/modules/activities/views/ActivitiesPage.vue index be3503f..f07e21f 100644 --- a/src/modules/activities/views/ActivitiesPage.vue +++ b/src/modules/activities/views/ActivitiesPage.vue @@ -1,10 +1,160 @@ diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index fa33fdf..ea6fa5a 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -1,13 +1,156 @@ From 00eddc918925438c64c79ec48a7981340eaf076d Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Apr 2026 07:40:26 +0200 Subject: [PATCH 03/23] Add standalone Sortir activities app (sortir.ariege.io) Second Vite entry point for deploying the activities module as an independent PWA at sortir.ariege.io. Includes its own App.vue with bottom navigation bar (p'a semana style: Feed, Calendar, Map, Favorites, Settings), stripped-down app config (base + activities only), French PWA manifest, and SPA fallback plugin for dev server. New routes for calendar, map, and favorites views (placeholder). Settings page with theme toggle, language switcher (FR/EN), and auth. Build: npm run build:activities -> dist-activities/ Dev: npm run dev:activities -> localhost:5173 Co-Authored-By: Claude Opus 4.6 (1M context) --- activities.html | 19 +++ package.json | 3 + src/activities-app/App.vue | 82 +++++++++++ src/activities-app/app.config.ts | 55 ++++++++ src/activities-app/app.ts | 132 ++++++++++++++++++ src/activities-app/main.ts | 18 +++ src/activities-app/views/SettingsPage.vue | 89 ++++++++++++ src/modules/activities/index.ts | 27 ++++ .../views/ActivitiesCalendarPage.vue | 13 ++ .../views/ActivitiesFavoritesPage.vue | 14 ++ .../activities/views/ActivitiesMapPage.vue | 13 ++ vite.activities.config.ts | 113 +++++++++++++++ 12 files changed, 578 insertions(+) create mode 100644 activities.html create mode 100644 src/activities-app/App.vue create mode 100644 src/activities-app/app.config.ts create mode 100644 src/activities-app/app.ts create mode 100644 src/activities-app/main.ts create mode 100644 src/activities-app/views/SettingsPage.vue create mode 100644 src/modules/activities/views/ActivitiesCalendarPage.vue create mode 100644 src/modules/activities/views/ActivitiesFavoritesPage.vue create mode 100644 src/modules/activities/views/ActivitiesMapPage.vue create mode 100644 vite.activities.config.ts 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/modules/activities/index.ts b/src/modules/activities/index.ts index 42ec29d..2816a86 100644 --- a/src/modules/activities/index.ts +++ b/src/modules/activities/index.ts @@ -32,6 +32,33 @@ export const activitiesModule = createModulePlugin({ 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', 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/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, + }, +})) From ac163d3b827c78e0efb76cd0e668f64ae84c359d Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Apr 2026 07:58:41 +0200 Subject: [PATCH 04/23] Add activity creation and publishing (Phase 2) CreateActivityDialog with vee-validate + Zod form: title, summary, description, start/end date+time, location, categories (multi-select), and image URL. Signs NIP-52 kind 31923 events with user's signing key via nostr-tools finalizeEvent and publishes through RelayHub. Fixed ActivitiesNostrService.publishCalendarEvent to properly sign events before publishing. CategorySelector and LocationPicker helper components. Create button visible only to authenticated users. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/CategorySelector.vue | 45 +++ .../components/CreateActivityDialog.vue | 270 ++++++++++++++++++ .../activities/components/LocationPicker.vue | 34 +++ .../services/ActivitiesNostrService.ts | 22 +- .../activities/views/ActivitiesPage.vue | 21 +- 5 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 src/modules/activities/components/CategorySelector.vue create mode 100644 src/modules/activities/components/CreateActivityDialog.vue create mode 100644 src/modules/activities/components/LocationPicker.vue diff --git a/src/modules/activities/components/CategorySelector.vue b/src/modules/activities/components/CategorySelector.vue new file mode 100644 index 0000000..5acb3f8 --- /dev/null +++ b/src/modules/activities/components/CategorySelector.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/modules/activities/components/CreateActivityDialog.vue b/src/modules/activities/components/CreateActivityDialog.vue new file mode 100644 index 0000000..0c6935a --- /dev/null +++ b/src/modules/activities/components/CreateActivityDialog.vue @@ -0,0 +1,270 @@ + + +