merge nostrized-events module into unified main
Adds the standalone activities/events PWA build: - vite.activities.config.ts (standalone entry point) - build:activities npm script - i18n support (Spanish Latin American translations) - Map/calendar UI improvements Conflicts resolved keeping main-unified's module system (links, tasks, expenses, di-container, navigation).
This commit is contained in:
commit
b1e8534ca7
55 changed files with 4821 additions and 0 deletions
|
|
@ -28,6 +28,12 @@ VITE_PUSH_NOTIFICATIONS_ENABLED=true
|
||||||
# Image Upload Configuration (pict-rs)
|
# Image Upload Configuration (pict-rs)
|
||||||
VITE_PICTRS_BASE_URL=https://img.mydomain.com
|
VITE_PICTRS_BASE_URL=https://img.mydomain.com
|
||||||
|
|
||||||
|
# Activities / Sortir Configuration
|
||||||
|
# Default language for the standalone activities app (fr, en, es)
|
||||||
|
VITE_DEFAULT_LOCALE=fr
|
||||||
|
# Default map center as "lat,lng" (defaults to France center if not set)
|
||||||
|
VITE_DEFAULT_MAP_CENTER=42.9667,1.6000
|
||||||
|
|
||||||
# Market Configuration
|
# Market Configuration
|
||||||
VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrrvc6r2qf8waehxw309akxucnfw3ejuct5d96xcctw9e5k7tmwdaehgunjv4kxz7f0v96xjmczyqrfrfkxv3m8t4elpe28x065z30zszaaqa4u0744qcmadsz3y50cjqcyqqq82scmcafla
|
VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrrvc6r2qf8waehxw309akxucnfw3ejuct5d96xcctw9e5k7tmwdaehgunjv4kxz7f0v96xjmczyqrfrfkxv3m8t4elpe28x065z30zszaaqa4u0744qcmadsz3y50cjqcyqqq82scmcafla
|
||||||
# OBSOLETE: Not used in codebase - market uses VITE_NOSTR_RELAYS instead
|
# OBSOLETE: Not used in codebase - market uses VITE_NOSTR_RELAYS instead
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,6 +9,7 @@ lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
dist-*
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|
|
||||||
19
activities.html
Normal file
19
activities.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||||
|
<title>Sortir — Activités</title>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Sortir">
|
||||||
|
<meta name="description" content="Découvrez les activités et événements près de chez vous">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/activities-app/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
package-lock.json
generated
43
package-lock.json
generated
|
|
@ -18,8 +18,10 @@
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"light-bolt11-decoder": "^3.2.0",
|
"light-bolt11-decoder": "^3.2.0",
|
||||||
"lucide-vue-next": "^0.474.0",
|
"lucide-vue-next": "^0.474.0",
|
||||||
|
"ngeohash": "^0.6.3",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
|
|
@ -48,6 +50,8 @@
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.0.12",
|
"@tailwindcss/vite": "^4.0.12",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"@types/ngeohash": "^0.6.8",
|
||||||
"@types/node": "^22.18.1",
|
"@types/node": "^22.18.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/rollup-plugin-visualizer": "^4.2.3",
|
"@types/rollup-plugin-visualizer": "^4.2.3",
|
||||||
|
|
@ -5067,6 +5071,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/http-cache-semantics": {
|
"node_modules/@types/http-cache-semantics": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
||||||
|
|
@ -5084,6 +5095,23 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ngeohash": {
|
||||||
|
"version": "0.6.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ngeohash/-/ngeohash-0.6.8.tgz",
|
||||||
|
"integrity": "sha512-A90x3HMwE1yXbWCnd0ztHzv8rAQPjwTzX2diYI/6OrWm/3oairDaehw5WPWJFgZ+8+J/OuF99IbipmMa2le6tQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.18.1",
|
"version": "22.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz",
|
||||||
|
|
@ -9755,6 +9783,12 @@
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
|
|
@ -10885,6 +10919,15 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ngeohash": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nice-try": {
|
"node_modules/nice-try": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
"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",
|
||||||
|
|
@ -27,8 +30,10 @@
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"light-bolt11-decoder": "^3.2.0",
|
"light-bolt11-decoder": "^3.2.0",
|
||||||
"lucide-vue-next": "^0.474.0",
|
"lucide-vue-next": "^0.474.0",
|
||||||
|
"ngeohash": "^0.6.3",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
|
|
@ -57,6 +62,8 @@
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.0.12",
|
"@tailwindcss/vite": "^4.0.12",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"@types/ngeohash": "^0.6.8",
|
||||||
"@types/node": "^22.18.1",
|
"@types/node": "^22.18.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/rollup-plugin-visualizer": "^4.2.3",
|
"@types/rollup-plugin-visualizer": "^4.2.3",
|
||||||
|
|
|
||||||
84
src/activities-app/App.vue
Normal file
84
src/activities-app/App.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
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()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
useTheme()
|
||||||
|
|
||||||
|
const showLoginDialog = ref(false)
|
||||||
|
|
||||||
|
// Bottom navigation tabs (p'a semana style)
|
||||||
|
const bottomTabs = computed(() => [
|
||||||
|
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
|
||||||
|
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
|
||||||
|
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
||||||
|
{ name: t('activities.nav.favorites'), icon: Heart, path: '/activities/favorites' },
|
||||||
|
{ name: t('activities.nav.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>
|
||||||
62
src/activities-app/app.config.ts
Normal file
62
src/activities-app/app.config.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { AppConfig } from '@/core/types'
|
||||||
|
|
||||||
|
function parseMapCenter(envValue: string | undefined, fallback: { lat: number; lng: number }) {
|
||||||
|
if (!envValue) return fallback
|
||||||
|
const [lat, lng] = envValue.split(',').map(Number)
|
||||||
|
if (isNaN(lat) || isNaN(lng)) return fallback
|
||||||
|
return { lat, lng }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: parseMapCenter(import.meta.env.VITE_DEFAULT_MAP_CENTER, { lat: 42.9667, lng: 1.6000 }),
|
||||||
|
maxTicketsPerUser: 10,
|
||||||
|
enableMap: true,
|
||||||
|
enablePrivateEvents: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
features: {
|
||||||
|
pwa: true,
|
||||||
|
pushNotifications: true,
|
||||||
|
electronApp: false,
|
||||||
|
developmentMode: import.meta.env.DEV
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default appConfig
|
||||||
138
src/activities-app/app.ts
Normal file
138
src/activities-app/app.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
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, changeLocale, type AvailableLocale } 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)
|
||||||
|
|
||||||
|
// Set default locale from env (user's saved preference takes priority via useStorage in i18n)
|
||||||
|
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||||
|
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||||
|
await changeLocale(defaultLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/activities-app/main.ts
Normal file
18
src/activities-app/main.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { startApp } from './app'
|
||||||
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
|
|
||||||
|
// PWA service worker with periodic updates
|
||||||
|
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||||
|
registerSW({
|
||||||
|
onRegistered(r) {
|
||||||
|
r && setInterval(() => {
|
||||||
|
r.update()
|
||||||
|
}, intervalMS)
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.log('Sortir app ready to work offline')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
startApp()
|
||||||
95
src/activities-app/views/SettingsPage.vue
Normal file
95
src/activities-app/views/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<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 { t, locale } = useI18n()
|
||||||
|
|
||||||
|
const languages: { code: AvailableLocale; label: string }[] = [
|
||||||
|
{ code: 'fr', label: 'Français' },
|
||||||
|
{ code: 'en', label: 'English' },
|
||||||
|
{ code: 'es', label: 'Español' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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">{{ t('activities.settings.title') }}</h1>
|
||||||
|
|
||||||
|
<!-- Account -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.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" />
|
||||||
|
{{ t('activities.settings.logOut') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="bg-muted/50 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-muted-foreground mb-3">
|
||||||
|
{{ t('activities.settings.loginPrompt') }}
|
||||||
|
</p>
|
||||||
|
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
|
||||||
|
<LogIn class="w-4 h-4" />
|
||||||
|
{{ t('activities.settings.logIn') }}
|
||||||
|
</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">{{ t('activities.settings.appearance') }}</h2>
|
||||||
|
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
|
||||||
|
<span class="text-sm text-foreground">{{ t('activities.settings.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">{{ t('activities.settings.language') }}</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="lang in languages"
|
||||||
|
:key="lang.code"
|
||||||
|
:variant="locale === lang.code ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
@click="setLanguage(lang.code)"
|
||||||
|
>
|
||||||
|
{{ lang.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import type { AppConfig } from './core/types'
|
import type { AppConfig } from './core/types'
|
||||||
|
|
||||||
|
function parseMapCenter(envValue: string | undefined, fallback: { lat: number; lng: number }) {
|
||||||
|
if (!envValue) return fallback
|
||||||
|
const [lat, lng] = envValue.split(',').map(Number)
|
||||||
|
if (isNaN(lat) || isNaN(lng)) return fallback
|
||||||
|
return { lat, lng }
|
||||||
|
}
|
||||||
|
|
||||||
export const appConfig: AppConfig = {
|
export const appConfig: AppConfig = {
|
||||||
modules: {
|
modules: {
|
||||||
base: {
|
base: {
|
||||||
|
|
@ -94,6 +101,21 @@ export const appConfig: AppConfig = {
|
||||||
maxTicketsPerUser: 10
|
maxTicketsPerUser: 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
activities: {
|
||||||
|
name: 'activities',
|
||||||
|
enabled: true,
|
||||||
|
lazy: false,
|
||||||
|
config: {
|
||||||
|
apiConfig: {
|
||||||
|
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
|
||||||
|
apiKey: import.meta.env.VITE_API_KEY || ''
|
||||||
|
},
|
||||||
|
defaultMapCenter: parseMapCenter(import.meta.env.VITE_DEFAULT_MAP_CENTER, { lat: 46.6034, lng: 1.8883 }),
|
||||||
|
maxTicketsPerUser: 10,
|
||||||
|
enableMap: true,
|
||||||
|
enablePrivateEvents: false
|
||||||
|
}
|
||||||
|
},
|
||||||
wallet: {
|
wallet: {
|
||||||
name: 'wallet',
|
name: 'wallet',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,10 @@ export const SERVICE_TOKENS = {
|
||||||
// Events services
|
// Events services
|
||||||
EVENTS_SERVICE: Symbol('eventsService'),
|
EVENTS_SERVICE: Symbol('eventsService'),
|
||||||
|
|
||||||
|
// Activities services (Nostr-native events module)
|
||||||
|
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
||||||
|
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
|
||||||
|
|
||||||
// Invoice services
|
// Invoice services
|
||||||
INVOICE_SERVICE: Symbol('invoiceService'),
|
INVOICE_SERVICE: Symbol('invoiceService'),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const messages: LocaleMessages = {
|
||||||
events: 'Events',
|
events: 'Events',
|
||||||
market: 'Market',
|
market: 'Market',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
activities: 'Activities',
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
logout: 'Logout'
|
logout: 'Logout'
|
||||||
},
|
},
|
||||||
|
|
@ -29,6 +30,95 @@ 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',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
feed: 'Feed',
|
||||||
|
calendar: 'Calendar',
|
||||||
|
map: 'Map',
|
||||||
|
favorites: 'Favorites',
|
||||||
|
settings: 'Settings',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Search activities...',
|
||||||
|
noResults: 'No activities found',
|
||||||
|
},
|
||||||
|
favorites: {
|
||||||
|
title: 'Favorites',
|
||||||
|
loginPrompt: 'Log in to save your favorite activities',
|
||||||
|
empty: 'No favorites yet',
|
||||||
|
emptyHint: 'Tap the heart icon on any activity to save it here',
|
||||||
|
logIn: 'Log in',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Settings',
|
||||||
|
account: 'Account',
|
||||||
|
loginPrompt: 'Log in to bookmark activities, RSVP, and purchase tickets.',
|
||||||
|
logIn: 'Log in',
|
||||||
|
logOut: 'Log out',
|
||||||
|
appearance: 'Appearance',
|
||||||
|
theme: 'Theme',
|
||||||
|
language: 'Language',
|
||||||
|
},
|
||||||
|
},
|
||||||
dateTimeFormats: {
|
dateTimeFormats: {
|
||||||
short: {
|
short: {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const messages: LocaleMessages = {
|
||||||
events: 'Eventos',
|
events: 'Eventos',
|
||||||
market: 'Mercado',
|
market: 'Mercado',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
activities: 'Actividades',
|
||||||
login: 'Iniciar Sesión',
|
login: 'Iniciar Sesión',
|
||||||
logout: 'Cerrar Sesión'
|
logout: 'Cerrar Sesión'
|
||||||
},
|
},
|
||||||
|
|
@ -29,6 +30,95 @@ const messages: LocaleMessages = {
|
||||||
de: 'Alemán',
|
de: 'Alemán',
|
||||||
zh: 'Chino'
|
zh: 'Chino'
|
||||||
},
|
},
|
||||||
|
activities: {
|
||||||
|
title: 'Actividades',
|
||||||
|
createNew: 'Crear actividad',
|
||||||
|
noActivities: 'No se encontraron actividades',
|
||||||
|
filters: {
|
||||||
|
all: 'Todas',
|
||||||
|
today: 'Hoy',
|
||||||
|
tomorrow: 'Mañana',
|
||||||
|
thisWeek: 'Esta semana',
|
||||||
|
thisMonth: 'Este mes',
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
concert: 'Concierto',
|
||||||
|
workshop: 'Taller',
|
||||||
|
market: 'Mercado',
|
||||||
|
festival: 'Festival',
|
||||||
|
exhibition: 'Exposición',
|
||||||
|
sport: 'Deporte',
|
||||||
|
theater: 'Teatro',
|
||||||
|
cinema: 'Cine',
|
||||||
|
party: 'Fiesta',
|
||||||
|
talk: 'Charla',
|
||||||
|
conference: 'Conferencia',
|
||||||
|
meetup: 'Encuentro',
|
||||||
|
food: 'Gastronomía',
|
||||||
|
outdoor: 'Al aire libre',
|
||||||
|
kids: 'Niños',
|
||||||
|
wellness: 'Bienestar',
|
||||||
|
technology: 'Tecnología',
|
||||||
|
art: 'Arte',
|
||||||
|
music: 'Música',
|
||||||
|
dance: 'Danza',
|
||||||
|
literature: 'Literatura',
|
||||||
|
comedy: 'Comedia',
|
||||||
|
charity: 'Solidario',
|
||||||
|
tradition: 'Tradición',
|
||||||
|
other: 'Otro',
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
getTicket: 'Obtener boleto',
|
||||||
|
going: 'Voy',
|
||||||
|
maybe: 'Tal vez',
|
||||||
|
notGoing: 'No voy',
|
||||||
|
contactOrganizer: 'Contactar organizador',
|
||||||
|
organizer: 'Organizador',
|
||||||
|
location: 'Ubicación',
|
||||||
|
when: 'Cuándo',
|
||||||
|
tickets: 'Boletos',
|
||||||
|
ticketsAvailable: '{count} boletos disponibles',
|
||||||
|
soldOut: 'Agotado',
|
||||||
|
free: 'Gratis',
|
||||||
|
},
|
||||||
|
tickets: {
|
||||||
|
myTickets: 'Mis boletos',
|
||||||
|
scanTicket: 'Escanear boleto',
|
||||||
|
noTickets: 'Aún no tienes boletos',
|
||||||
|
paid: 'Pagado',
|
||||||
|
pending: 'Pendiente',
|
||||||
|
registered: 'Registrado',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
feed: 'Inicio',
|
||||||
|
calendar: 'Calendario',
|
||||||
|
map: 'Mapa',
|
||||||
|
favorites: 'Favoritos',
|
||||||
|
settings: 'Ajustes',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Buscar actividades...',
|
||||||
|
noResults: 'No se encontraron actividades',
|
||||||
|
},
|
||||||
|
favorites: {
|
||||||
|
title: 'Favoritos',
|
||||||
|
loginPrompt: 'Inicia sesión para guardar tus actividades favoritas',
|
||||||
|
empty: 'Aún no tienes favoritos',
|
||||||
|
emptyHint: 'Toca el corazón en cualquier actividad para guardarla aquí',
|
||||||
|
logIn: 'Iniciar sesión',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Ajustes',
|
||||||
|
account: 'Cuenta',
|
||||||
|
loginPrompt: 'Inicia sesión para guardar actividades, confirmar asistencia y comprar boletos.',
|
||||||
|
logIn: 'Iniciar sesión',
|
||||||
|
logOut: 'Cerrar sesión',
|
||||||
|
appearance: 'Apariencia',
|
||||||
|
theme: 'Tema',
|
||||||
|
language: 'Idioma',
|
||||||
|
},
|
||||||
|
},
|
||||||
dateTimeFormats: {
|
dateTimeFormats: {
|
||||||
short: {
|
short: {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const messages: LocaleMessages = {
|
||||||
events: 'Événements',
|
events: 'Événements',
|
||||||
market: 'Marché',
|
market: 'Marché',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
activities: 'Activités',
|
||||||
login: 'Connexion',
|
login: 'Connexion',
|
||||||
logout: 'Déconnexion'
|
logout: 'Déconnexion'
|
||||||
},
|
},
|
||||||
|
|
@ -29,6 +30,95 @@ 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é',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
feed: 'Fil',
|
||||||
|
calendar: 'Calendrier',
|
||||||
|
map: 'Carte',
|
||||||
|
favorites: 'Favoris',
|
||||||
|
settings: 'Réglages',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Rechercher des activités...',
|
||||||
|
noResults: 'Aucune activité trouvée',
|
||||||
|
},
|
||||||
|
favorites: {
|
||||||
|
title: 'Favoris',
|
||||||
|
loginPrompt: 'Connectez-vous pour sauvegarder vos activités préférées',
|
||||||
|
empty: 'Pas encore de favoris',
|
||||||
|
emptyHint: "Appuyez sur le cœur d'une activité pour la sauvegarder ici",
|
||||||
|
logIn: 'Se connecter',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Réglages',
|
||||||
|
account: 'Compte',
|
||||||
|
loginPrompt: 'Connectez-vous pour sauvegarder des activités, confirmer votre présence et acheter des billets.',
|
||||||
|
logIn: 'Se connecter',
|
||||||
|
logOut: 'Se déconnecter',
|
||||||
|
appearance: 'Apparence',
|
||||||
|
theme: 'Thème',
|
||||||
|
language: 'Langue',
|
||||||
|
},
|
||||||
|
},
|
||||||
dateTimeFormats: {
|
dateTimeFormats: {
|
||||||
short: {
|
short: {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface LocaleMessages {
|
||||||
events: string
|
events: string
|
||||||
market: string
|
market: string
|
||||||
chat: string
|
chat: string
|
||||||
|
activities: string
|
||||||
login: string
|
login: string
|
||||||
logout: string
|
logout: string
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +30,70 @@ 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
|
||||||
|
}
|
||||||
|
nav: {
|
||||||
|
feed: string
|
||||||
|
calendar: string
|
||||||
|
map: string
|
||||||
|
favorites: string
|
||||||
|
settings: string
|
||||||
|
}
|
||||||
|
search: {
|
||||||
|
placeholder: string
|
||||||
|
noResults: string
|
||||||
|
}
|
||||||
|
favorites: {
|
||||||
|
title: string
|
||||||
|
loginPrompt: string
|
||||||
|
empty: string
|
||||||
|
emptyHint: string
|
||||||
|
logIn: string
|
||||||
|
}
|
||||||
|
settings: {
|
||||||
|
title: string
|
||||||
|
account: string
|
||||||
|
loginPrompt: string
|
||||||
|
logIn: string
|
||||||
|
logOut: string
|
||||||
|
appearance: string
|
||||||
|
theme: string
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
}
|
||||||
// Add date/time formats
|
// Add date/time formats
|
||||||
dateTimeFormats: {
|
dateTimeFormats: {
|
||||||
short: {
|
short: {
|
||||||
|
|
|
||||||
183
src/modules/activities/components/ActivityCalendarView.vue
Normal file
183
src/modules/activities/components/ActivityCalendarView.vue
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import {
|
||||||
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
|
eachDayOfInterval, format, isSameMonth, isSameDay,
|
||||||
|
addMonths, subMonths,
|
||||||
|
} from 'date-fns'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activities: Activity[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectDate: [date: Date]
|
||||||
|
selectActivity: [activity: Activity]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { dateLocale } = useDateLocale()
|
||||||
|
|
||||||
|
const currentMonth = ref(new Date())
|
||||||
|
|
||||||
|
const monthLabel = computed(() => format(currentMonth.value, 'MMMM yyyy', { locale: dateLocale.value }))
|
||||||
|
|
||||||
|
const weekDays = computed(() => {
|
||||||
|
// Generate localized single-letter day names starting from Monday
|
||||||
|
const days: string[] = []
|
||||||
|
// Start from a known Monday (2024-01-01 is a Monday)
|
||||||
|
const monday = new Date(2024, 0, 1)
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(monday)
|
||||||
|
d.setDate(d.getDate() + i)
|
||||||
|
days.push(format(d, 'EEEEE', { locale: dateLocale.value }))
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
})
|
||||||
|
|
||||||
|
const calendarDays = computed(() => {
|
||||||
|
const monthStart = startOfMonth(currentMonth.value)
|
||||||
|
const monthEnd = endOfMonth(currentMonth.value)
|
||||||
|
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 })
|
||||||
|
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 })
|
||||||
|
|
||||||
|
return eachDayOfInterval({ start: calStart, end: calEnd })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map of date string -> activities on that day
|
||||||
|
const activityDayMap = computed(() => {
|
||||||
|
const map = new Map<string, Activity[]>()
|
||||||
|
for (const activity of props.activities) {
|
||||||
|
if (!activity.startDate || isNaN(activity.startDate.getTime())) continue
|
||||||
|
const key = format(activity.startDate, 'yyyy-MM-dd')
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(activity)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
function getActivitiesForDay(date: Date): Activity[] {
|
||||||
|
const key = format(date, 'yyyy-MM-dd')
|
||||||
|
return activityDayMap.value.get(key) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDotCount(date: Date): number {
|
||||||
|
return Math.min(getActivitiesForDay(date).length, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDay = ref<Date | null>(null)
|
||||||
|
const selectedDayActivities = computed(() => {
|
||||||
|
if (!selectedDay.value) return []
|
||||||
|
return getActivitiesForDay(selectedDay.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectDay(date: Date) {
|
||||||
|
if (selectedDay.value && isSameDay(selectedDay.value, date)) {
|
||||||
|
selectedDay.value = null
|
||||||
|
} else {
|
||||||
|
selectedDay.value = date
|
||||||
|
emit('selectDate', date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
currentMonth.value = subMonths(currentMonth.value, 1)
|
||||||
|
selectedDay.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
currentMonth.value = addMonths(currentMonth.value, 1)
|
||||||
|
selectedDay.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Month navigation -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">{{ monthLabel }}</h2>
|
||||||
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="nextMonth">
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekday headers -->
|
||||||
|
<div class="grid grid-cols-7 gap-0">
|
||||||
|
<div
|
||||||
|
v-for="day in weekDays"
|
||||||
|
:key="day"
|
||||||
|
class="text-center text-xs font-medium text-muted-foreground py-1"
|
||||||
|
>
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
<div class="grid grid-cols-7 gap-0">
|
||||||
|
<button
|
||||||
|
v-for="date in calendarDays"
|
||||||
|
:key="date.toISOString()"
|
||||||
|
class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
|
||||||
|
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
|
||||||
|
'bg-muted/50': isSameDay(date, new Date()) && !(selectedDay && isSameDay(date, selectedDay)),
|
||||||
|
'hover:bg-muted': !(selectedDay && isSameDay(date, selectedDay)),
|
||||||
|
}"
|
||||||
|
@click="selectDay(date)"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{{ format(date, 'd') }}</span>
|
||||||
|
<!-- Activity dots -->
|
||||||
|
<div v-if="getDotCount(date) > 0" class="flex gap-0.5 mt-0.5">
|
||||||
|
<div
|
||||||
|
v-for="i in getDotCount(date)"
|
||||||
|
:key="i"
|
||||||
|
class="w-1 h-1 rounded-full"
|
||||||
|
:class="selectedDay && isSameDay(date, selectedDay) ? 'bg-primary-foreground' : 'bg-primary'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected day activities -->
|
||||||
|
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
||||||
|
<h3 class="text-sm font-medium text-muted-foreground">
|
||||||
|
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
|
||||||
|
<span v-if="selectedDayActivities.length > 0" class="ml-1">
|
||||||
|
({{ selectedDayActivities.length }})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="selectedDayActivities.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
|
||||||
|
No activities on this day
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="activity in selectedDayActivities"
|
||||||
|
:key="activity.nostrEventId"
|
||||||
|
class="flex items-center gap-3 p-2 rounded-lg hover:bg-muted cursor-pointer"
|
||||||
|
@click="emit('selectActivity', activity)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="activity.image"
|
||||||
|
:src="activity.image"
|
||||||
|
:alt="activity.title"
|
||||||
|
class="w-12 h-12 rounded object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
|
{{ activity.type === 'time' ? format(activity.startDate, 'HH:mm') : '' }}
|
||||||
|
{{ activity.location ? `· ${activity.location}` : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
152
src/modules/activities/components/ActivityCard.vue
Normal file
152
src/modules/activities/components/ActivityCard.vue
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<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 BookmarkButton from './BookmarkButton.vue'
|
||||||
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activity: Activity
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [activity: Activity]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { dateLocale } = useDateLocale()
|
||||||
|
|
||||||
|
const dateDisplay = computed(() => {
|
||||||
|
const a = props.activity
|
||||||
|
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
|
||||||
|
try {
|
||||||
|
const opts = { locale: dateLocale.value }
|
||||||
|
if (a.type === 'date') {
|
||||||
|
return format(a.startDate, 'EEE, MMM d', opts)
|
||||||
|
}
|
||||||
|
const date = format(a.startDate, 'EEE, MMM d', opts)
|
||||||
|
const time = format(a.startDate, 'HH:mm', opts)
|
||||||
|
return `${date} \u2022 ${time}`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 + Bookmark -->
|
||||||
|
<div class="flex items-start gap-1">
|
||||||
|
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
|
||||||
|
{{ activity.title }}
|
||||||
|
</h3>
|
||||||
|
<BookmarkButton
|
||||||
|
:pubkey="activity.organizer.pubkey"
|
||||||
|
:d-tag="activity.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
59
src/modules/activities/components/ActivityList.vue
Normal file
59
src/modules/activities/components/ActivityList.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { CalendarSearch } 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"
|
||||||
|
>
|
||||||
|
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||||
|
<h3 class="text-lg font-medium text-foreground mb-1">
|
||||||
|
{{ t('activities.noActivities') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ t('activities.search.noResults') }}
|
||||||
|
</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>
|
||||||
126
src/modules/activities/components/ActivityMap.vue
Normal file
126
src/modules/activities/components/ActivityMap.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activities: Activity[]
|
||||||
|
center?: { lat: number; lng: number }
|
||||||
|
zoom?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
|
let map: L.Map | null = null
|
||||||
|
let markerGroup: L.LayerGroup | null = null
|
||||||
|
let hasFittedBounds = false
|
||||||
|
|
||||||
|
// Fix Leaflet default icon paths (broken by bundlers)
|
||||||
|
const defaultIcon = L.icon({
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
shadowSize: [41, 41],
|
||||||
|
})
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
if (!mapContainer.value) return
|
||||||
|
|
||||||
|
const defaultCenter = props.center ?? { lat: 46.6034, lng: 1.8883 } // France
|
||||||
|
const defaultZoom = props.zoom ?? 5
|
||||||
|
|
||||||
|
map = L.map(mapContainer.value).setView(
|
||||||
|
[defaultCenter.lat, defaultCenter.lng],
|
||||||
|
defaultZoom
|
||||||
|
)
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
markerGroup = L.layerGroup().addTo(map)
|
||||||
|
|
||||||
|
updateMarkers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarkers() {
|
||||||
|
if (!map || !markerGroup) return
|
||||||
|
|
||||||
|
markerGroup.clearLayers()
|
||||||
|
|
||||||
|
const geoActivities = props.activities.filter(a => a.coordinates)
|
||||||
|
|
||||||
|
for (const activity of geoActivities) {
|
||||||
|
const { lat, lng } = activity.coordinates!
|
||||||
|
|
||||||
|
const marker = L.marker([lat, lng], { icon: defaultIcon })
|
||||||
|
|
||||||
|
const popupContent = `
|
||||||
|
<div style="min-width: 200px; cursor: pointer;" class="activity-popup">
|
||||||
|
${activity.image ? `<img src="${activity.image}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;" />` : ''}
|
||||||
|
<div style="font-weight: 600; font-size: 14px; margin-bottom: 4px;">${escapeHtml(activity.title)}</div>
|
||||||
|
${activity.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(activity.location)}</div>` : ''}
|
||||||
|
<div style="font-size: 12px; color: #888;">📅 ${activity.startDate.toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
marker.bindPopup(popupContent)
|
||||||
|
marker.on('click', () => {
|
||||||
|
marker.openPopup()
|
||||||
|
})
|
||||||
|
// Navigate on popup click
|
||||||
|
marker.on('popupopen', () => {
|
||||||
|
const popup = marker.getPopup()
|
||||||
|
if (popup) {
|
||||||
|
const el = popup.getElement()
|
||||||
|
const content = el?.querySelector('.activity-popup')
|
||||||
|
if (content) {
|
||||||
|
(content as HTMLElement).onclick = () => {
|
||||||
|
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
markerGroup.addLayer(marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit bounds only on first load, not when new activities stream in
|
||||||
|
if (!hasFittedBounds && geoActivities.length > 0) {
|
||||||
|
hasFittedBounds = true
|
||||||
|
const bounds = L.latLngBounds(
|
||||||
|
geoActivities.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
|
||||||
|
)
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = text
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.activities, updateMarkers, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initMap()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (map) {
|
||||||
|
map.remove()
|
||||||
|
map = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="mapContainer" class="w-full h-full min-h-[400px] rounded-lg z-0" />
|
||||||
|
</template>
|
||||||
169
src/modules/activities/components/ActivitySearchOverlay.vue
Normal file
169
src/modules/activities/components/ActivitySearchOverlay.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||||
|
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activities: Activity[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [activity: Activity]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { dateLocale } = useDateLocale()
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const searchOptions: FuzzySearchOptions<Activity> = {
|
||||||
|
fuseOptions: {
|
||||||
|
keys: [
|
||||||
|
{ name: 'title', weight: 0.5 },
|
||||||
|
{ name: 'summary', weight: 0.2 },
|
||||||
|
{ name: 'description', weight: 0.15 },
|
||||||
|
{ name: 'location', weight: 0.1 },
|
||||||
|
{ name: 'tags', weight: 0.05 },
|
||||||
|
],
|
||||||
|
threshold: 0.35,
|
||||||
|
ignoreLocation: true,
|
||||||
|
},
|
||||||
|
matchAllWhenSearchEmpty: false,
|
||||||
|
minSearchLength: 2,
|
||||||
|
resultLimit: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
const activitiesRef = computed(() => props.activities)
|
||||||
|
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
filteredItems,
|
||||||
|
isSearching,
|
||||||
|
clearSearch,
|
||||||
|
setSearchQuery,
|
||||||
|
} = useFuzzySearch(activitiesRef, searchOptions)
|
||||||
|
|
||||||
|
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||||
|
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
||||||
|
|
||||||
|
function formatDate(activity: Activity): string {
|
||||||
|
if (!activity.startDate || isNaN(activity.startDate.getTime())) return ''
|
||||||
|
try {
|
||||||
|
const opts = { locale: dateLocale.value }
|
||||||
|
if (activity.type === 'date') return format(activity.startDate, 'MMM d', opts)
|
||||||
|
return format(activity.startDate, 'MMM d · HH:mm', opts)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(activity: Activity) {
|
||||||
|
clearSearch()
|
||||||
|
isOpen.value = false
|
||||||
|
emit('select', activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
clearSearch()
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(value: string | number) {
|
||||||
|
setSearchQuery(String(value))
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('.search-overlay-container')) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-overlay-container relative">
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
ref="inputRef"
|
||||||
|
:model-value="searchQuery"
|
||||||
|
@update:model-value="handleInput"
|
||||||
|
@focus="handleFocus"
|
||||||
|
:placeholder="t('activities.search.placeholder')"
|
||||||
|
class="pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="searchQuery"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
||||||
|
@click="handleClear"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results dropdown overlay -->
|
||||||
|
<div
|
||||||
|
v-if="showResults || showNoResults"
|
||||||
|
class="absolute left-0 right-0 top-full mt-1 z-50 bg-background border rounded-lg shadow-lg max-h-[50vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- No results -->
|
||||||
|
<div v-if="showNoResults" class="p-4 text-sm text-muted-foreground text-center">
|
||||||
|
{{ t('activities.search.noResults') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result items -->
|
||||||
|
<button
|
||||||
|
v-for="activity in filteredItems"
|
||||||
|
:key="activity.nostrEventId"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-muted transition-colors text-left border-b last:border-b-0"
|
||||||
|
@click="handleSelect(activity)"
|
||||||
|
>
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<img
|
||||||
|
v-if="activity.image"
|
||||||
|
:src="activity.image"
|
||||||
|
:alt="activity.title"
|
||||||
|
class="w-10 h-10 rounded object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-10 h-10 rounded bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<Calendar class="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span v-if="formatDate(activity)" class="truncate">{{ formatDate(activity) }}</span>
|
||||||
|
<span v-if="activity.location" class="flex items-center gap-0.5 truncate">
|
||||||
|
<MapPin class="w-2.5 h-2.5 shrink-0" />
|
||||||
|
{{ activity.location }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
48
src/modules/activities/components/BookmarkButton.vue
Normal file
48
src/modules/activities/components/BookmarkButton.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Heart } from 'lucide-vue-next'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { useBookmarks } from '../composables/useBookmarks'
|
||||||
|
import { NIP52_KINDS } from '../types/nip52'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
pubkey: string
|
||||||
|
dTag: string
|
||||||
|
kind?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { isBookmarked, toggleBookmark } = useBookmarks()
|
||||||
|
|
||||||
|
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||||
|
const bookmarked = computed(() => isBookmarked(activityKind.value, props.pubkey, props.dTag))
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toast.info('Log in to save favorites', {
|
||||||
|
action: {
|
||||||
|
label: 'Log in',
|
||||||
|
onClick: () => router.push('/login'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toggleBookmark(activityKind.value, props.pubkey, props.dTag)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
|
||||||
|
@click.stop="handleToggle"
|
||||||
|
>
|
||||||
|
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
52
src/modules/activities/components/CategoryFilterBar.vue
Normal file
52
src/modules/activities/components/CategoryFilterBar.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import type { ActivityCategory } from '../types/category'
|
||||||
|
import { ALL_CATEGORIES } from '../types/category'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selected: ActivityCategory[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: [category: ActivityCategory]
|
||||||
|
clear: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function categoryLabel(cat: ActivityCategory): string {
|
||||||
|
return t(`activities.categories.${cat}`, cat)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-muted-foreground">Categories</span>
|
||||||
|
<Button
|
||||||
|
v-if="props.selected.length > 0"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
@click="emit('clear')"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3 mr-1" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<Badge
|
||||||
|
v-for="cat in ALL_CATEGORIES"
|
||||||
|
:key="cat"
|
||||||
|
:variant="props.selected.includes(cat) ? 'default' : 'outline'"
|
||||||
|
class="cursor-pointer text-xs select-none hover:opacity-80 transition-opacity"
|
||||||
|
@click="emit('toggle', cat)"
|
||||||
|
>
|
||||||
|
{{ categoryLabel(cat) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
45
src/modules/activities/components/CategorySelector.vue
Normal file
45
src/modules/activities/components/CategorySelector.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import type { ActivityCategory } from '../types/category'
|
||||||
|
import { ALL_CATEGORIES } from '../types/category'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: ActivityCategory[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ActivityCategory[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function toggle(cat: ActivityCategory) {
|
||||||
|
const current = [...props.modelValue]
|
||||||
|
const idx = current.indexOf(cat)
|
||||||
|
if (idx >= 0) {
|
||||||
|
current.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
current.push(cat)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', current)
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(cat: ActivityCategory): string {
|
||||||
|
return t(`activities.categories.${cat}`, cat)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<Badge
|
||||||
|
v-for="cat in ALL_CATEGORIES"
|
||||||
|
:key="cat"
|
||||||
|
:variant="modelValue.includes(cat) ? 'default' : 'outline'"
|
||||||
|
class="cursor-pointer text-xs select-none hover:opacity-80 transition-opacity"
|
||||||
|
@click="toggle(cat)"
|
||||||
|
>
|
||||||
|
{{ label(cat) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
270
src/modules/activities/components/CreateActivityDialog.vue
Normal file
270
src/modules/activities/components/CreateActivityDialog.vue
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
FormControl, FormField, FormItem, FormLabel, FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CalendarPlus } from 'lucide-vue-next'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
||||||
|
import type { CalendarTimeEvent } from '../types/nip52'
|
||||||
|
import type { ActivityCategory } from '../types/category'
|
||||||
|
import CategorySelector from './CategorySelector.vue'
|
||||||
|
import LocationPicker from './LocationPicker.vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:isOpen': [value: boolean]
|
||||||
|
'created': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { currentUser } = useAuth()
|
||||||
|
|
||||||
|
const isPublishing = ref(false)
|
||||||
|
const selectedCategories = ref<ActivityCategory[]>([])
|
||||||
|
const location = ref('')
|
||||||
|
|
||||||
|
const formSchema = toTypedSchema(z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
summary: z.string().max(500).optional(),
|
||||||
|
description: z.string().min(1, 'Description is required').max(5000),
|
||||||
|
startDate: z.string().min(1, 'Start date is required'),
|
||||||
|
startTime: z.string().min(1, 'Start time is required'),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
endTime: z.string().optional(),
|
||||||
|
image: z.string().url('Must be a valid URL').optional().or(z.literal('')),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: formSchema,
|
||||||
|
initialValues: {
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
description: '',
|
||||||
|
startDate: '',
|
||||||
|
startTime: '',
|
||||||
|
endDate: '',
|
||||||
|
endTime: '',
|
||||||
|
image: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFormValid = computed(() => form.meta.value.valid)
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
||||||
|
if (!nostrService) {
|
||||||
|
toast.error('Activities service not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingKey = currentUser.value?.prvkey
|
||||||
|
if (!signingKey) {
|
||||||
|
toast.error('Signing key not available. Please log in again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPublishing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build unix timestamps
|
||||||
|
const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000)
|
||||||
|
let endTimestamp: number | undefined
|
||||||
|
if (values.endDate && values.endTime) {
|
||||||
|
endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique d-tag
|
||||||
|
const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
|
const eventData: Partial<CalendarTimeEvent> = {
|
||||||
|
dTag,
|
||||||
|
title: values.title,
|
||||||
|
summary: values.summary || undefined,
|
||||||
|
content: values.description,
|
||||||
|
image: values.image || undefined,
|
||||||
|
start: startTimestamp,
|
||||||
|
end: endTimestamp,
|
||||||
|
startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
location: location.value || undefined,
|
||||||
|
hashtags: selectedCategories.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await nostrService.publishCalendarEvent(eventData, signingKey)
|
||||||
|
|
||||||
|
if (result.success > 0) {
|
||||||
|
toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`)
|
||||||
|
emit('created')
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to publish to any relay')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to publish activity:', err)
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to publish activity')
|
||||||
|
} finally {
|
||||||
|
isPublishing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:isOpen', false)
|
||||||
|
form.resetForm()
|
||||||
|
selectedCategories.value = []
|
||||||
|
location.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="handleClose">
|
||||||
|
<DialogContent class="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<CalendarPlus class="w-5 h-5" />
|
||||||
|
{{ t('activities.createNew') }}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Publish a new activity to Nostr relays
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form @submit="onSubmit" class="space-y-4 py-2">
|
||||||
|
<!-- Title -->
|
||||||
|
<FormField v-slot="{ componentField }" name="title">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. Marché de Noël de Foix" v-bind="componentField" :disabled="isPublishing" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<FormField v-slot="{ componentField }" name="summary">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Brief one-line description" v-bind="componentField" :disabled="isPublishing" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Full details about the activity..."
|
||||||
|
v-bind="componentField"
|
||||||
|
:disabled="isPublishing"
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Start date/time -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<FormField v-slot="{ componentField }" name="startDate">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Start date *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" v-bind="componentField" :disabled="isPublishing" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="startTime">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Start time *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="time" v-bind="componentField" :disabled="isPublishing" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End date/time -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<FormField v-slot="{ componentField }" name="endDate">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>End date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" v-bind="componentField" :disabled="isPublishing" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="endTime">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>End time</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="time" v-bind="componentField" :disabled="isPublishing" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<LocationPicker
|
||||||
|
v-model="location"
|
||||||
|
:disabled="isPublishing"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-sm font-medium">Categories</label>
|
||||||
|
<CategorySelector v-model="selectedCategories" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image URL -->
|
||||||
|
<FormField v-slot="{ componentField }" name="image">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Image URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
v-bind="componentField"
|
||||||
|
:disabled="isPublishing"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isPublishing || !isFormValid"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<span v-if="isPublishing" class="animate-spin mr-2">⚡</span>
|
||||||
|
{{ isPublishing ? 'Publishing...' : 'Publish Activity' }}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
73
src/modules/activities/components/DatePickerStrip.vue
Normal file
73
src/modules/activities/components/DatePickerStrip.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<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'
|
||||||
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Currently selected date (if any) */
|
||||||
|
selectedDate?: Date
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [date: Date]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { dateLocale } = useDateLocale()
|
||||||
|
|
||||||
|
/** 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="grid grid-cols-7 flex-1 gap-0.5">
|
||||||
|
<button
|
||||||
|
v-for="day in days"
|
||||||
|
:key="day.toISOString()"
|
||||||
|
class="flex flex-col items-center py-1.5 rounded-lg 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, 'EEEEE', { locale: dateLocale }) }}
|
||||||
|
</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>
|
||||||
34
src/modules/activities/components/LocationPicker.vue
Normal file
34
src/modules/activities/components/LocationPicker.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { MapPin } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label class="text-sm font-medium">Location</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<MapPin class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="emit('update:modelValue', $event as string)"
|
||||||
|
:placeholder="placeholder ?? 'e.g. Salle des fêtes, Foix, Ariège'"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Enter the venue name and address
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
34
src/modules/activities/components/OrganizerCard.vue
Normal file
34
src/modules/activities/components/OrganizerCard.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||||
|
import { User } from 'lucide-vue-next'
|
||||||
|
import { useOrganizerProfile } from '../composables/useOrganizerProfile'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
pubkey: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Avatar class="h-10 w-10">
|
||||||
|
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
||||||
|
<AvatarFallback class="bg-primary/10">
|
||||||
|
<User class="w-5 h-5 text-primary" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-foreground truncate">
|
||||||
|
<template v-if="isLoading">Loading...</template>
|
||||||
|
<template v-else>{{ displayName }}</template>
|
||||||
|
</p>
|
||||||
|
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
|
||||||
|
{{ profile.nip05 }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-muted-foreground font-mono truncate">
|
||||||
|
{{ pubkey.slice(0, 16) }}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
66
src/modules/activities/components/RSVPButton.vue
Normal file
66
src/modules/activities/components/RSVPButton.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Check, HelpCircle, X } from 'lucide-vue-next'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { useRSVP } from '../composables/useRSVP'
|
||||||
|
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
pubkey: string
|
||||||
|
dTag: string
|
||||||
|
kind?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { getMyRSVP, getRSVPCount, setRSVP } = useRSVP()
|
||||||
|
|
||||||
|
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||||
|
const myStatus = computed(() => getMyRSVP(activityKind.value, props.pubkey, props.dTag))
|
||||||
|
const goingCount = computed(() => getRSVPCount(activityKind.value, props.pubkey, props.dTag))
|
||||||
|
|
||||||
|
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
|
||||||
|
{ status: 'accepted', labelKey: 'activities.detail.going', icon: Check },
|
||||||
|
{ status: 'tentative', labelKey: 'activities.detail.maybe', icon: HelpCircle },
|
||||||
|
{ status: 'declined', labelKey: 'activities.detail.notGoing', icon: X },
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleClick(status: RSVPStatus) {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toast.info('Log in to RSVP', {
|
||||||
|
action: {
|
||||||
|
label: 'Log in',
|
||||||
|
onClick: () => router.push('/login'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRSVP(activityKind.value, props.pubkey, props.dTag, status)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="btn in buttons"
|
||||||
|
:key="btn.status"
|
||||||
|
:variant="myStatus === btn.status ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1 gap-1.5"
|
||||||
|
@click="handleClick(btn.status)"
|
||||||
|
>
|
||||||
|
<component :is="btn.icon" class="w-3.5 h-3.5" />
|
||||||
|
{{ t(btn.labelKey) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="goingCount > 0" class="text-xs text-muted-foreground">
|
||||||
|
{{ goingCount }} {{ goingCount === 1 ? 'person' : 'people' }} going
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
src/modules/activities/components/TemporalFilterBar.vue
Normal file
38
src/modules/activities/components/TemporalFilterBar.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { TemporalFilter } from '../types/filters'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: TemporalFilter
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: TemporalFilter]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
|
{ value: 'all', labelKey: 'activities.filters.all' },
|
||||||
|
{ value: 'today', labelKey: 'activities.filters.today' },
|
||||||
|
{ value: 'tomorrow', labelKey: 'activities.filters.tomorrow' },
|
||||||
|
{ value: 'this-week', labelKey: 'activities.filters.thisWeek' },
|
||||||
|
{ value: 'this-month', labelKey: 'activities.filters.thisMonth' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
|
||||||
|
<Button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="rounded-full text-xs shrink-0"
|
||||||
|
@click="emit('update:modelValue', option.value)"
|
||||||
|
>
|
||||||
|
{{ t(option.labelKey) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
129
src/modules/activities/composables/useActivities.ts
Normal file
129
src/modules/activities/composables/useActivities.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
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 (from all activities, filters handle time range)
|
||||||
|
const filteredActivities = computed(() => {
|
||||||
|
const all = store.activities.sort(
|
||||||
|
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
||||||
|
)
|
||||||
|
return filters.applyFilters(all)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/modules/activities/composables/useActivityDetail.ts
Normal file
78
src/modules/activities/composables/useActivityDetail.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
||||||
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for loading a single activity by its d-tag identifier.
|
||||||
|
* First checks the store cache, then queries relays if not found.
|
||||||
|
*/
|
||||||
|
export function useActivityDetail(activityId: string) {
|
||||||
|
const store = useActivitiesStore()
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
const activity = computed<Activity | undefined>(() =>
|
||||||
|
store.getActivityById(activityId)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
// Already in cache
|
||||||
|
if (activity.value) return
|
||||||
|
|
||||||
|
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
||||||
|
if (!nostrService) {
|
||||||
|
error.value = 'Activities service not available'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
// Subscribe and wait for this specific event
|
||||||
|
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||||
|
(incoming) => {
|
||||||
|
store.upsertActivity(incoming)
|
||||||
|
if (incoming.id === activityId) {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Also do a one-shot query
|
||||||
|
const results = await nostrService.queryCalendarEvents()
|
||||||
|
store.upsertActivities(results)
|
||||||
|
|
||||||
|
// If we still don't have it after query, stop loading
|
||||||
|
setTimeout(() => {
|
||||||
|
isLoading.value = false
|
||||||
|
if (!activity.value) {
|
||||||
|
error.value = 'Activity not found'
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load activity'
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
reload: load,
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/modules/activities/composables/useActivityFilters.ts
Normal file
145
src/modules/activities/composables/useActivityFilters.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import {
|
||||||
|
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
||||||
|
startOfMonth, endOfMonth, addDays, isSameDay,
|
||||||
|
} 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 selectedDate = ref<Date | undefined>(undefined)
|
||||||
|
|
||||||
|
const filters = computed<ActivityFilters>(() => ({
|
||||||
|
temporal: temporal.value,
|
||||||
|
categories: selectedCategories.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the current filters to a list of activities.
|
||||||
|
*/
|
||||||
|
function applyFilters(activities: Activity[]): Activity[] {
|
||||||
|
let result = activities
|
||||||
|
|
||||||
|
// Specific date filter (from DatePickerStrip) takes priority over temporal
|
||||||
|
if (selectedDate.value) {
|
||||||
|
const dayStart = startOfDay(selectedDate.value)
|
||||||
|
const dayEnd = endOfDay(selectedDate.value)
|
||||||
|
result = result.filter(a => {
|
||||||
|
const activityEnd = a.endDate ?? a.startDate
|
||||||
|
return a.startDate <= dayEnd && activityEnd >= dayStart
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTemporal(value: TemporalFilter) {
|
||||||
|
temporal.value = value
|
||||||
|
selectedDate.value = undefined // clear date pick when using temporal pills
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDate(date: Date) {
|
||||||
|
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
|
||||||
|
selectedDate.value = undefined // toggle off
|
||||||
|
} else {
|
||||||
|
selectedDate.value = date
|
||||||
|
temporal.value = 'all' // clear temporal pill when picking a specific date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
selectedDate.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() =>
|
||||||
|
temporal.value !== 'all' ||
|
||||||
|
selectedCategories.value.length > 0 ||
|
||||||
|
selectedDate.value !== undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
temporal,
|
||||||
|
selectedCategories,
|
||||||
|
selectedDate,
|
||||||
|
filters,
|
||||||
|
hasActiveFilters,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
applyFilters,
|
||||||
|
setTemporal,
|
||||||
|
selectDate,
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
156
src/modules/activities/composables/useBookmarks.ts
Normal file
156
src/modules/activities/composables/useBookmarks.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-51 Bookmarks (kind 10003) for saving favorite activities.
|
||||||
|
*
|
||||||
|
* Stores references to NIP-52 calendar events as 'a' tags:
|
||||||
|
* ['a', '<kind>:<pubkey>:<d-tag>']
|
||||||
|
*
|
||||||
|
* The bookmark list is a replaceable event (kind 10003) — publishing
|
||||||
|
* a new one replaces the previous.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BOOKMARK_KIND = 10003
|
||||||
|
|
||||||
|
interface BookmarkState {
|
||||||
|
/** Set of bookmarked activity coordinates: "kind:pubkey:d-tag" */
|
||||||
|
bookmarkedCoords: Set<string>
|
||||||
|
/** The latest bookmark event we've seen */
|
||||||
|
lastEventId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared state across all component instances
|
||||||
|
const state = ref<BookmarkState>({
|
||||||
|
bookmarkedCoords: new Set(),
|
||||||
|
lastEventId: null,
|
||||||
|
})
|
||||||
|
const isLoaded = ref(false)
|
||||||
|
|
||||||
|
export function useBookmarks() {
|
||||||
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
const bookmarkedIds = computed(() => state.value.bookmarkedCoords)
|
||||||
|
|
||||||
|
function isBookmarked(activityKind: number, pubkey: string, dTag: string): boolean {
|
||||||
|
return state.value.bookmarkedCoords.has(`${activityKind}:${pubkey}:${dTag}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBookmarkedByDTag(dTag: string): boolean {
|
||||||
|
for (const coord of state.value.bookmarkedCoords) {
|
||||||
|
if (coord.endsWith(`:${dTag}`)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the user's bookmark list from relays.
|
||||||
|
*/
|
||||||
|
function loadBookmarks() {
|
||||||
|
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
||||||
|
|
||||||
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
if (!relayHub) return
|
||||||
|
|
||||||
|
unsubscribe = relayHub.subscribe({
|
||||||
|
id: `bookmarks-${Date.now()}`,
|
||||||
|
filters: [{
|
||||||
|
kinds: [BOOKMARK_KIND],
|
||||||
|
authors: [currentUser.value.pubkey],
|
||||||
|
limit: 1,
|
||||||
|
}],
|
||||||
|
onEvent: (event: NostrEvent) => {
|
||||||
|
// Only process if newer than what we have
|
||||||
|
if (state.value.lastEventId && event.created_at <= (state.value as any).lastCreatedAt) return
|
||||||
|
|
||||||
|
const coords = new Set<string>()
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'a' && tag[1]) {
|
||||||
|
coords.add(tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.value = {
|
||||||
|
bookmarkedCoords: coords,
|
||||||
|
lastEventId: event.id,
|
||||||
|
}
|
||||||
|
;(state.value as any).lastCreatedAt = event.created_at
|
||||||
|
isLoaded.value = true
|
||||||
|
},
|
||||||
|
onEose: () => {
|
||||||
|
isLoaded.value = true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list.
|
||||||
|
*/
|
||||||
|
async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) {
|
||||||
|
if (!isAuthenticated.value || !currentUser.value?.prvkey) return
|
||||||
|
|
||||||
|
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||||
|
const newCoords = new Set(state.value.bookmarkedCoords)
|
||||||
|
|
||||||
|
if (newCoords.has(coord)) {
|
||||||
|
newCoords.delete(coord)
|
||||||
|
} else {
|
||||||
|
newCoords.add(coord)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and publish updated bookmark list
|
||||||
|
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
|
||||||
|
|
||||||
|
const template: EventTemplate = {
|
||||||
|
kind: BOOKMARK_KIND,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingKey = hexToUint8Array(currentUser.value.prvkey)
|
||||||
|
const signedEvent = finalizeEvent(template, signingKey)
|
||||||
|
|
||||||
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
if (!relayHub) return
|
||||||
|
|
||||||
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
|
if (result.success > 0) {
|
||||||
|
state.value = {
|
||||||
|
bookmarkedCoords: newCoords,
|
||||||
|
lastEventId: signedEvent.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!isLoaded.value) {
|
||||||
|
loadBookmarks()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookmarkedIds,
|
||||||
|
isBookmarked,
|
||||||
|
isBookmarkedByDTag,
|
||||||
|
toggleBookmark,
|
||||||
|
isLoaded,
|
||||||
|
loadBookmarks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToUint8Array(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2)
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
23
src/modules/activities/composables/useDateLocale.ts
Normal file
23
src/modules/activities/composables/useDateLocale.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { fr, es, enUS } from 'date-fns/locale'
|
||||||
|
import type { Locale } from 'date-fns'
|
||||||
|
|
||||||
|
const localeMap: Record<string, Locale> = {
|
||||||
|
fr,
|
||||||
|
es,
|
||||||
|
en: enUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a reactive date-fns Locale based on the current i18n language.
|
||||||
|
*/
|
||||||
|
export function useDateLocale() {
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const dateLocale = computed<Locale>(() => {
|
||||||
|
return localeMap[locale.value] ?? enUS
|
||||||
|
})
|
||||||
|
|
||||||
|
return { dateLocale }
|
||||||
|
}
|
||||||
149
src/modules/activities/composables/useOrganizerProfile.ts
Normal file
149
src/modules/activities/composables/useOrganizerProfile.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { Event as NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
export interface OrganizerProfile {
|
||||||
|
pubkey: string
|
||||||
|
name?: string
|
||||||
|
displayName?: string
|
||||||
|
about?: string
|
||||||
|
picture?: string
|
||||||
|
banner?: string
|
||||||
|
nip05?: string
|
||||||
|
lud16?: string
|
||||||
|
website?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global cache of fetched profiles
|
||||||
|
const profileCache = ref<Map<string, OrganizerProfile>>(new Map())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for fetching and displaying organizer profiles (NIP-01 kind 0).
|
||||||
|
* Uses its own relay subscription to avoid depending on the nostr-feed module.
|
||||||
|
*/
|
||||||
|
export function useOrganizerProfile(pubkey: string) {
|
||||||
|
const profile = ref<OrganizerProfile | null>(profileCache.value.get(pubkey) ?? null)
|
||||||
|
const isLoading = ref(!profile.value)
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (profileCache.value.has(pubkey)) {
|
||||||
|
profile.value = profileCache.value.get(pubkey)!
|
||||||
|
isLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
if (!relayHub) {
|
||||||
|
isLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe = relayHub.subscribe({
|
||||||
|
id: `profile-${pubkey}-${Date.now()}`,
|
||||||
|
filters: [{
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: 1,
|
||||||
|
}],
|
||||||
|
onEvent: (event: NostrEvent) => {
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(event.content)
|
||||||
|
const p: OrganizerProfile = {
|
||||||
|
pubkey,
|
||||||
|
name: metadata.name,
|
||||||
|
displayName: metadata.display_name,
|
||||||
|
about: metadata.about,
|
||||||
|
picture: metadata.picture,
|
||||||
|
banner: metadata.banner,
|
||||||
|
nip05: metadata.nip05,
|
||||||
|
lud16: metadata.lud16,
|
||||||
|
website: metadata.website,
|
||||||
|
}
|
||||||
|
profileCache.value.set(pubkey, p)
|
||||||
|
profile.value = p
|
||||||
|
} catch {
|
||||||
|
// invalid metadata JSON
|
||||||
|
}
|
||||||
|
isLoading.value = false
|
||||||
|
},
|
||||||
|
onEose: () => {
|
||||||
|
isLoading.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
isLoading,
|
||||||
|
get displayName() {
|
||||||
|
const p = profile.value
|
||||||
|
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-fetch profiles for multiple pubkeys (for activity cards).
|
||||||
|
*/
|
||||||
|
export function useBatchProfiles() {
|
||||||
|
function fetchProfiles(pubkeys: string[]) {
|
||||||
|
const uncached = pubkeys.filter(pk => !profileCache.value.has(pk))
|
||||||
|
if (uncached.length === 0) return
|
||||||
|
|
||||||
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
if (!relayHub) return
|
||||||
|
|
||||||
|
relayHub.subscribe({
|
||||||
|
id: `batch-profiles-${Date.now()}`,
|
||||||
|
filters: [{
|
||||||
|
kinds: [0],
|
||||||
|
authors: uncached,
|
||||||
|
}],
|
||||||
|
onEvent: (event: NostrEvent) => {
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(event.content)
|
||||||
|
profileCache.value.set(event.pubkey, {
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
name: metadata.name,
|
||||||
|
displayName: metadata.display_name,
|
||||||
|
about: metadata.about,
|
||||||
|
picture: metadata.picture,
|
||||||
|
banner: metadata.banner,
|
||||||
|
nip05: metadata.nip05,
|
||||||
|
lud16: metadata.lud16,
|
||||||
|
website: metadata.website,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// skip invalid
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfile(pubkey: string): OrganizerProfile | undefined {
|
||||||
|
return profileCache.value.get(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(pubkey: string): string {
|
||||||
|
const p = profileCache.value.get(pubkey)
|
||||||
|
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles: profileCache,
|
||||||
|
fetchProfiles,
|
||||||
|
getProfile,
|
||||||
|
getDisplayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/modules/activities/composables/useRSVP.ts
Normal file
172
src/modules/activities/composables/useRSVP.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-52 RSVP (kind 31925) for responding to calendar events.
|
||||||
|
*
|
||||||
|
* Each RSVP is an addressable event with:
|
||||||
|
* d-tag: unique identifier for this RSVP
|
||||||
|
* a-tag: reference to the calendar event (kind:pubkey:d-tag)
|
||||||
|
* status tag: 'accepted' | 'declined' | 'tentative'
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RSVPEntry {
|
||||||
|
status: RSVPStatus
|
||||||
|
eventId: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache: activityCoord -> user's RSVP status
|
||||||
|
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
||||||
|
// Cache: activityCoord -> count of RSVPs from all users
|
||||||
|
const rsvpCounts = ref<Map<string, number>>(new Map())
|
||||||
|
const isLoaded = ref(false)
|
||||||
|
|
||||||
|
export function useRSVP() {
|
||||||
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's RSVP status for an activity.
|
||||||
|
*/
|
||||||
|
function getMyRSVP(activityKind: number, pubkey: string, dTag: string): RSVPStatus | null {
|
||||||
|
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||||
|
return rsvpCache.value.get(coord)?.status ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get RSVP count for an activity.
|
||||||
|
*/
|
||||||
|
function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number {
|
||||||
|
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||||
|
return rsvpCounts.value.get(coord) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the user's RSVPs and counts for visible activities from relays.
|
||||||
|
*/
|
||||||
|
function loadRSVPs() {
|
||||||
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
if (!relayHub) return
|
||||||
|
|
||||||
|
// Subscribe to all RSVPs (for counts) and filter user's own
|
||||||
|
unsubscribe = relayHub.subscribe({
|
||||||
|
id: `rsvps-${Date.now()}`,
|
||||||
|
filters: [{
|
||||||
|
kinds: [NIP52_KINDS.RSVP],
|
||||||
|
limit: 500,
|
||||||
|
}],
|
||||||
|
onEvent: (event: NostrEvent) => {
|
||||||
|
const aTag = event.tags.find(t => t[0] === 'a')?.[1]
|
||||||
|
if (!aTag) return
|
||||||
|
|
||||||
|
const statusTag = event.tags.find(t => t[0] === 'status')?.[1] as RSVPStatus | undefined
|
||||||
|
// Also check 'l' tag pattern used by some clients
|
||||||
|
const lStatus = event.tags.find(t => t[0] === 'l' && t[2] === 'status')?.[1] as RSVPStatus | undefined
|
||||||
|
const status = statusTag ?? lStatus
|
||||||
|
if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return
|
||||||
|
|
||||||
|
// Update count
|
||||||
|
if (status === 'accepted') {
|
||||||
|
rsvpCounts.value.set(aTag, (rsvpCounts.value.get(aTag) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's own RSVP
|
||||||
|
if (currentUser.value?.pubkey && event.pubkey === currentUser.value.pubkey) {
|
||||||
|
const existing = rsvpCache.value.get(aTag)
|
||||||
|
if (!existing || event.created_at > existing.createdAt) {
|
||||||
|
rsvpCache.value.set(aTag, {
|
||||||
|
status,
|
||||||
|
eventId: event.id,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEose: () => {
|
||||||
|
isLoaded.value = true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an RSVP for an activity.
|
||||||
|
* Clicking the same status again removes the RSVP (publishes 'declined').
|
||||||
|
*/
|
||||||
|
async function setRSVP(
|
||||||
|
activityKind: number,
|
||||||
|
activityPubkey: string,
|
||||||
|
activityDTag: string,
|
||||||
|
status: RSVPStatus
|
||||||
|
) {
|
||||||
|
if (!isAuthenticated.value || !currentUser.value?.prvkey) return
|
||||||
|
|
||||||
|
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
||||||
|
|
||||||
|
// Toggle: if already this status, decline instead
|
||||||
|
const currentStatus = getMyRSVP(activityKind, activityPubkey, activityDTag)
|
||||||
|
const newStatus = currentStatus === status ? 'declined' : status
|
||||||
|
|
||||||
|
const dTag = `rsvp-${activityDTag}`
|
||||||
|
|
||||||
|
const template: EventTemplate = {
|
||||||
|
kind: NIP52_KINDS.RSVP,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['d', dTag],
|
||||||
|
['a', coord],
|
||||||
|
['status', newStatus],
|
||||||
|
['L', 'status'],
|
||||||
|
['l', newStatus, 'status'],
|
||||||
|
['p', activityPubkey],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingKey = hexToUint8Array(currentUser.value.prvkey)
|
||||||
|
const signedEvent = finalizeEvent(template, signingKey)
|
||||||
|
|
||||||
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
if (!relayHub) return
|
||||||
|
|
||||||
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
|
if (result.success > 0) {
|
||||||
|
rsvpCache.value.set(coord, {
|
||||||
|
status: newStatus,
|
||||||
|
eventId: signedEvent.id,
|
||||||
|
createdAt: signedEvent.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!isLoaded.value) {
|
||||||
|
loadRSVPs()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
getMyRSVP,
|
||||||
|
getRSVPCount,
|
||||||
|
setRSVP,
|
||||||
|
isLoaded,
|
||||||
|
loadRSVPs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToUint8Array(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2)
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
128
src/modules/activities/index.ts
Normal file
128
src/modules/activities/index.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
|
||||||
|
import { SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { ActivitiesNostrService } from './services/ActivitiesNostrService'
|
||||||
|
import { TicketApiService, type TicketApiConfig } from './services/TicketApiService'
|
||||||
|
|
||||||
|
export interface ActivitiesModuleConfig {
|
||||||
|
apiConfig: TicketApiConfig
|
||||||
|
defaultMapCenter?: { lat: number; lng: number }
|
||||||
|
maxTicketsPerUser?: number
|
||||||
|
enableMap?: boolean
|
||||||
|
enablePrivateEvents?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activities Module Plugin
|
||||||
|
*
|
||||||
|
* Nostr-native communal events module using NIP-52 Calendar Events
|
||||||
|
* for discovery, with database-backed ticketing via LNbits.
|
||||||
|
*/
|
||||||
|
export const activitiesModule = createModulePlugin({
|
||||||
|
name: 'activities',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: ['base'],
|
||||||
|
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/activities',
|
||||||
|
name: 'activities',
|
||||||
|
component: () => import('./views/ActivitiesPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Activities',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/activities/calendar',
|
||||||
|
name: 'activities-calendar',
|
||||||
|
component: () => import('./views/ActivitiesCalendarPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Calendar',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/activities/map',
|
||||||
|
name: 'activities-map',
|
||||||
|
component: () => import('./views/ActivitiesMapPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Map',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/activities/favorites',
|
||||||
|
name: 'activities-favorites',
|
||||||
|
component: () => import('./views/ActivitiesFavoritesPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Favorites',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/activities/:id',
|
||||||
|
name: 'activity-detail',
|
||||||
|
component: () => import('./views/ActivityDetailPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Activity',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/my-tickets',
|
||||||
|
name: 'my-tickets-v2',
|
||||||
|
component: () => import('./views/MyTicketsPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'My Tickets',
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
eventListeners: [
|
||||||
|
{
|
||||||
|
event: 'payment:completed',
|
||||||
|
handler: (event) => {
|
||||||
|
console.log('Activities module: payment completed', event.data)
|
||||||
|
},
|
||||||
|
description: 'Handle payment completion for ticket purchases',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
onInstall: async (_app, options) => {
|
||||||
|
const config = options?.config as ActivitiesModuleConfig | undefined
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Activities module requires configuration')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = await import('@/core/di-container')
|
||||||
|
|
||||||
|
// 1. Create services
|
||||||
|
const nostrService = new ActivitiesNostrService()
|
||||||
|
const ticketApi = new TicketApiService(config.apiConfig)
|
||||||
|
|
||||||
|
// 2. Register in DI container BEFORE initialization
|
||||||
|
container.provide(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE, nostrService)
|
||||||
|
container.provide(SERVICE_TOKENS.ACTIVITIES_TICKET_API, ticketApi)
|
||||||
|
|
||||||
|
// 3. Initialize the Nostr service (needs RelayHub dependency)
|
||||||
|
await nostrService.initialize({
|
||||||
|
waitForDependencies: true,
|
||||||
|
maxRetries: 3,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onUninstall: async () => {
|
||||||
|
const { container } = await import('@/core/di-container')
|
||||||
|
container.remove(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
||||||
|
container.remove(SERVICE_TOKENS.ACTIVITIES_TICKET_API)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default activitiesModule
|
||||||
|
|
||||||
|
// Re-export types for external use
|
||||||
|
export type { Activity, OrganizerInfo, ActivityTicketInfo } from './types/activity'
|
||||||
|
export type { ActivityTicket, TicketStatus } from './types/ticket'
|
||||||
|
export type { ActivityCategory } from './types/category'
|
||||||
|
export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52'
|
||||||
182
src/modules/activities/services/ActivitiesNostrService.ts
Normal file
182
src/modules/activities/services/ActivitiesNostrService.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
|
import { finalizeEvent, type Event as NostrEvent, type EventTemplate } 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.
|
||||||
|
* Requires an authenticated user with a signing key.
|
||||||
|
*/
|
||||||
|
async publishCalendarEvent(
|
||||||
|
eventData: Partial<CalendarTimeEvent>,
|
||||||
|
signingKeyHex: string
|
||||||
|
): Promise<{ success: number; total: number }> {
|
||||||
|
if (!this.relayHub) {
|
||||||
|
throw new Error('RelayHub not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = buildCalendarTimeEventTags(eventData)
|
||||||
|
const template: EventTemplate = {
|
||||||
|
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: eventData.content ?? '',
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
const privkeyBytes = hexToUint8Array(signingKeyHex)
|
||||||
|
const signedEvent = finalizeEvent(template, privkeyBytes)
|
||||||
|
|
||||||
|
return await this.relayHub.publishEvent(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
||||||
|
for (const unsub of this.activeUnsubscribes) {
|
||||||
|
unsub()
|
||||||
|
}
|
||||||
|
this.activeUnsubscribes = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToUint8Array(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2)
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
91
src/modules/activities/services/LnbitsPaymentProvider.ts
Normal file
91
src/modules/activities/services/LnbitsPaymentProvider.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type {
|
||||||
|
PaymentProvider,
|
||||||
|
CreateInvoiceParams,
|
||||||
|
InvoiceResult,
|
||||||
|
PaymentStatus,
|
||||||
|
PayInvoiceResult,
|
||||||
|
} from './PaymentProviderInterface'
|
||||||
|
|
||||||
|
export interface LnbitsPaymentConfig {
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNbits implementation of PaymentProvider.
|
||||||
|
* Talks to the LNbits REST API for invoice creation, payment, and status checks.
|
||||||
|
*/
|
||||||
|
export class LnbitsPaymentProvider implements PaymentProvider {
|
||||||
|
constructor(private config: LnbitsPaymentConfig) {}
|
||||||
|
|
||||||
|
async createInvoice(params: CreateInvoiceParams): Promise<InvoiceResult> {
|
||||||
|
const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': this.config.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
out: false,
|
||||||
|
amount: params.amount,
|
||||||
|
memo: params.memo,
|
||||||
|
extra: params.metadata,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to create invoice' }))
|
||||||
|
throw new Error(typeof error.detail === 'string' ? error.detail : 'Failed to create invoice')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return {
|
||||||
|
paymentHash: data.payment_hash,
|
||||||
|
paymentRequest: data.payment_request,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkPaymentStatus(paymentHash: string): Promise<PaymentStatus> {
|
||||||
|
const response = await fetch(`${this.config.baseUrl}/api/v1/payments/${paymentHash}`, {
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': this.config.apiKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to check payment status')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return {
|
||||||
|
paid: data.paid === true,
|
||||||
|
preimage: data.preimage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async payInvoice(paymentRequest: string): Promise<PayInvoiceResult> {
|
||||||
|
const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': this.config.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
out: true,
|
||||||
|
bolt11: paymentRequest,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to pay invoice' }))
|
||||||
|
throw new Error(typeof error.detail === 'string' ? error.detail : 'Failed to pay invoice')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return {
|
||||||
|
paymentHash: data.payment_hash,
|
||||||
|
feeMsat: data.fee_msat ?? 0,
|
||||||
|
preimage: data.preimage ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/modules/activities/services/PaymentProviderInterface.ts
Normal file
48
src/modules/activities/services/PaymentProviderInterface.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Payment Provider Abstraction
|
||||||
|
*
|
||||||
|
* Enables swapping between LNbits and lightning.pub (or other providers)
|
||||||
|
* without changing consuming code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CreateInvoiceParams {
|
||||||
|
/** Amount in the specified currency */
|
||||||
|
amount: number
|
||||||
|
/** Currency code (e.g., 'sats', 'EUR') */
|
||||||
|
currency: string
|
||||||
|
/** Invoice memo/description */
|
||||||
|
memo: string
|
||||||
|
/** Arbitrary metadata attached to the invoice */
|
||||||
|
metadata?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceResult {
|
||||||
|
paymentHash: string
|
||||||
|
paymentRequest: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentStatus {
|
||||||
|
paid: boolean
|
||||||
|
preimage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayInvoiceResult {
|
||||||
|
paymentHash: string
|
||||||
|
feeMsat: number
|
||||||
|
preimage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract payment provider interface.
|
||||||
|
* Implementations handle the specifics of LNbits, lightning.pub, etc.
|
||||||
|
*/
|
||||||
|
export interface PaymentProvider {
|
||||||
|
/** Create a Lightning invoice for receiving payment */
|
||||||
|
createInvoice(params: CreateInvoiceParams): Promise<InvoiceResult>
|
||||||
|
|
||||||
|
/** Check whether an invoice has been paid */
|
||||||
|
checkPaymentStatus(paymentHash: string): Promise<PaymentStatus>
|
||||||
|
|
||||||
|
/** Pay a Lightning invoice from the user's wallet */
|
||||||
|
payInvoice(paymentRequest: string): Promise<PayInvoiceResult>
|
||||||
|
}
|
||||||
158
src/modules/activities/services/TicketApiService.ts
Normal file
158
src/modules/activities/services/TicketApiService.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import type {
|
||||||
|
ActivityTicket,
|
||||||
|
TicketPurchaseInvoice,
|
||||||
|
TicketPaymentStatus,
|
||||||
|
} from '../types/ticket'
|
||||||
|
|
||||||
|
export interface TicketApiConfig {
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database-backed ticketing API service.
|
||||||
|
* Talks to the LNbits events extension for ticket inventory,
|
||||||
|
* purchases, payment status, and validation.
|
||||||
|
*
|
||||||
|
* This is NOT a BaseService -- it's a simple API wrapper instantiated
|
||||||
|
* with config at module install time (same pattern as EventsApiService).
|
||||||
|
*/
|
||||||
|
export class TicketApiService {
|
||||||
|
constructor(private config: TicketApiConfig) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all public events from the LNbits events extension.
|
||||||
|
* Used to correlate Nostr activities with ticketed events.
|
||||||
|
*/
|
||||||
|
async fetchTicketedEvents(): Promise<any[]> {
|
||||||
|
const response = await this.request(
|
||||||
|
'/events/api/v1/events/public',
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a ticket purchase (creates a Lightning invoice).
|
||||||
|
*/
|
||||||
|
async requestTicket(
|
||||||
|
eventId: string,
|
||||||
|
userId: string,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<TicketPurchaseInvoice> {
|
||||||
|
const data = await this.request(
|
||||||
|
`/events/api/v1/tickets/${eventId}/user/${userId}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentHash: data.payment_hash,
|
||||||
|
paymentRequest: data.payment_request,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a ticket payment has been confirmed.
|
||||||
|
*/
|
||||||
|
async checkPaymentStatus(
|
||||||
|
eventId: string,
|
||||||
|
paymentHash: string
|
||||||
|
): Promise<TicketPaymentStatus> {
|
||||||
|
const data = await this.request(
|
||||||
|
`/events/api/v1/tickets/${eventId}/${paymentHash}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid: data.paid === true,
|
||||||
|
ticketId: data.ticket_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all tickets for a user.
|
||||||
|
*/
|
||||||
|
async fetchUserTickets(
|
||||||
|
userId: string,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<ActivityTicket[]> {
|
||||||
|
const data = await this.request(
|
||||||
|
`/events/api/v1/tickets/user/${userId}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (data as any[]).map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
wallet: t.wallet,
|
||||||
|
activityId: t.event,
|
||||||
|
name: t.name,
|
||||||
|
email: t.email,
|
||||||
|
userId: t.user_id,
|
||||||
|
registered: t.registered,
|
||||||
|
paid: t.paid,
|
||||||
|
time: t.time,
|
||||||
|
regTimestamp: t.reg_timestamp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate/register a ticket at the door (scan).
|
||||||
|
*/
|
||||||
|
async validateTicket(ticketId: string): Promise<ActivityTicket[]> {
|
||||||
|
const data = await this.request(
|
||||||
|
`/events/api/v1/register/ticket/${ticketId}`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
|
||||||
|
return (data as any[]).map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
wallet: t.wallet,
|
||||||
|
activityId: t.event,
|
||||||
|
name: t.name,
|
||||||
|
email: t.email,
|
||||||
|
userId: t.user_id,
|
||||||
|
registered: t.registered,
|
||||||
|
paid: t.paid,
|
||||||
|
time: t.time,
|
||||||
|
regTimestamp: t.reg_timestamp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal fetch helper with standard headers and error handling.
|
||||||
|
*/
|
||||||
|
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'X-API-KEY': this.config.apiKey,
|
||||||
|
...(init.headers as Record<string, string> ?? {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: `Request failed: ${path}` }))
|
||||||
|
const errorMessage = typeof error.detail === 'string'
|
||||||
|
? error.detail
|
||||||
|
: Array.isArray(error.detail)
|
||||||
|
? error.detail[0]?.msg ?? 'Request failed'
|
||||||
|
: 'Request failed'
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/modules/activities/stores/activities.ts
Normal file
100
src/modules/activities/stores/activities.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinia store for cached activities from Nostr relays.
|
||||||
|
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
|
||||||
|
*/
|
||||||
|
export const useActivitiesStore = defineStore('activities', () => {
|
||||||
|
// State
|
||||||
|
const activitiesMap = ref<Map<string, Activity>>(new Map())
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const lastUpdated = ref<Date | null>(null)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const activities = computed(() => Array.from(activitiesMap.value.values()))
|
||||||
|
|
||||||
|
const upcomingActivities = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return activities.value
|
||||||
|
.filter(a => a.startDate >= now || (a.endDate && a.endDate >= now))
|
||||||
|
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
const pastActivities = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return activities.value
|
||||||
|
.filter(a => {
|
||||||
|
const endOrStart = a.endDate ?? a.startDate
|
||||||
|
return endOrStart < now
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update an activity in the store.
|
||||||
|
* Deduplicates by id (d-tag). Newer events replace older ones.
|
||||||
|
*/
|
||||||
|
function upsertActivity(activity: Activity) {
|
||||||
|
const existing = activitiesMap.value.get(activity.id)
|
||||||
|
|
||||||
|
// Only update if this is a newer version
|
||||||
|
if (!existing || activity.createdAt >= existing.createdAt) {
|
||||||
|
activitiesMap.value.set(activity.id, activity)
|
||||||
|
lastUpdated.value = new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple activities (batch upsert).
|
||||||
|
*/
|
||||||
|
function upsertActivities(newActivities: Activity[]) {
|
||||||
|
for (const activity of newActivities) {
|
||||||
|
upsertActivity(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an activity from the store.
|
||||||
|
*/
|
||||||
|
function removeActivity(id: string) {
|
||||||
|
activitiesMap.value.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached activities.
|
||||||
|
*/
|
||||||
|
function clearAll() {
|
||||||
|
activitiesMap.value.clear()
|
||||||
|
lastUpdated.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single activity by its id (d-tag).
|
||||||
|
*/
|
||||||
|
function getActivityById(id: string): Activity | undefined {
|
||||||
|
return activitiesMap.value.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
activitiesMap,
|
||||||
|
isLoading,
|
||||||
|
lastUpdated,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
activities,
|
||||||
|
upcomingActivities,
|
||||||
|
pastActivities,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
upsertActivity,
|
||||||
|
upsertActivities,
|
||||||
|
removeActivity,
|
||||||
|
clearAll,
|
||||||
|
getActivityById,
|
||||||
|
}
|
||||||
|
})
|
||||||
139
src/modules/activities/types/activity.ts
Normal file
139
src/modules/activities/types/activity.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import ngeohash from 'ngeohash'
|
||||||
|
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,
|
||||||
|
coordinates: decodeGeohash(event.geohash),
|
||||||
|
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,
|
||||||
|
coordinates: decodeGeohash(event.geohash),
|
||||||
|
geohash: event.geohash,
|
||||||
|
category,
|
||||||
|
tags: event.hashtags,
|
||||||
|
isPrivate: false,
|
||||||
|
createdAt: new Date(event.createdAt * 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
|
||||||
|
if (!geohash) return undefined
|
||||||
|
try {
|
||||||
|
const { latitude, longitude } = ngeohash.decode(geohash)
|
||||||
|
return { lat: latitude, lng: longitude }
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/modules/activities/types/category.ts
Normal file
35
src/modules/activities/types/category.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Activity categories inspired by p'a semana
|
||||||
|
* Mapped to NIP-52 't' (hashtag) tags
|
||||||
|
*/
|
||||||
|
export const ACTIVITY_CATEGORIES = {
|
||||||
|
concert: 'concert',
|
||||||
|
workshop: 'workshop',
|
||||||
|
market: 'market',
|
||||||
|
festival: 'festival',
|
||||||
|
exhibition: 'exhibition',
|
||||||
|
sport: 'sport',
|
||||||
|
theater: 'theater',
|
||||||
|
cinema: 'cinema',
|
||||||
|
party: 'party',
|
||||||
|
talk: 'talk',
|
||||||
|
conference: 'conference',
|
||||||
|
meetup: 'meetup',
|
||||||
|
food: 'food',
|
||||||
|
outdoor: 'outdoor',
|
||||||
|
kids: 'kids',
|
||||||
|
wellness: 'wellness',
|
||||||
|
technology: 'technology',
|
||||||
|
art: 'art',
|
||||||
|
music: 'music',
|
||||||
|
dance: 'dance',
|
||||||
|
literature: 'literature',
|
||||||
|
comedy: 'comedy',
|
||||||
|
charity: 'charity',
|
||||||
|
tradition: 'tradition',
|
||||||
|
other: 'other',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ActivityCategory = typeof ACTIVITY_CATEGORIES[keyof typeof ACTIVITY_CATEGORIES]
|
||||||
|
|
||||||
|
export const ALL_CATEGORIES = Object.values(ACTIVITY_CATEGORIES)
|
||||||
28
src/modules/activities/types/filters.ts
Normal file
28
src/modules/activities/types/filters.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { ActivityCategory } from './category'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporal filter presets (p'a semana style)
|
||||||
|
*/
|
||||||
|
export type TemporalFilter = 'all' | 'today' | 'tomorrow' | 'this-week' | 'this-month'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined filter state for activity discovery
|
||||||
|
*/
|
||||||
|
export interface ActivityFilters {
|
||||||
|
temporal: TemporalFilter
|
||||||
|
categories: ActivityCategory[]
|
||||||
|
/** Free text search */
|
||||||
|
search?: string
|
||||||
|
/** Geohash prefix for geographic filtering */
|
||||||
|
geohash?: string
|
||||||
|
/** Filter by specific organizer pubkey */
|
||||||
|
organizerPubkey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default filter state
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FILTERS: ActivityFilters = {
|
||||||
|
temporal: 'all',
|
||||||
|
categories: [],
|
||||||
|
}
|
||||||
245
src/modules/activities/types/nip52.ts
Normal file
245
src/modules/activities/types/nip52.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
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 NIP-52 start/end tag value to a unix timestamp in seconds.
|
||||||
|
* Handles: unix seconds, unix milliseconds, and ISO date strings.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
|
||||||
|
* Handles: unix seconds, unix milliseconds, and ISO date strings.
|
||||||
|
* Returns NaN if unparseable (caller must handle).
|
||||||
|
*/
|
||||||
|
function parseTimestamp(value: string): number {
|
||||||
|
const num = Number(value)
|
||||||
|
if (!isNaN(num) && num > 0) {
|
||||||
|
// If the number is unreasonably large, it's likely milliseconds
|
||||||
|
if (num > 1e12) {
|
||||||
|
return Math.floor(num / 1000)
|
||||||
|
}
|
||||||
|
return num
|
||||||
|
}
|
||||||
|
// Try as ISO date string
|
||||||
|
const date = new Date(value)
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return Math.floor(date.getTime() / 1000)
|
||||||
|
}
|
||||||
|
return NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 startTs = parseTimestamp(startStr)
|
||||||
|
if (isNaN(startTs)) return null // reject events with unparseable timestamps
|
||||||
|
|
||||||
|
const endStr = getTagValue(event.tags, 'end')
|
||||||
|
const endTs = endStr ? parseTimestamp(endStr) : undefined
|
||||||
|
|
||||||
|
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: startTs,
|
||||||
|
end: isNaN(endTs as number) ? undefined : endTs,
|
||||||
|
startTzid: getTagValue(event.tags, 'start_tzid'),
|
||||||
|
endTzid: getTagValue(event.tags, 'end_tzid'),
|
||||||
|
location: getTagValue(event.tags, 'location'),
|
||||||
|
geohash: getTagValue(event.tags, 'g'),
|
||||||
|
hashtags: getTagValues(event.tags, 't'),
|
||||||
|
participants,
|
||||||
|
references: getTagValues(event.tags, 'r'),
|
||||||
|
id: event.id,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Nostr event into a CalendarDateEvent (kind 31922)
|
||||||
|
*/
|
||||||
|
export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | null {
|
||||||
|
if (event.kind !== NIP52_KINDS.CALENDAR_DATE_EVENT) return null
|
||||||
|
|
||||||
|
const dTag = getTagValue(event.tags, 'd')
|
||||||
|
const title = getTagValue(event.tags, 'title')
|
||||||
|
const start = getTagValue(event.tags, 'start')
|
||||||
|
|
||||||
|
if (!dTag || !title || !start) return null
|
||||||
|
|
||||||
|
const participants: Participant[] = event.tags
|
||||||
|
.filter(t => t[0] === 'p')
|
||||||
|
.map(t => ({
|
||||||
|
pubkey: t[1],
|
||||||
|
relayUrl: t[2] || undefined,
|
||||||
|
role: t[3] || undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
dTag,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
title,
|
||||||
|
summary: getTagValue(event.tags, 'summary'),
|
||||||
|
content: event.content,
|
||||||
|
image: getTagValue(event.tags, 'image'),
|
||||||
|
start,
|
||||||
|
end: getTagValue(event.tags, 'end'),
|
||||||
|
location: getTagValue(event.tags, 'location'),
|
||||||
|
geohash: getTagValue(event.tags, 'g'),
|
||||||
|
hashtags: getTagValues(event.tags, 't'),
|
||||||
|
participants,
|
||||||
|
references: getTagValues(event.tags, 'r'),
|
||||||
|
id: event.id,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build NIP-52 tags for a time-based calendar event
|
||||||
|
*/
|
||||||
|
export function buildCalendarTimeEventTags(event: Partial<CalendarTimeEvent>): string[][] {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (event.dTag) tags.push(['d', event.dTag])
|
||||||
|
if (event.title) tags.push(['title', event.title])
|
||||||
|
if (event.summary) tags.push(['summary', event.summary])
|
||||||
|
if (event.image) tags.push(['image', event.image])
|
||||||
|
if (event.start != null) tags.push(['start', String(event.start)])
|
||||||
|
if (event.end != null) tags.push(['end', String(event.end)])
|
||||||
|
if (event.startTzid) tags.push(['start_tzid', event.startTzid])
|
||||||
|
if (event.endTzid) tags.push(['end_tzid', event.endTzid])
|
||||||
|
if (event.location) tags.push(['location', event.location])
|
||||||
|
if (event.geohash) tags.push(['g', event.geohash])
|
||||||
|
|
||||||
|
for (const tag of event.hashtags ?? []) {
|
||||||
|
tags.push(['t', tag])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of event.participants ?? []) {
|
||||||
|
const pTag = ['p', p.pubkey]
|
||||||
|
if (p.relayUrl) pTag.push(p.relayUrl)
|
||||||
|
else if (p.role) pTag.push('')
|
||||||
|
if (p.role) pTag.push(p.role)
|
||||||
|
tags.push(pTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of event.references ?? []) {
|
||||||
|
tags.push(['r', r])
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
42
src/modules/activities/types/ticket.ts
Normal file
42
src/modules/activities/types/ticket.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Database-backed ticket types (via LNbits events extension)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ActivityTicket {
|
||||||
|
id: string
|
||||||
|
wallet: string
|
||||||
|
/** Reference to the activity (LNbits event ID) */
|
||||||
|
activityId: string
|
||||||
|
/** Ticket holder name */
|
||||||
|
name: string | null
|
||||||
|
/** Ticket holder email */
|
||||||
|
email: string | null
|
||||||
|
/** Nostr pubkey or LNbits user ID */
|
||||||
|
userId: string | null
|
||||||
|
/** Whether ticket has been scanned/registered at the door */
|
||||||
|
registered: boolean
|
||||||
|
/** Whether payment has been confirmed */
|
||||||
|
paid: boolean
|
||||||
|
/** Ticket creation timestamp */
|
||||||
|
time: string
|
||||||
|
/** Registration/scan timestamp */
|
||||||
|
regTimestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
||||||
|
|
||||||
|
export interface TicketPurchaseRequest {
|
||||||
|
activityId: string
|
||||||
|
userId: string
|
||||||
|
accessToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketPurchaseInvoice {
|
||||||
|
paymentHash: string
|
||||||
|
paymentRequest: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketPaymentStatus {
|
||||||
|
paid: boolean
|
||||||
|
ticketId?: string
|
||||||
|
}
|
||||||
27
src/modules/activities/views/ActivitiesCalendarPage.vue
Normal file
27
src/modules/activities/views/ActivitiesCalendarPage.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useActivities } from '../composables/useActivities'
|
||||||
|
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
|
||||||
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { allActivities, subscribe } = useActivities()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
subscribe()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSelectActivity(activity: Activity) {
|
||||||
|
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||||
|
<ActivityCalendarView
|
||||||
|
:activities="allActivities"
|
||||||
|
@select-activity="handleSelectActivity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
72
src/modules/activities/views/ActivitiesFavoritesPage.vue
Normal file
72
src/modules/activities/views/ActivitiesFavoritesPage.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Heart } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { useBookmarks } from '../composables/useBookmarks'
|
||||||
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
|
import ActivityList from '../components/ActivityList.vue'
|
||||||
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { isBookmarkedByDTag, isLoaded } = useBookmarks()
|
||||||
|
const store = useActivitiesStore()
|
||||||
|
|
||||||
|
const favoriteActivities = computed(() => {
|
||||||
|
return store.activities.filter(a => isBookmarkedByDTag(a.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSelect(activity: Activity) {
|
||||||
|
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toast.info(t('activities.favorites.loginPrompt'), {
|
||||||
|
action: {
|
||||||
|
label: t('activities.favorites.logIn'),
|
||||||
|
onClick: () => router.push('/login'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground mb-4">{{ t('activities.favorites.title') }}</h1>
|
||||||
|
|
||||||
|
<!-- Not authenticated -->
|
||||||
|
<div v-if="!isAuthenticated" class="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<Heart class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||||
|
<p class="text-muted-foreground mb-3">{{ t('activities.favorites.loginPrompt') }}</p>
|
||||||
|
<Button variant="outline" size="sm" @click="router.push('/login')">
|
||||||
|
{{ t('activities.favorites.logIn') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-else-if="!isLoaded" class="flex flex-col items-center justify-center py-16">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="favoriteActivities.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<Heart class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||||
|
<p class="text-muted-foreground">{{ t('activities.favorites.empty') }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground/70 mt-1">{{ t('activities.favorites.emptyHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Favorites list -->
|
||||||
|
<ActivityList
|
||||||
|
v-else
|
||||||
|
:activities="favoriteActivities"
|
||||||
|
@select="handleSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
55
src/modules/activities/views/ActivitiesMapPage.vue
Normal file
55
src/modules/activities/views/ActivitiesMapPage.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { Map } from 'lucide-vue-next'
|
||||||
|
import { useActivities } from '../composables/useActivities'
|
||||||
|
import ActivityMap from '../components/ActivityMap.vue'
|
||||||
|
|
||||||
|
const { allActivities, isLoading, subscribe } = useActivities()
|
||||||
|
|
||||||
|
function parseMapCenter(): { lat: number; lng: number } | undefined {
|
||||||
|
const raw = import.meta.env.VITE_DEFAULT_MAP_CENTER
|
||||||
|
if (!raw) return undefined
|
||||||
|
const [lat, lng] = raw.split(',').map(Number)
|
||||||
|
if (isNaN(lat) || isNaN(lng)) return undefined
|
||||||
|
return { lat, lng }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapCenter = parseMapCenter()
|
||||||
|
|
||||||
|
const geoActivities = computed(() =>
|
||||||
|
allActivities.value.filter(a => a.coordinates)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
subscribe()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div v-if="isLoading && geoActivities.length === 0" class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No geotagged activities -->
|
||||||
|
<div v-else-if="!isLoading && geoActivities.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||||
|
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||||
|
<p class="text-muted-foreground">No geotagged activities found</p>
|
||||||
|
<p class="text-sm text-muted-foreground/70 mt-1">Activities with location data will appear as markers on the map</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map -->
|
||||||
|
<ActivityMap
|
||||||
|
v-else
|
||||||
|
:activities="geoActivities"
|
||||||
|
:center="mapCenter"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Activity count -->
|
||||||
|
<div v-if="geoActivities.length > 0" class="px-4 py-2 text-xs text-muted-foreground border-t bg-background">
|
||||||
|
{{ geoActivities.length }} activit{{ geoActivities.length === 1 ? 'y' : 'ies' }} on map
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
147
src/modules/activities/views/ActivitiesPage.vue
Normal file
147
src/modules/activities/views/ActivitiesPage.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import { RefreshCw, SlidersHorizontal, ChevronDown, Plus } from 'lucide-vue-next'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { useActivities } from '../composables/useActivities'
|
||||||
|
import CreateActivityDialog from '../components/CreateActivityDialog.vue'
|
||||||
|
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
|
||||||
|
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 { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
activities,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
temporal,
|
||||||
|
selectedCategories,
|
||||||
|
hasActiveFilters,
|
||||||
|
selectedDate,
|
||||||
|
selectDate,
|
||||||
|
setTemporal,
|
||||||
|
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 items-center justify-between mb-4">
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||||
|
{{ t('activities.title') }}
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
size="sm"
|
||||||
|
@click="showCreateDialog = true"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4 mr-1.5" />
|
||||||
|
<span class="hidden sm:inline">{{ t('activities.createNew') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="handleRefresh" :disabled="isLoading">
|
||||||
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search with dropdown overlay -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<ActivitySearchOverlay
|
||||||
|
:activities="activities"
|
||||||
|
@select="handleSelectActivity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date picker strip (p'a semana style) -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Temporal filter pills -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Activity feed -->
|
||||||
|
<ActivityList
|
||||||
|
:activities="activities"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
@select="handleSelectActivity"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Create Activity Dialog -->
|
||||||
|
<CreateActivityDialog
|
||||||
|
v-model:is-open="showCreateDialog"
|
||||||
|
@created="handleRefresh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
166
src/modules/activities/views/ActivityDetailPage.vue
Normal file
166
src/modules/activities/views/ActivityDetailPage.vue
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
<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 { useDateLocale } from '../composables/useDateLocale'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import {
|
||||||
|
Calendar, MapPin, ArrowLeft,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||||
|
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
|
import RSVPButton from '../components/RSVPButton.vue'
|
||||||
|
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const activityId = route.params.id as string
|
||||||
|
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
|
||||||
|
const { dateLocale } = useDateLocale()
|
||||||
|
|
||||||
|
const dateDisplay = computed(() => {
|
||||||
|
if (!activity.value) return ''
|
||||||
|
const a = activity.value
|
||||||
|
const opts = { locale: dateLocale.value }
|
||||||
|
if (a.type === 'date') {
|
||||||
|
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
|
||||||
|
if (a.endDate) {
|
||||||
|
return `${start} — ${format(a.endDate, 'EEEE, MMMM d, yyyy', opts)}`
|
||||||
|
}
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
const start = format(a.startDate, 'EEEE, MMMM d, yyyy \u2022 HH:mm', opts)
|
||||||
|
if (a.endDate) {
|
||||||
|
return `${start} — ${format(a.endDate, 'HH:mm', opts)}`
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
||||||
|
<ArrowLeft class="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<BookmarkButton
|
||||||
|
v-if="activity"
|
||||||
|
:pubkey="activity.organizer.pubkey"
|
||||||
|
:d-tag="activity.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- RSVP -->
|
||||||
|
<RSVPButton
|
||||||
|
:pubkey="activity.organizer.pubkey"
|
||||||
|
:d-tag="activity.id"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Organizer -->
|
||||||
|
<div class="bg-muted/50 rounded-lg p-4">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
{{ t('activities.detail.organizer') }}
|
||||||
|
</p>
|
||||||
|
<OrganizerCard :pubkey="activity.organizer.pubkey" />
|
||||||
|
</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>
|
||||||
10
src/modules/activities/views/MyTicketsPage.vue
Normal file
10
src/modules/activities/views/MyTicketsPage.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Phase 3 will implement the full tickets view
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">My Tickets</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">Your purchased tickets will appear here</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
113
vite.activities.config.ts
Normal file
113
vite.activities.config.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { defineConfig, type Plugin } from 'vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||||
|
import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin to rewrite dev server requests to activities.html
|
||||||
|
* (SPA fallback for the standalone activities app entry point)
|
||||||
|
*/
|
||||||
|
function activitiesHtmlPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'activities-html-rewrite',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Rewrite all non-asset requests to activities.html
|
||||||
|
if (
|
||||||
|
req.url &&
|
||||||
|
!req.url.startsWith('/@') &&
|
||||||
|
!req.url.startsWith('/src/') &&
|
||||||
|
!req.url.startsWith('/node_modules/') &&
|
||||||
|
!req.url.includes('.') // skip files with extensions
|
||||||
|
) {
|
||||||
|
req.url = '/activities.html'
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite config for the standalone Sortir activities app.
|
||||||
|
* Deployed to sortir.ariege.io
|
||||||
|
*/
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
plugins: [
|
||||||
|
activitiesHtmlPlugin(),
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
|
},
|
||||||
|
includeAssets: [
|
||||||
|
'favicon.ico',
|
||||||
|
'apple-touch-icon.png',
|
||||||
|
'mask-icon.svg',
|
||||||
|
'icon-192.png',
|
||||||
|
'icon-512.png',
|
||||||
|
'icon-maskable-192.png',
|
||||||
|
'icon-maskable-512.png',
|
||||||
|
],
|
||||||
|
manifest: {
|
||||||
|
name: 'Sortir — Activités & Événements',
|
||||||
|
short_name: 'Sortir',
|
||||||
|
description: 'Découvrez les activités et événements près de chez vous',
|
||||||
|
theme_color: '#1f2937',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait-primary',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
id: 'sortir-activities',
|
||||||
|
categories: ['social', 'entertainment', 'lifestyle'],
|
||||||
|
lang: 'fr',
|
||||||
|
icons: [
|
||||||
|
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||||
|
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ViteImageOptimizer({
|
||||||
|
jpg: { quality: 80 },
|
||||||
|
png: { quality: 80 },
|
||||||
|
webp: { lossless: true },
|
||||||
|
}),
|
||||||
|
mode === 'analyze' &&
|
||||||
|
visualizer({
|
||||||
|
open: true,
|
||||||
|
filename: 'dist-activities/stats.html',
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-activities',
|
||||||
|
rollupOptions: {
|
||||||
|
input: 'activities.html',
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||||
|
'ui-vendor': ['radix-vue', '@vueuse/core'],
|
||||||
|
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 1000,
|
||||||
|
},
|
||||||
|
}))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue