Compare commits

..

3 commits

Author SHA1 Message Date
00eddc9189 Add standalone Sortir activities app (sortir.ariege.io)
Second Vite entry point for deploying the activities module as an
independent PWA at sortir.ariege.io. Includes its own App.vue with
bottom navigation bar (p'a semana style: Feed, Calendar, Map, Favorites,
Settings), stripped-down app config (base + activities only), French
PWA manifest, and SPA fallback plugin for dev server. New routes for
calendar, map, and favorites views (placeholder). Settings page with
theme toggle, language switcher (FR/EN), and auth.

Build: npm run build:activities -> dist-activities/
Dev:   npm run dev:activities -> localhost:5173

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 07:40:26 +02:00
eebc1865c9 Add activities event discovery UI (Phase 1)
Composables (useActivities, useActivityFilters, useActivityDetail) for
subscribing to NIP-52 calendar events, filtering by temporal range and
category, and loading single activity details. Components: ActivityCard
with image/placeholder, date, location, category badge; ActivityList
with responsive grid, loading skeletons, and empty state; TemporalFilterBar
(today/tomorrow/week/month pills); CategoryFilterBar (25 categories);
DatePickerStrip (horizontal week calendar). Full ActivitiesPage with
search, filters, upcoming/past tabs. ActivityDetailPage with hero image,
organizer info, and description. Activities nav link added (no auth required).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 18:48:53 +02:00
e98356ffa0 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>
2026-04-19 18:46:04 +02:00
42 changed files with 2960 additions and 7 deletions

View file

@ -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
<!-- ✅ 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:**
- 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

19
activities.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Sortir — Activités</title>
<meta name="apple-mobile-web-app-title" content="Sortir">
<meta name="description" content="Découvrez les activités et événements près de chez vous">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/activities-app/main.ts"></script>
</body>
</html>

View file

@ -9,6 +9,9 @@
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host",
"analyze": "vite build --mode analyze",
"dev:activities": "vite --host --config vite.activities.config.ts",
"build:activities": "vue-tsc -b && vite build --config vite.activities.config.ts",
"preview:activities": "vite preview --host --config vite.activities.config.ts",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder",

View file

@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import {
CalendarDays, Map, Heart, Settings, Search,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
useTheme()
const showLoginDialog = ref(false)
// Bottom navigation tabs (p'a semana style)
const bottomTabs = [
{ name: 'Feed', icon: Search, path: '/activities' },
{ name: 'Calendar', icon: CalendarDays, path: '/activities/calendar' },
{ name: 'Map', icon: Map, path: '/activities/map' },
{ name: 'Favorites', icon: Heart, path: '/activities/favorites' },
{ name: 'Settings', icon: Settings, path: '/settings' },
]
const isLoginPage = computed(() => route.path === '/login')
function isActiveTab(path: string): boolean {
if (path === '/activities') {
return route.path === '/activities' || route.path.startsWith('/activities/') &&
!route.path.startsWith('/activities/calendar') &&
!route.path.startsWith('/activities/map') &&
!route.path.startsWith('/activities/favorites')
}
return route.path.startsWith(path)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<div class="min-h-screen bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<!-- Main content (with bottom padding for nav bar) -->
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
<router-view />
</main>
<!-- Bottom navigation bar (p'a semana style) -->
<nav
v-if="!isLoginPage"
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<button
v-for="tab in bottomTabs"
:key="tab.path"
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="isActiveTab(tab.path)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'"
@click="router.push(tab.path)"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ tab.name }}</span>
</button>
</div>
</nav>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
</template>

View file

@ -0,0 +1,55 @@
import type { AppConfig } from '@/core/types'
/**
* Standalone activities app configuration.
* Only enables base + activities modules.
*/
export const appConfig: AppConfig = {
modules: {
base: {
name: 'base',
enabled: true,
lazy: false,
config: {
nostr: {
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
},
auth: {
sessionTimeout: 24 * 60 * 60 * 1000,
},
pwa: {
autoPrompt: true
},
imageUpload: {
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
maxSizeMB: 10,
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
}
}
},
activities: {
name: 'activities',
enabled: true,
lazy: false,
config: {
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
apiKey: import.meta.env.VITE_API_KEY || ''
},
defaultMapCenter: { lat: 42.9667, lng: 1.6000 }, // Ariège, France
maxTicketsPerUser: 10,
enableMap: true,
enablePrivateEvents: false
}
},
},
features: {
pwa: true,
pushNotifications: true,
electronApp: false,
developmentMode: import.meta.env.DEV
}
}
export default appConfig

132
src/activities-app/app.ts Normal file
View file

@ -0,0 +1,132 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
import { pluginManager } from '@/core/plugin-manager'
import { eventBus } from '@/core/event-bus'
import { container } from '@/core/di-container'
import appConfig from './app.config'
import baseModule from '@/modules/base'
import activitiesModule from '@/modules/activities'
import App from './App.vue'
import '@/assets/index.css'
import { i18n } from '@/i18n'
/**
* Initialize the standalone activities app
*/
export async function createAppInstance() {
console.log('🚀 Starting Sortir — Activities App...')
const app = createApp(App)
// Collect routes from enabled modules only
const moduleRoutes = [
...baseModule.routes || [],
...activitiesModule.routes || [],
].filter(Boolean)
const router = createRouter({
history: createWebHistory(),
routes: [
// Activities page is the home page in standalone mode
{
path: '/',
redirect: '/activities'
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/Login.vue'),
meta: { requiresAuth: false }
},
...moduleRoutes,
// App-specific routes
{
path: '/settings',
name: 'settings',
component: () => import('./views/SettingsPage.vue'),
meta: { requiresAuth: false }
},
]
})
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.use(i18n)
// Initialize plugin manager
pluginManager.init(app, router)
// Register modules
const moduleRegistrations = []
if (appConfig.modules.base.enabled) {
moduleRegistrations.push(
pluginManager.register(baseModule, appConfig.modules.base)
)
}
if (appConfig.modules.activities?.enabled) {
moduleRegistrations.push(
pluginManager.register(activitiesModule, appConfig.modules.activities)
)
}
await Promise.all(moduleRegistrations)
await pluginManager.installAll()
// Initialize auth
const { auth } = await import('@/composables/useAuthService')
await auth.initialize()
// Auth guard — only redirect for routes that explicitly require auth
router.beforeEach(async (to, _from, next) => {
const requiresAuth = to.meta.requiresAuth === true
if (requiresAuth && !auth.isAuthenticated.value) {
next('/login')
} else if (to.path === '/login' && auth.isAuthenticated.value) {
next('/')
} else {
next()
}
})
// Global error handling
app.config.errorHandler = (err, _vm, info) => {
console.error('Global error:', err, info)
eventBus.emit('app:error', { error: err, info }, 'app')
}
if (appConfig.features.developmentMode) {
;(window as any).__pluginManager = pluginManager
;(window as any).__eventBus = eventBus
;(window as any).__container = container
}
console.log('✅ Sortir app initialized')
return { app, router }
}
export async function startApp() {
try {
const { app } = await createAppInstance()
app.mount('#app')
console.log('🎉 Sortir app started!')
eventBus.emit('app:started', {}, 'app')
} catch (error) {
console.error('💥 Failed to start Sortir app:', error)
document.getElementById('app')!.innerHTML = `
<div style="padding: 20px; text-align: center; color: red;">
<h1>Failed to Start</h1>
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
<p>Please refresh the page.</p>
</div>
`
}
}

View file

@ -0,0 +1,18 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import 'vue-sonner/style.css'
// PWA service worker with periodic updates
const intervalMS = 60 * 60 * 1000 // 1 hour
registerSW({
onRegistered(r) {
r && setInterval(() => {
r.update()
}, intervalMS)
},
onOfflineReady() {
console.log('Sortir app ready to work offline')
}
})
startApp()

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { useTheme } from '@/components/theme-provider'
import { changeLocale, type AvailableLocale } from '@/i18n'
import { auth } from '@/composables/useAuthService'
import { useI18n } from 'vue-i18n'
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
const { theme, setTheme } = useTheme()
const { locale } = useI18n()
const isAuthenticated = computed(() => auth.isAuthenticated.value)
const userPubkey = computed(() => auth.currentUser.value?.pubkey)
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
function setLanguage(lang: AvailableLocale) {
changeLocale(lang)
}
async function handleLogout() {
await auth.logout()
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<h1 class="text-2xl font-bold text-foreground mb-6">Settings</h1>
<!-- Account -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Account</h2>
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
<p class="text-sm text-foreground font-mono truncate">
{{ userPubkey }}
</p>
<Button variant="outline" size="sm" class="w-full gap-2" @click="handleLogout">
<LogOut class="w-4 h-4" />
Log out
</Button>
</div>
<div v-else class="bg-muted/50 rounded-lg p-4">
<p class="text-sm text-muted-foreground mb-3">
Log in to bookmark activities, RSVP, and purchase tickets.
</p>
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
<LogIn class="w-4 h-4" />
Log in
</Button>
</div>
</div>
<Separator class="my-6" />
<!-- Appearance -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Appearance</h2>
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
<span class="text-sm text-foreground">Theme</span>
<Button variant="outline" size="icon" class="h-8 w-8" @click="toggleTheme">
<Sun v-if="theme === 'dark'" class="w-4 h-4" />
<Moon v-else class="w-4 h-4" />
</Button>
</div>
</div>
<Separator class="my-6" />
<!-- Language -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Language</h2>
<div class="flex gap-2">
<Button
v-for="lang in (['fr', 'en'] as const)"
:key="lang"
:variant="locale === lang ? 'default' : 'outline'"
size="sm"
@click="setLanguage(lang)"
>
{{ lang === 'fr' ? 'Français' : 'English' }}
</Button>
</div>
</div>
</div>
</template>

View file

@ -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,

View file

@ -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)

View file

@ -42,6 +42,14 @@ export function useModularNavigation() {
})
}
if (appConfig.modules.activities?.enabled) {
items.push({
name: t('nav.activities'),
href: '/activities',
requiresAuth: false
})
}
if (appConfig.modules.chat.enabled) {
items.push({
name: t('nav.chat'),

View file

@ -143,6 +143,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'),

View file

@ -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',

View file

@ -9,6 +9,7 @@ const messages: LocaleMessages = {
events: 'Eventos',
market: 'Mercado',
chat: 'Chat',
activities: 'Actividades',
login: 'Iniciar Sesión',
logout: 'Cerrar Sesión'
},

View file

@ -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',

View file

@ -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<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
dateTimeFormats: {
short: {

View file

@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket } from 'lucide-vue-next'
import type { Activity } from '../types/activity'
const props = defineProps<{
activity: Activity
}>()
const emit = defineEmits<{
click: [activity: Activity]
}>()
const { t } = useI18n()
const dateDisplay = computed(() => {
const a = props.activity
if (a.type === 'date') {
return format(a.startDate, 'EEE, MMM d')
}
const date = format(a.startDate, 'EEE, MMM d')
const time = format(a.startDate, 'HH:mm')
return `${date} \u2022 ${time}`
})
const categoryLabel = computed(() => {
if (!props.activity.category) return null
return t(`activities.categories.${props.activity.category}`, props.activity.category)
})
const priceDisplay = computed(() => {
const info = props.activity.ticketInfo
if (!info) return null
if (info.price === 0) return t('activities.detail.free')
return `${info.price} ${info.currency}`
})
const placeholderBg = computed(() => {
// Generate a consistent hue from the activity title
const hash = props.activity.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const hue = hash % 360
return `hsl(${hue}, 40%, 85%)`
})
</script>
<template>
<Card
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
@click="emit('click', activity)"
>
<!-- Image / Placeholder -->
<div class="relative aspect-[16/9] overflow-hidden">
<img
v-if="activity.image"
:src="activity.image"
:alt="activity.title"
class="w-full h-full object-cover"
loading="lazy"
/>
<div
v-else
class="w-full h-full flex items-center justify-center"
:style="{ backgroundColor: placeholderBg }"
>
<Calendar class="w-12 h-12 text-foreground/20" />
</div>
<!-- Category badge -->
<Badge
v-if="categoryLabel"
variant="secondary"
class="absolute top-2 left-2 text-xs"
>
{{ categoryLabel }}
</Badge>
<!-- Price badge -->
<Badge
v-if="priceDisplay"
class="absolute top-2 right-2 text-xs"
>
{{ priceDisplay }}
</Badge>
</div>
<CardContent class="p-4 flex-1 flex flex-col gap-2">
<!-- Title -->
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight">
{{ activity.title }}
</h3>
<!-- Summary -->
<p
v-if="activity.summary"
class="text-sm text-muted-foreground line-clamp-2"
>
{{ activity.summary }}
</p>
<div class="mt-auto space-y-1.5 pt-2">
<!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ dateDisplay }}</span>
</div>
<!-- Location -->
<div
v-if="activity.location"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<MapPin class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ activity.location }}</span>
</div>
<!-- Tickets available -->
<div
v-if="activity.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }}
</span>
</div>
</div>
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Calendar } from 'lucide-vue-next'
import ActivityCard from './ActivityCard.vue'
import type { Activity } from '../types/activity'
defineProps<{
activities: Activity[]
isLoading?: boolean
}>()
const emit = defineEmits<{
select: [activity: Activity]
}>()
const { t } = useI18n()
</script>
<template>
<!-- Loading skeleton -->
<div v-if="isLoading" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="i in 6"
:key="i"
class="rounded-lg border bg-card animate-pulse"
>
<div class="aspect-[16/9] bg-muted" />
<div class="p-4 space-y-3">
<div class="h-5 bg-muted rounded w-3/4" />
<div class="h-4 bg-muted rounded w-full" />
<div class="h-4 bg-muted rounded w-1/2" />
</div>
</div>
</div>
<!-- Empty state -->
<div
v-else-if="activities.length === 0"
class="flex flex-col items-center justify-center py-16 text-center"
>
<Calendar class="w-16 h-16 text-muted-foreground/30 mb-4" />
<h3 class="text-lg font-medium text-foreground mb-1">
{{ t('activities.noActivities') }}
</h3>
<p class="text-sm text-muted-foreground">
Try adjusting your filters or check back later
</p>
</div>
<!-- Activity grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<ActivityCard
v-for="activity in activities"
:key="activity.nostrEventId"
:activity="activity"
@click="emit('select', activity)"
/>
</div>
</template>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
import type { ActivityCategory } from '../types/category'
import { ALL_CATEGORIES } from '../types/category'
const props = defineProps<{
selected: ActivityCategory[]
}>()
const emit = defineEmits<{
toggle: [category: ActivityCategory]
clear: []
}>()
const { t } = useI18n()
function categoryLabel(cat: ActivityCategory): string {
return t(`activities.categories.${cat}`, cat)
}
</script>
<template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-muted-foreground">Categories</span>
<Button
v-if="props.selected.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="emit('clear')"
>
<X class="w-3 h-3 mr-1" />
Clear
</Button>
</div>
<div class="flex flex-wrap gap-1.5">
<Badge
v-for="cat in ALL_CATEGORIES"
:key="cat"
:variant="props.selected.includes(cat) ? 'default' : 'outline'"
class="cursor-pointer text-xs select-none hover:opacity-80 transition-opacity"
@click="emit('toggle', cat)"
>
{{ categoryLabel(cat) }}
</Badge>
</div>
</div>
</template>

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { addDays, format, isSameDay, startOfWeek } from 'date-fns'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
const props = defineProps<{
/** Currently selected date (if any) */
selectedDate?: Date
}>()
const emit = defineEmits<{
select: [date: Date]
}>()
/** Start of the visible week */
const weekStart = ref(startOfWeek(new Date(), { weekStartsOn: 1 }))
const days = computed(() => {
return Array.from({ length: 7 }, (_, i) => addDays(weekStart.value, i))
})
const isToday = (date: Date) => isSameDay(date, new Date())
const isSelected = (date: Date) => props.selectedDate ? isSameDay(date, props.selectedDate) : false
function prevWeek() {
weekStart.value = addDays(weekStart.value, -7)
}
function nextWeek() {
weekStart.value = addDays(weekStart.value, 7)
}
</script>
<template>
<div class="flex items-center gap-2">
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="prevWeek">
<ChevronLeft class="h-4 w-4" />
</Button>
<div class="flex gap-1 overflow-x-auto flex-1 justify-center">
<button
v-for="day in days"
:key="day.toISOString()"
class="flex flex-col items-center px-2.5 py-1.5 rounded-lg min-w-[3rem] transition-colors"
:class="{
'bg-primary text-primary-foreground': isSelected(day),
'bg-muted/50': isToday(day) && !isSelected(day),
'hover:bg-muted': !isSelected(day),
}"
@click="emit('select', day)"
>
<span class="text-[10px] font-medium uppercase leading-none"
:class="isSelected(day) ? 'text-primary-foreground/70' : 'text-muted-foreground'"
>
{{ format(day, 'EEE') }}
</span>
<span class="text-sm font-semibold leading-tight mt-0.5">
{{ format(day, 'd') }}
</span>
</button>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="nextWeek">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import type { TemporalFilter } from '../types/filters'
const props = defineProps<{
modelValue: TemporalFilter
}>()
const emit = defineEmits<{
'update:modelValue': [value: TemporalFilter]
}>()
const { t } = useI18n()
const options: { value: TemporalFilter; labelKey: string }[] = [
{ value: 'all', labelKey: 'activities.filters.all' },
{ value: 'today', labelKey: 'activities.filters.today' },
{ value: 'tomorrow', labelKey: 'activities.filters.tomorrow' },
{ value: 'this-week', labelKey: 'activities.filters.thisWeek' },
{ value: 'this-month', labelKey: 'activities.filters.thisMonth' },
]
</script>
<template>
<div class="flex flex-wrap gap-2">
<Button
v-for="option in options"
:key="option.value"
:variant="props.modelValue === option.value ? 'default' : 'outline'"
size="sm"
class="rounded-full text-xs"
@click="emit('update:modelValue', option.value)"
>
{{ t(option.labelKey) }}
</Button>
</div>
</template>

