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) <noreply@anthropic.com>
This commit is contained in:
parent
caad99a645
commit
e98356ffa0
22 changed files with 1376 additions and 3 deletions
40
CLAUDE.md
40
CLAUDE.md
|
|
@ -723,6 +723,31 @@ export function useMyModule() {
|
||||||
- Handle connection recovery in `onResume()` callback
|
- Handle connection recovery in `onResume()` callback
|
||||||
- Implement battery-conscious pausing in `onPause()` 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
|
||||||
|
<!-- ✅ CORRECT: Semantic classes that adapt to theme -->
|
||||||
|
<div class="bg-background text-foreground border-border">
|
||||||
|
<p class="text-muted-foreground">Secondary text</p>
|
||||||
|
<div class="bg-card rounded-lg">Card content</div>
|
||||||
|
<button class="bg-primary text-primary-foreground">Action</button>
|
||||||
|
|
||||||
|
<!-- ❌ WRONG: Hard-coded colors break theme switching -->
|
||||||
|
<div class="bg-white text-gray-900 border-gray-200">
|
||||||
|
<p class="text-gray-600">Secondary text</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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:**
|
### **Code Conventions:**
|
||||||
- Use TypeScript interfaces over types for extendability
|
- Use TypeScript interfaces over types for extendability
|
||||||
- Prefer functional and declarative patterns over classes (except for services)
|
- 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
|
- Electron Forge configured for cross-platform packaging
|
||||||
- TailwindCSS v4 integration via Vite plugin
|
- TailwindCSS v4 integration via Vite plugin
|
||||||
|
|
||||||
**Environment:**
|
**Environment Variables** (see `.env.example`):
|
||||||
- Nostr relay configuration via `VITE_NOSTR_RELAYS` environment variable
|
- `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
|
- PWA manifest configured for standalone app experience
|
||||||
- Service worker with automatic updates every hour
|
- Service worker with automatic updates every hour
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,21 @@ export const appConfig: AppConfig = {
|
||||||
maxTicketsPerUser: 10
|
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: {
|
wallet: {
|
||||||
name: 'wallet',
|
name: 'wallet',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
|
||||||
11
src/app.ts
11
src/app.ts
|
|
@ -16,6 +16,7 @@ import chatModule from './modules/chat'
|
||||||
import eventsModule from './modules/events'
|
import eventsModule from './modules/events'
|
||||||
import marketModule from './modules/market'
|
import marketModule from './modules/market'
|
||||||
import walletModule from './modules/wallet'
|
import walletModule from './modules/wallet'
|
||||||
|
import activitiesModule from './modules/activities'
|
||||||
|
|
||||||
// Root component
|
// Root component
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
@ -43,7 +44,8 @@ export async function createAppInstance() {
|
||||||
...chatModule.routes || [],
|
...chatModule.routes || [],
|
||||||
...eventsModule.routes || [],
|
...eventsModule.routes || [],
|
||||||
...marketModule.routes || [],
|
...marketModule.routes || [],
|
||||||
...walletModule.routes || []
|
...walletModule.routes || [],
|
||||||
|
...activitiesModule.routes || []
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
// Create router with all routes available immediately
|
// 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
|
// Wait for all modules to register
|
||||||
await Promise.all(moduleRegistrations)
|
await Promise.all(moduleRegistrations)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,10 @@ export const SERVICE_TOKENS = {
|
||||||
|
|
||||||
// Events services
|
// Events services
|
||||||
EVENTS_SERVICE: Symbol('eventsService'),
|
EVENTS_SERVICE: Symbol('eventsService'),
|
||||||
|
|
||||||
|
// Activities services (Nostr-native events module)
|
||||||
|
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
||||||
|
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
|
||||||
|
|
||||||
// Invoice services
|
// Invoice services
|
||||||
INVOICE_SERVICE: Symbol('invoiceService'),
|
INVOICE_SERVICE: Symbol('invoiceService'),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const messages: LocaleMessages = {
|
||||||
events: 'Events',
|
events: 'Events',
|
||||||
market: 'Market',
|
market: 'Market',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
activities: 'Activities',
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
logout: 'Logout'
|
logout: 'Logout'
|
||||||
},
|
},
|
||||||
|
|
@ -29,6 +30,67 @@ const messages: LocaleMessages = {
|
||||||
de: 'German',
|
de: 'German',
|
||||||
zh: 'Chinese'
|
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: {
|
dateTimeFormats: {
|
||||||
short: {
|
short: {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const messages: LocaleMessages = {
|
||||||
events: 'Eventos',
|
events: 'Eventos',
|
||||||
market: 'Mercado',
|
market: 'Mercado',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
activities: 'Actividades',
|
||||||
login: 'Iniciar Sesión',
|
login: 'Iniciar Sesión',
|
||||||
logout: 'Cerrar Sesión'
|
logout: 'Cerrar Sesión'
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const messages: LocaleMessages = {
|
||||||
events: 'Événements',
|
events: 'Événements',
|
||||||
market: 'Marché',
|
market: 'Marché',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
activities: 'Activités',
|
||||||
login: 'Connexion',
|
login: 'Connexion',
|
||||||
logout: 'Déconnexion'
|
logout: 'Déconnexion'
|
||||||
},
|
},
|
||||||
|
|
@ -29,6 +30,67 @@ const messages: LocaleMessages = {
|
||||||
de: 'Allemand',
|
de: 'Allemand',
|
||||||
zh: 'Chinois'
|
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: {
|
dateTimeFormats: {
|
||||||
short: {
|
short: {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface LocaleMessages {
|
||||||
events: string
|
events: string
|
||||||
market: string
|
market: string
|
||||||
chat: string
|
chat: string
|
||||||
|
activities: string
|
||||||
login: string
|
login: string
|
||||||
logout: string
|
logout: string
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +30,42 @@ export interface LocaleMessages {
|
||||||
de: string
|
de: string
|
||||||
zh: 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<string, string>
|
||||||
|
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
|
// Add date/time formats
|
||||||
dateTimeFormats: {
|
dateTimeFormats: {
|
||||||
short: {
|
short: {
|
||||||
|
|
|
||||||
101
src/modules/activities/index.ts
Normal file
101
src/modules/activities/index.ts
Normal file
|
|
@ -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'
|
||||||
170
src/modules/activities/services/ActivitiesNostrService.ts
Normal file
170
src/modules/activities/services/ActivitiesNostrService.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<Activity[]> {
|
||||||
|
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<CalendarTimeEvent>
|
||||||
|
): 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<Record<string, any>> {
|
||||||
|
const filter: Record<string, any> = {
|
||||||
|
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<void> {
|
||||||
|
// Clean up all active subscriptions
|
||||||
|
for (const unsub of this.activeUnsubscribes) {
|
||||||
|
unsub()
|
||||||
|
}
|
||||||
|
this.activeUnsubscribes = []
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/modules/activities/services/LnbitsPaymentProvider.ts
Normal file
91
src/modules/activities/services/LnbitsPaymentProvider.ts
Normal file
|
|
@ -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<InvoiceResult> {
|
||||||
|
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<PaymentStatus> {
|
||||||
|
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<PayInvoiceResult> {
|
||||||
|
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 ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/modules/activities/services/PaymentProviderInterface.ts
Normal file
48
src/modules/activities/services/PaymentProviderInterface.ts
Normal file
|
|
@ -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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InvoiceResult>
|
||||||
|
|
||||||
|
/** Check whether an invoice has been paid */
|
||||||
|
checkPaymentStatus(paymentHash: string): Promise<PaymentStatus>
|
||||||
|
|
||||||
|
/** Pay a Lightning invoice from the user's wallet */
|
||||||
|
payInvoice(paymentRequest: string): Promise<PayInvoiceResult>
|
||||||
|
}
|
||||||
158
src/modules/activities/services/TicketApiService.ts
Normal file
158
src/modules/activities/services/TicketApiService.ts
Normal file
|
|
@ -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<any[]> {
|
||||||
|
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<TicketPurchaseInvoice> {
|
||||||
|
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<TicketPaymentStatus> {
|
||||||
|
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<ActivityTicket[]> {
|
||||||
|
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<ActivityTicket[]> {
|
||||||
|
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<any> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'X-API-KEY': this.config.apiKey,
|
||||||
|
...(init.headers as Record<string, string> ?? {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/modules/activities/stores/activities.ts
Normal file
100
src/modules/activities/stores/activities.ts
Normal file
|
|
@ -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<Map<string, Activity>>(new Map())
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const lastUpdated = ref<Date | null>(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,
|
||||||
|
}
|
||||||
|
})
|
||||||
126
src/modules/activities/types/activity.ts
Normal file
126
src/modules/activities/types/activity.ts
Normal file
|
|
@ -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<OrganizerInfo>): 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<OrganizerInfo>): 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/modules/activities/types/category.ts
Normal file
35
src/modules/activities/types/category.ts
Normal file
|
|
@ -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)
|
||||||
28
src/modules/activities/types/filters.ts
Normal file
28
src/modules/activities/types/filters.ts
Normal file
|
|
@ -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: [],
|
||||||
|
}
|
||||||
215
src/modules/activities/types/nip52.ts
Normal file
215
src/modules/activities/types/nip52.ts
Normal file
|
|
@ -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<CalendarTimeEvent>): 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
|
||||||
|
}
|
||||||
42
src/modules/activities/types/ticket.ts
Normal file
42
src/modules/activities/types/ticket.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
10
src/modules/activities/views/ActivitiesPage.vue
Normal file
10
src/modules/activities/views/ActivitiesPage.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Phase 1 will implement the full activities feed with filters
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Activities</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">Coming soon — Nostr-native event discovery</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
13
src/modules/activities/views/ActivityDetailPage.vue
Normal file
13
src/modules/activities/views/ActivityDetailPage.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const activityId = route.params.id as string
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Activity Detail</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">Activity: {{ activityId }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
10
src/modules/activities/views/MyTicketsPage.vue
Normal file
10
src/modules/activities/views/MyTicketsPage.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Phase 3 will implement the full tickets view
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">My Tickets</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">Your purchased tickets will appear here</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue