Compare commits
No commits in common. "00eddc918925438c64c79ec48a7981340eaf076d" and "caad99a645a70682bb4a03bc8f077761dfb9ac76" have entirely different histories.
00eddc9189
...
caad99a645
42 changed files with 7 additions and 2960 deletions
40
CLAUDE.md
40
CLAUDE.md
|
|
@ -723,31 +723,6 @@ 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)
|
||||||
|
|
@ -794,19 +769,8 @@ 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 Variables** (see `.env.example`):
|
**Environment:**
|
||||||
- `VITE_APP_NAME` - Application display name
|
- Nostr relay configuration via `VITE_NOSTR_RELAYS` environment variable
|
||||||
- `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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -9,9 +9,6 @@
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview --host",
|
"preview": "vite preview --host",
|
||||||
"analyze": "vite build --mode analyze",
|
"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:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
||||||
"electron:package": "electron-builder",
|
"electron:package": "electron-builder",
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
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>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -75,21 +75,6 @@ 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,7 +16,6 @@ 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'
|
||||||
|
|
@ -44,8 +43,7 @@ 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
|
||||||
|
|
@ -128,13 +126,6 @@ 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,19 +42,11 @@ export function useModularNavigation() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appConfig.modules.activities?.enabled) {
|
|
||||||
items.push({
|
|
||||||
name: t('nav.activities'),
|
|
||||||
href: '/activities',
|
|
||||||
requiresAuth: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appConfig.modules.chat.enabled) {
|
if (appConfig.modules.chat.enabled) {
|
||||||
items.push({
|
items.push({
|
||||||
name: t('nav.chat'),
|
name: t('nav.chat'),
|
||||||
href: '/chat',
|
href: '/chat',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,10 +142,6 @@ 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,7 +9,6 @@ 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'
|
||||||
},
|
},
|
||||||
|
|
@ -30,67 +29,6 @@ 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,7 +9,6 @@ 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,7 +9,6 @@ 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'
|
||||||
},
|
},
|
||||||
|
|
@ -30,67 +29,6 @@ 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,7 +7,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -30,42 +29,6 @@ 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: {
|
||||||
|
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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'
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
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 = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
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 ?? '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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: [],
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue