From e98356ffa0b56086ed9dde202df7cc6138cb27d6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Apr 2026 18:46:04 +0200 Subject: [PATCH] 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 @@ + + +