View file

@ -0,0 +1,132 @@
import { ref, computed, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters'
/**
* Main composable for activities discovery.
* Subscribes to NIP-52 events via ActivitiesNostrService and manages the activity feed.
*/
export function useActivities() {
const store = useActivitiesStore()
const filters = useActivityFilters()
const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null)
let unsubscribe: (() => void) | null = null
// Filtered and sorted activities
const filteredActivities = computed(() => {
const upcoming = store.upcomingActivities
return filters.applyFilters(upcoming)
})
const pastFilteredActivities = computed(() => {
return filters.applyFilters(store.pastActivities)
})
/**
* Subscribe to NIP-52 calendar events from Nostr relays.
*/
function subscribe(eventFilters?: CalendarEventFilters) {
if (isSubscribed.value) return
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
subscriptionError.value = 'Activities service not available'
return
}
try {
store.isLoading = true
subscriptionError.value = null
unsubscribe = nostrService.subscribeToCalendarEvents(
(activity) => {
store.upsertActivity(activity)
store.isLoading = false
},
eventFilters
)
isSubscribed.value = true
// Set loading to false after a timeout (in case no events arrive)
setTimeout(() => {
store.isLoading = false
}, 5000)
} catch (err) {
subscriptionError.value = err instanceof Error ? err.message : 'Failed to subscribe'
store.isLoading = false
}
}
/**
* One-shot query for calendar events.
*/
async function query(eventFilters?: CalendarEventFilters) {
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
subscriptionError.value = 'Activities service not available'
return
}
try {
store.isLoading = true
subscriptionError.value = null
const activities = await nostrService.queryCalendarEvents(eventFilters)
store.upsertActivities(activities)
} catch (err) {
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
} finally {
store.isLoading = false
}
}
/**
* Unsubscribe from relay events.
*/
function stop() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
isSubscribed.value = false
}
/**
* Refresh: stop current subscription and re-subscribe.
*/
function refresh(eventFilters?: CalendarEventFilters) {
stop()
store.clearAll()
subscribe(eventFilters)
}
// Cleanup on unmount
onUnmounted(() => {
stop()
})
return {
// State
activities: filteredActivities,
pastActivities: pastFilteredActivities,
allActivities: computed(() => store.activities),
isLoading: computed(() => store.isLoading),
isSubscribed,
error: subscriptionError,
lastUpdated: computed(() => store.lastUpdated),
// Filter controls (re-exported)
...filters,
// Actions
subscribe,
query,
stop,
refresh,
}
}

View file

@ -0,0 +1,78 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import { useActivitiesStore } from '../stores/activities'
import type { Activity } from '../types/activity'
/**
* Composable for loading a single activity by its d-tag identifier.
* First checks the store cache, then queries relays if not found.
*/
export function useActivityDetail(activityId: string) {
const store = useActivitiesStore()
const isLoading = ref(false)
const error = ref<string | null>(null)
let unsubscribe: (() => void) | null = null
const activity = computed<Activity | undefined>(() =>
store.getActivityById(activityId)
)
async function load() {
// Already in cache
if (activity.value) return
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
error.value = 'Activities service not available'
return
}
try {
isLoading.value = true
error.value = null
// Subscribe and wait for this specific event
unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => {
store.upsertActivity(incoming)
if (incoming.id === activityId) {
isLoading.value = false
}
}
)
// Also do a one-shot query
const results = await nostrService.queryCalendarEvents()
store.upsertActivities(results)
// If we still don't have it after query, stop loading
setTimeout(() => {
isLoading.value = false
if (!activity.value) {
error.value = 'Activity not found'
}
}, 5000)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load activity'
isLoading.value = false
}
}
onMounted(() => {
load()
})
onUnmounted(() => {
if (unsubscribe) {
unsubscribe()
}
})
return {
activity,
isLoading,
error,
reload: load,
}
}

View file

@ -0,0 +1,136 @@
import { ref, computed } from 'vue'
import {
startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays,
} from 'date-fns'
import type { Activity } from '../types/activity'
import type { ActivityCategory } from '../types/category'
import type { TemporalFilter, ActivityFilters } from '../types/filters'
import { DEFAULT_FILTERS } from '../types/filters'
/**
* Composable for managing activity filter state and applying filters reactively.
*/
export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const searchQuery = ref('')
const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value,
categories: selectedCategories.value,
search: searchQuery.value || undefined,
}))
/**
* Apply the current filters to a list of activities.
*/
function applyFilters(activities: Activity[]): Activity[] {
let result = activities
// Temporal filter
result = applyTemporalFilter(result, temporal.value)
// Category filter
if (selectedCategories.value.length > 0) {
result = result.filter(a =>
a.category && selectedCategories.value.includes(a.category)
)
}
// Search filter
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim()
result = result.filter(a =>
a.title.toLowerCase().includes(query) ||
a.summary?.toLowerCase().includes(query) ||
a.description.toLowerCase().includes(query) ||
a.location?.toLowerCase().includes(query)
)
}
return result
}
function setTemporal(value: TemporalFilter) {
temporal.value = value
}
function toggleCategory(category: ActivityCategory) {
const idx = selectedCategories.value.indexOf(category)
if (idx >= 0) {
selectedCategories.value.splice(idx, 1)
} else {
selectedCategories.value.push(category)
}
}
function clearCategories() {
selectedCategories.value = []
}
function resetFilters() {
temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = []
searchQuery.value = ''
}
const hasActiveFilters = computed(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
searchQuery.value.trim().length > 0
)
return {
// State
temporal,
selectedCategories,
searchQuery,
filters,
hasActiveFilters,
// Actions
applyFilters,
setTemporal,
toggleCategory,
clearCategories,
resetFilters,
}
}
// --- Helpers ---
function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Activity[] {
if (filter === 'all') return activities
const now = new Date()
let start: Date
let end: Date
switch (filter) {
case 'today':
start = startOfDay(now)
end = endOfDay(now)
break
case 'tomorrow':
start = startOfDay(addDays(now, 1))
end = endOfDay(addDays(now, 1))
break
case 'this-week':
start = startOfWeek(now, { weekStartsOn: 1 })
end = endOfWeek(now, { weekStartsOn: 1 })
break
case 'this-month':
start = startOfMonth(now)
end = endOfMonth(now)
break
default:
return activities
}
return activities.filter(a => {
const activityEnd = a.endDate ?? a.startDate
// Activity overlaps with the filter range
return a.startDate <= end && activityEnd >= start
})
}

View file

@ -0,0 +1,128 @@
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
import { SERVICE_TOKENS } from '@/core/di-container'
import { ActivitiesNostrService } from './services/ActivitiesNostrService'
import { TicketApiService, type TicketApiConfig } from './services/TicketApiService'
export interface ActivitiesModuleConfig {
apiConfig: TicketApiConfig
defaultMapCenter?: { lat: number; lng: number }
maxTicketsPerUser?: number
enableMap?: boolean
enablePrivateEvents?: boolean
}
/**
* Activities Module Plugin
*
* Nostr-native communal events module using NIP-52 Calendar Events
* for discovery, with database-backed ticketing via LNbits.
*/
export const activitiesModule = createModulePlugin({
name: 'activities',
version: '1.0.0',
dependencies: ['base'],
routes: [
{
path: '/activities',
name: 'activities',
component: () => import('./views/ActivitiesPage.vue'),
meta: {
title: 'Activities',
requiresAuth: false,
},
},
{
path: '/activities/calendar',
name: 'activities-calendar',
component: () => import('./views/ActivitiesCalendarPage.vue'),
meta: {
title: 'Calendar',
requiresAuth: false,
},
},
{
path: '/activities/map',
name: 'activities-map',
component: () => import('./views/ActivitiesMapPage.vue'),
meta: {
title: 'Map',
requiresAuth: false,
},
},
{
path: '/activities/favorites',
name: 'activities-favorites',
component: () => import('./views/ActivitiesFavoritesPage.vue'),
meta: {
title: 'Favorites',
requiresAuth: true,
},
},
{
path: '/activities/:id',
name: 'activity-detail',
component: () => import('./views/ActivityDetailPage.vue'),
meta: {
title: 'Activity',
requiresAuth: false,
},
},
{
path: '/my-tickets',
name: 'my-tickets-v2',
component: () => import('./views/MyTicketsPage.vue'),
meta: {
title: 'My Tickets',
requiresAuth: true,
},
},
],
eventListeners: [
{
event: 'payment:completed',
handler: (event) => {
console.log('Activities module: payment completed', event.data)
},
description: 'Handle payment completion for ticket purchases',
},
],
onInstall: async (_app, options) => {
const config = options?.config as ActivitiesModuleConfig | undefined
if (!config) {
throw new Error('Activities module requires configuration')
}
const { container } = await import('@/core/di-container')
// 1. Create services
const nostrService = new ActivitiesNostrService()
const ticketApi = new TicketApiService(config.apiConfig)
// 2. Register in DI container BEFORE initialization
container.provide(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE, nostrService)
container.provide(SERVICE_TOKENS.ACTIVITIES_TICKET_API, ticketApi)
// 3. Initialize the Nostr service (needs RelayHub dependency)
await nostrService.initialize({
waitForDependencies: true,
maxRetries: 3,
})
},
onUninstall: async () => {
const { container } = await import('@/core/di-container')
container.remove(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
container.remove(SERVICE_TOKENS.ACTIVITIES_TICKET_API)
},
})
export default activitiesModule
// Re-export types for external use
export type { Activity, OrganizerInfo, ActivityTicketInfo } from './types/activity'
export type { ActivityTicket, TicketStatus } from './types/ticket'
export type { ActivityCategory } from './types/category'
export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52'

View 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 = []
}
}

View 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 ?? '',
}
}
}

View 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>
}

View 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()
}
}

View 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,
}
})

View 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),
}
}

View 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)

View 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: [],
}

View 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
}

View 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
}

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import { CalendarDays } from 'lucide-vue-next'
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Calendar</h1>
<div class="flex flex-col items-center justify-center py-16 text-center">
<CalendarDays class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">Calendar view coming in Phase 5</p>
</div>
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import { Heart } from 'lucide-vue-next'
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Favorites</h1>
<div class="flex flex-col items-center justify-center py-16 text-center">
<Heart class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">Bookmark your favorite activities</p>
<p class="text-sm text-muted-foreground/70 mt-1">Coming in Phase 4</p>
</div>
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import { Map } from 'lucide-vue-next'
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Map</h1>
<div class="flex flex-col items-center justify-center py-16 text-center">
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">Map view coming in Phase 5</p>
</div>
</div>
</template>

View file

@ -0,0 +1,160 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { RefreshCw, Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import DatePickerStrip from '../components/DatePickerStrip.vue'
import ActivityList from '../components/ActivityList.vue'
import type { Activity } from '../types/activity'
const router = useRouter()
const { t } = useI18n()
const {
activities,
pastActivities,
isLoading,
error,
temporal,
selectedCategories,
searchQuery,
hasActiveFilters,
toggleCategory,
clearCategories,
resetFilters,
subscribe,
refresh,
} = useActivities()
const filtersOpen = ref(false)
onMounted(() => {
subscribe()
})
function handleSelectActivity(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
}
function handleRefresh() {
refresh()
}
</script>
<template>
<div class="container mx-auto py-6 px-4">
<!-- Page header -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div class="space-y-1">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ t('activities.title') }}
</h1>
</div>
<div class="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" size="sm" @click="handleRefresh" :disabled="isLoading">
<RefreshCw class="w-4 h-4 mr-1.5" :class="{ 'animate-spin': isLoading }" />
Refresh
</Button>
</div>
</div>
<!-- Search bar -->
<div class="relative mb-4">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search activities..."
class="pl-9"
/>
</div>
<!-- Date picker strip (p'a semana style) -->
<div class="mb-4">
<DatePickerStrip />
</div>
<!-- Temporal filter pills -->
<div class="mb-4">
<TemporalFilterBar v-model="temporal" />
</div>
<!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child>
<Button variant="ghost" size="sm" class="gap-1.5 text-muted-foreground">
<SlidersHorizontal class="w-4 h-4" />
Categories
<span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5">
{{ selectedCategories.length }}
</span>
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="mt-2">
<CategoryFilterBar
:selected="selectedCategories"
@toggle="toggleCategory"
@clear="clearCategories"
/>
</CollapsibleContent>
</Collapsible>
<!-- Active filters indicator -->
<div v-if="hasActiveFilters" class="flex items-center gap-2 mb-4">
<span class="text-xs text-muted-foreground">Filters active</span>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="resetFilters">
Clear all
</Button>
</div>
<!-- Error state -->
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
{{ error }}
</div>
<!-- Tabs: Upcoming / Past -->
<Tabs default-value="upcoming" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="upcoming">
Upcoming
<span v-if="activities.length > 0" class="ml-1.5 text-xs opacity-60">
({{ activities.length }})
</span>
</TabsTrigger>
<TabsTrigger value="past">
Past
<span v-if="pastActivities.length > 0" class="ml-1.5 text-xs opacity-60">
({{ pastActivities.length }})
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
<ActivityList
:activities="activities"
:is-loading="isLoading"
@select="handleSelectActivity"
/>
</TabsContent>
<TabsContent value="past">
<ActivityList
:activities="pastActivities"
:is-loading="isLoading"
@select="handleSelectActivity"
/>
</TabsContent>
</Tabs>
</div>
</template>

View file

@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Calendar, MapPin, ArrowLeft, User,
} from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const activityId = route.params.id as string
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
const dateDisplay = computed(() => {
if (!activity.value) return ''
const a = activity.value
if (a.type === 'date') {
const start = format(a.startDate, 'EEEE, MMMM d, yyyy')
if (a.endDate) {
return `${start}${format(a.endDate, 'EEEE, MMMM d, yyyy')}`
}
return start
}
const start = format(a.startDate, 'EEEE, MMMM d, yyyy \u2022 HH:mm')
if (a.endDate) {
return `${start}${format(a.endDate, 'HH:mm')}`
}
return start
})
const categoryLabel = computed(() => {
if (!activity.value?.category) return null
return t(`activities.categories.${activity.value.category}`, activity.value.category)
})
function goBack() {
router.push({ name: 'activities' })
}
</script>
<template>
<div class="container mx-auto py-6 px-4 max-w-3xl">
<!-- Back button -->
<Button variant="ghost" size="sm" class="mb-4 gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<!-- Loading -->
<div v-if="isLoading" class="space-y-4">
<div class="aspect-[16/9] bg-muted rounded-lg animate-pulse" />
<div class="h-8 bg-muted rounded w-3/4 animate-pulse" />
<div class="h-4 bg-muted rounded w-1/2 animate-pulse" />
<div class="h-32 bg-muted rounded animate-pulse" />
</div>
<!-- Error -->
<div v-else-if="error" class="text-center py-16">
<h2 class="text-xl font-semibold text-foreground mb-2">Activity not found</h2>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button variant="outline" @click="reload">Retry</Button>
</div>
<!-- Detail content -->
<div v-else-if="activity" class="space-y-6">
<!-- Hero image -->
<div v-if="activity.image" class="rounded-lg overflow-hidden">
<img
:src="activity.image"
:alt="activity.title"
class="w-full aspect-[16/9] object-cover"
/>
</div>
<!-- Title + Category -->
<div>
<div class="flex items-start gap-2 mb-2">
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
{{ categoryLabel }}
</Badge>
<div v-for="tag in activity.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ activity.title }}
</h1>
<p v-if="activity.summary" class="text-muted-foreground mt-2">
{{ activity.summary }}
</p>
</div>
<Separator />
<!-- Info section -->
<div class="grid gap-4 sm:grid-cols-2">
<!-- When -->
<div class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<Calendar class="w-4 h-4" />
{{ t('activities.detail.when') }}
</div>
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
<p v-if="activity.timezone" class="text-xs text-muted-foreground/70">
{{ activity.timezone }}
</p>
</div>
<!-- Where -->
<div v-if="activity.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<MapPin class="w-4 h-4" />
{{ t('activities.detail.location') }}
</div>
<p class="text-sm text-muted-foreground">{{ activity.location }}</p>
</div>
</div>
<!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<User class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">
{{ t('activities.detail.organizer') }}
</p>
<p class="text-xs text-muted-foreground font-mono truncate">
{{ activity.organizer.name ?? activity.organizer.pubkey.slice(0, 16) + '...' }}
</p>
</div>
</div>
</div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
</div>
<!-- External references -->
<div v-if="activity.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->
</div>
</div>
</div>
</template>

