+
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