View 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>

113
vite.activities.config.ts Normal file
View file

@ -0,0 +1,113 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
/**
* Plugin to rewrite dev server requests to activities.html
* (SPA fallback for the standalone activities app entry point)
*/
function activitiesHtmlPlugin(): Plugin {
return {
name: 'activities-html-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
// Rewrite all non-asset requests to activities.html
if (
req.url &&
!req.url.startsWith('/@') &&
!req.url.startsWith('/src/') &&
!req.url.startsWith('/node_modules/') &&
!req.url.includes('.') // skip files with extensions
) {
req.url = '/activities.html'
}
next()
})
},
}
}
/**
* Vite config for the standalone Sortir activities app.
* Deployed to sortir.ariege.io
*/
export default defineConfig(({ mode }) => ({
plugins: [
activitiesHtmlPlugin(),
vue(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true,
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
includeAssets: [
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Sortir — Activités & Événements',
short_name: 'Sortir',
description: 'Découvrez les activités et événements près de chez vous',
theme_color: '#1f2937',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: '/',
scope: '/',
id: 'sortir-activities',
categories: ['social', 'entertainment', 'lifestyle'],
lang: 'fr',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
ViteImageOptimizer({
jpg: { quality: 80 },
png: { quality: 80 },
webp: { lossless: true },
}),
mode === 'analyze' &&
visualizer({
open: true,
filename: 'dist-activities/stats.html',
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-activities',
rollupOptions: {
input: 'activities.html',
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['radix-vue', '@vueuse/core'],
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
},
},
},
chunkSizeWarningLimit: 1000,
},
}))