Compare commits

...

24 commits

Author SHA1 Message Date
Patrick Mulligan
b1e8534ca7 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).
2026-04-25 07:38:17 -04:00
93c2a73e21 Internationalize all UI strings and localize date formatting
All hardcoded English strings now use t() with i18n keys: bottom nav
tabs, search placeholder, empty states, favorites page, settings page.

Added useDateLocale composable that maps the current i18n language to
the corresponding date-fns locale (fr/es/enUS). All date formatting
across ActivityCard, ActivityDetailPage, DatePickerStrip, calendar
view, and search overlay now passes the locale to date-fns format().
Month names, day names, and date labels change with language switch.

Added nav, search, favorites, and settings i18n sections to all three
locales (EN/FR/ES).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 07:24:43 +02:00
f782cd1a7a Replace inline search with dropdown overlay for mobile
New ActivitySearchOverlay component: compact dropdown anchored to the
search input showing thumbnail, title, date, and location for each
result. Limited to 8 results, scrollable. Stays visible above the
keyboard on mobile. Tap a result to navigate, tap outside or clear
to dismiss. Uses the same Fuse.js fuzzy search under the hood.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:25:07 +02:00
a75885ca76 Only fit map bounds on initial load, not on every activity update
Prevents the map from resetting zoom/pan when new activities stream
in from relays. Users can now freely zoom and pan without being
snapped back to the world view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:46:39 +02:00
276e1a8135 Fix empty state icon: use CalendarSearch with element opacity
Replaces Calendar with CalendarSearch for better empty-state semantics.
Uses opacity-30 on the element instead of text color opacity to avoid
visible stroke overlaps on SVG icons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:43:57 +02:00
36c8cb8c7a Add Spanish (Latin American) activities translations
Full activities i18n for Spanish: categories, detail page, tickets,
filters. Uses Latin American terminology (boleto instead of entrada,
charla instead of ponencia). Added Spanish to the standalone app
Settings language picker alongside French and English.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:08:37 +02:00
2ceedf8bbf Add env vars for default locale and map center
VITE_DEFAULT_LOCALE (fr/en/es) sets the initial language for the
standalone app when no user preference is saved. VITE_DEFAULT_MAP_CENTER
(lat,lng) configures the default map center point. Both fall back to
sensible defaults (fr and France center) when not set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:55:08 +02:00
8a3ff14568 Skip activities with invalid dates in calendar view
Prevents RangeError from format() on activities with unparseable
start timestamps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:44:49 +02:00
4926c3a313 Add map and calendar views (Phase 5)
Map view: Leaflet + OpenStreetMap with markers from NIP-52 geohash
tags. Click popup navigates to activity detail. Auto-fits bounds to
show all markers. Geohash decoding via ngeohash added to Activity
model conversion.

Calendar view: Month grid with activity dots (up to 3 per day).
Click a day to see activities listed below the calendar. Month
navigation with prev/next buttons.

New dependencies: leaflet, @types/leaflet, ngeohash, @types/ngeohash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:41:38 +02:00
e0dde90adc Show toast with login link instead of redirecting for bookmark/RSVP
Both BookmarkButton and RSVPButton now show a toast notification
with a "Log in" action button when tapped by unauthenticated users.
RSVP buttons are now always visible (not hidden behind v-if).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:35:37 +02:00
bd6ba375b3 Replace plain text search with Fuse.js fuzzy search
Uses the existing FuzzySearch component and useFuzzySearch composable
(same pattern as market module). Searches across title (50%), summary
(20%), description (15%), location (10%), and tags (5%) with threshold
0.35. Fuzzy search feeds into the display list after temporal/category/
date filters are applied. Removed manual string search from filter
composable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:42:10 +02:00
272288f1e2 Move bookmark heart from image overlay to title row in card
Heart is now next to the title text in the caption area where
it's always visible regardless of the background image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:36:58 +02:00
ced192b65e Show bookmark heart to all users, redirect to login if not authenticated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:34:47 +02:00
c62229c795 Add social features: bookmarks, RSVP, organizer profiles (Phase 4)
Bookmarks (NIP-51 kind 10003): useBookmarks composable publishes/reads
bookmark lists with 'a' tag references to calendar events. BookmarkButton
(heart icon) on activity cards and detail page. Favorites page shows
bookmarked activities.

RSVP (NIP-52 kind 31925): useRSVP composable publishes addressable RSVP
events with accepted/declined/tentative status. RSVPButton with Going/
Maybe/Not Going toggle and attendee count on detail page.

Organizer profiles (NIP-01 kind 0): useOrganizerProfile fetches metadata
from relays with global cache. OrganizerCard displays avatar, name, and
nip05. useBatchProfiles for bulk fetching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:31:21 +02:00
703e093488 Add dist-* to gitignore for standalone app build outputs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:23:25 +02:00
05befc8a24 Fix date strip to show all 7 days on mobile
Use CSS grid (grid-cols-7) instead of flex with min-width so all days
always fit. Single-letter day names (M T W T F S S) instead of
three-letter abbreviations to save horizontal space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:12:11 +02:00
0238716acf Fix date filtering and timestamp parsing
- parseTimestamp now handles unix seconds, milliseconds, and ISO strings;
  returns NaN for unparseable values instead of 0 (which caused Jan 1 1970)
- Events with unparseable start timestamps are rejected by the parser
- ActivityCard safely handles invalid dates without crashing
- DatePickerStrip day selection now filters activities to that date
- Selecting a day clears temporal pills; selecting a pill clears the day
- Feed shows all activities sorted by date (no more upcoming/past split)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:09:37 +02:00
021198ab0f Keep temporal filter pills on single row
Use horizontal scroll with hidden scrollbar instead of flex-wrap.
Pills stay on one line and scroll on narrow screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:02:07 +02:00
76840567cb Remove Upcoming/Past tabs from activities feed
Replace tabbed layout with a single continuous feed matching p'a semana
style. Temporal filter pills already handle time-based filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:01:06 +02:00
63c20e39ae Move refresh to compact icon button in header row
Refresh is now a ghost icon-only button next to the title instead of
a full-width outlined button. Create button text hidden on mobile
(icon-only). Saves vertical space on small screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:00:01 +02:00
ac163d3b82 Add activity creation and publishing (Phase 2)
CreateActivityDialog with vee-validate + Zod form: title, summary,
description, start/end date+time, location, categories (multi-select),
and image URL. Signs NIP-52 kind 31923 events with user's signing key
via nostr-tools finalizeEvent and publishes through RelayHub. Fixed
ActivitiesNostrService.publishCalendarEvent to properly sign events
before publishing. CategorySelector and LocationPicker helper components.
Create button visible only to authenticated users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 07:58:41 +02:00
00eddc9189 Add standalone Sortir activities app (sortir.ariege.io)
Second Vite entry point for deploying the activities module as an
independent PWA at sortir.ariege.io. Includes its own App.vue with
bottom navigation bar (p'a semana style: Feed, Calendar, Map, Favorites,
Settings), stripped-down app config (base + activities only), French
PWA manifest, and SPA fallback plugin for dev server. New routes for
calendar, map, and favorites views (placeholder). Settings page with
theme toggle, language switcher (FR/EN), and auth.

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 18:48:53 +02:00
e98356ffa0 Add activities module foundation (Phase 0)
Nostr-native communal events module using NIP-52 Calendar Events.
Includes types (activity, ticket, category, NIP-52 parsers/builders),
services (ActivitiesNostrService, TicketApiService, PaymentProvider
abstraction with LNbits implementation), Pinia store with deduplication,
module plugin with DI registration, i18n (EN/FR/ES), and placeholder views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 18:46:04 +02:00
55 changed files with 4821 additions and 0 deletions

View file

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

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

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

43
package-lock.json generated
View file

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

View file

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

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

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

View file

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

View file

@ -0,0 +1,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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View 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: '&copy; <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>

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

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

View file

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

View file

@ -0,0 +1,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>

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

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

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

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

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

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import type { TemporalFilter } from '../types/filters'
const props = defineProps<{
modelValue: TemporalFilter
}>()
const emit = defineEmits<{
'update:modelValue': [value: TemporalFilter]
}>()
const { t } = useI18n()
const options: { value: TemporalFilter; labelKey: string }[] = [
{ value: 'all', labelKey: 'activities.filters.all' },
{ value: 'today', labelKey: 'activities.filters.today' },
{ value: 'tomorrow', labelKey: 'activities.filters.tomorrow' },
{ value: 'this-week', labelKey: 'activities.filters.thisWeek' },
{ value: 'this-month', labelKey: 'activities.filters.thisMonth' },
]
</script>
<template>
<div class="flex 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>

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

View file

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

View file

@ -0,0 +1,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
})
}

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

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

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

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

View file

@ -0,0 +1,128 @@
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
import { SERVICE_TOKENS } from '@/core/di-container'
import { ActivitiesNostrService } from './services/ActivitiesNostrService'
import { TicketApiService, type TicketApiConfig } from './services/TicketApiService'
export interface ActivitiesModuleConfig {
apiConfig: TicketApiConfig
defaultMapCenter?: { lat: number; lng: number }
maxTicketsPerUser?: number
enableMap?: boolean
enablePrivateEvents?: boolean
}
/**
* Activities Module Plugin
*
* Nostr-native communal events module using NIP-52 Calendar Events
* for discovery, with database-backed ticketing via LNbits.
*/
export const activitiesModule = createModulePlugin({
name: 'activities',
version: '1.0.0',
dependencies: ['base'],
routes: [
{
path: '/activities',
name: 'activities',
component: () => import('./views/ActivitiesPage.vue'),
meta: {
title: 'Activities',
requiresAuth: false,
},
},
{
path: '/activities/calendar',
name: 'activities-calendar',
component: () => import('./views/ActivitiesCalendarPage.vue'),
meta: {
title: 'Calendar',
requiresAuth: false,
},
},
{
path: '/activities/map',
name: 'activities-map',
component: () => import('./views/ActivitiesMapPage.vue'),
meta: {
title: 'Map',
requiresAuth: false,
},
},
{
path: '/activities/favorites',
name: 'activities-favorites',
component: () => import('./views/ActivitiesFavoritesPage.vue'),
meta: {
title: 'Favorites',
requiresAuth: 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'

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

View file

@ -0,0 +1,91 @@
import type {
PaymentProvider,
CreateInvoiceParams,
InvoiceResult,
PaymentStatus,
PayInvoiceResult,
} from './PaymentProviderInterface'
export interface LnbitsPaymentConfig {
baseUrl: string
apiKey: string
}
/**
* LNbits implementation of PaymentProvider.
* Talks to the LNbits REST API for invoice creation, payment, and status checks.
*/
export class LnbitsPaymentProvider implements PaymentProvider {
constructor(private config: LnbitsPaymentConfig) {}
async createInvoice(params: CreateInvoiceParams): Promise<InvoiceResult> {
const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': this.config.apiKey,
},
body: JSON.stringify({
out: false,
amount: params.amount,
memo: params.memo,
extra: params.metadata,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to create invoice' }))
throw new Error(typeof error.detail === 'string' ? error.detail : 'Failed to create invoice')
}
const data = await response.json()
return {
paymentHash: data.payment_hash,
paymentRequest: data.payment_request,
}
}
async checkPaymentStatus(paymentHash: string): Promise<PaymentStatus> {
const response = await fetch(`${this.config.baseUrl}/api/v1/payments/${paymentHash}`, {
headers: {
'X-Api-Key': this.config.apiKey,
},
})
if (!response.ok) {
throw new Error('Failed to check payment status')
}
const data = await response.json()
return {
paid: data.paid === true,
preimage: data.preimage,
}
}
async payInvoice(paymentRequest: string): Promise<PayInvoiceResult> {
const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': this.config.apiKey,
},
body: JSON.stringify({
out: true,
bolt11: paymentRequest,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to pay invoice' }))
throw new Error(typeof error.detail === 'string' ? error.detail : 'Failed to pay invoice')
}
const data = await response.json()
return {
paymentHash: data.payment_hash,
feeMsat: data.fee_msat ?? 0,
preimage: data.preimage ?? '',
}
}
}

View file

@ -0,0 +1,48 @@
/**
* Payment Provider Abstraction
*
* Enables swapping between LNbits and lightning.pub (or other providers)
* without changing consuming code.
*/
export interface CreateInvoiceParams {
/** Amount in the specified currency */
amount: number
/** Currency code (e.g., 'sats', 'EUR') */
currency: string
/** Invoice memo/description */
memo: string
/** Arbitrary metadata attached to the invoice */
metadata?: Record<string, string>
}
export interface InvoiceResult {
paymentHash: string
paymentRequest: string
}
export interface PaymentStatus {
paid: boolean
preimage?: string
}
export interface PayInvoiceResult {
paymentHash: string
feeMsat: number
preimage: string
}
/**
* Abstract payment provider interface.
* Implementations handle the specifics of LNbits, lightning.pub, etc.
*/
export interface PaymentProvider {
/** Create a Lightning invoice for receiving payment */
createInvoice(params: CreateInvoiceParams): Promise<InvoiceResult>
/** Check whether an invoice has been paid */
checkPaymentStatus(paymentHash: string): Promise<PaymentStatus>
/** Pay a Lightning invoice from the user's wallet */
payInvoice(paymentRequest: string): Promise<PayInvoiceResult>
}

View file

@ -0,0 +1,158 @@
import type {
ActivityTicket,
TicketPurchaseInvoice,
TicketPaymentStatus,
} from '../types/ticket'
export interface TicketApiConfig {
baseUrl: string
apiKey: string
}
/**
* Database-backed ticketing API service.
* Talks to the LNbits events extension for ticket inventory,
* purchases, payment status, and validation.
*
* This is NOT a BaseService -- it's a simple API wrapper instantiated
* with config at module install time (same pattern as EventsApiService).
*/
export class TicketApiService {
constructor(private config: TicketApiConfig) {}
/**
* Fetch all public events from the LNbits events extension.
* Used to correlate Nostr activities with ticketed events.
*/
async fetchTicketedEvents(): Promise<any[]> {
const response = await this.request(
'/events/api/v1/events/public',
{ method: 'GET' }
)
return response
}
/**
* Request a ticket purchase (creates a Lightning invoice).
*/
async requestTicket(
eventId: string,
userId: string,
accessToken: string
): Promise<TicketPurchaseInvoice> {
const data = await this.request(
`/events/api/v1/tickets/${eventId}/user/${userId}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
)
return {
paymentHash: data.payment_hash,
paymentRequest: data.payment_request,
}
}
/**
* Check whether a ticket payment has been confirmed.
*/
async checkPaymentStatus(
eventId: string,
paymentHash: string
): Promise<TicketPaymentStatus> {
const data = await this.request(
`/events/api/v1/tickets/${eventId}/${paymentHash}`,
{ method: 'POST' }
)
return {
paid: data.paid === true,
ticketId: data.ticket_id,
}
}
/**
* Fetch all tickets for a user.
*/
async fetchUserTickets(
userId: string,
accessToken: string
): Promise<ActivityTicket[]> {
const data = await this.request(
`/events/api/v1/tickets/user/${userId}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
)
return (data as any[]).map(t => ({
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
}))
}
/**
* Validate/register a ticket at the door (scan).
*/
async validateTicket(ticketId: string): Promise<ActivityTicket[]> {
const data = await this.request(
`/events/api/v1/register/ticket/${ticketId}`,
{ method: 'GET' }
)
return (data as any[]).map(t => ({
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
}))
}
/**
* Internal fetch helper with standard headers and error handling.
*/
private async request(path: string, init: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = {
'accept': 'application/json',
'X-API-KEY': this.config.apiKey,
...(init.headers as Record<string, string> ?? {}),
}
const response = await fetch(`${this.config.baseUrl}${path}`, {
...init,
headers,
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: `Request failed: ${path}` }))
const errorMessage = typeof error.detail === 'string'
? error.detail
: Array.isArray(error.detail)
? error.detail[0]?.msg ?? 'Request failed'
: 'Request failed'
throw new Error(errorMessage)
}
return response.json()
}
}

View file

@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Activity } from '../types/activity'
/**
* Pinia store for cached activities from Nostr relays.
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
*/
export const useActivitiesStore = defineStore('activities', () => {
// State
const activitiesMap = ref<Map<string, Activity>>(new Map())
const isLoading = ref(false)
const lastUpdated = ref<Date | null>(null)
// Computed
const activities = computed(() => Array.from(activitiesMap.value.values()))
const upcomingActivities = computed(() => {
const now = new Date()
return activities.value
.filter(a => a.startDate >= now || (a.endDate && a.endDate >= now))
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
})
const pastActivities = computed(() => {
const now = new Date()
return activities.value
.filter(a => {
const endOrStart = a.endDate ?? a.startDate
return endOrStart < now
})
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
})
// Actions
/**
* Add or update an activity in the store.
* Deduplicates by id (d-tag). Newer events replace older ones.
*/
function upsertActivity(activity: Activity) {
const existing = activitiesMap.value.get(activity.id)
// Only update if this is a newer version
if (!existing || activity.createdAt >= existing.createdAt) {
activitiesMap.value.set(activity.id, activity)
lastUpdated.value = new Date()
}
}
/**
* Add multiple activities (batch upsert).
*/
function upsertActivities(newActivities: Activity[]) {
for (const activity of newActivities) {
upsertActivity(activity)
}
}
/**
* Remove an activity from the store.
*/
function removeActivity(id: string) {
activitiesMap.value.delete(id)
}
/**
* Clear all cached activities.
*/
function clearAll() {
activitiesMap.value.clear()
lastUpdated.value = null
}
/**
* Get a single activity by its id (d-tag).
*/
function getActivityById(id: string): Activity | undefined {
return activitiesMap.value.get(id)
}
return {
// State
activitiesMap,
isLoading,
lastUpdated,
// Computed
activities,
upcomingActivities,
pastActivities,
// Actions
upsertActivity,
upsertActivities,
removeActivity,
clearAll,
getActivityById,
}
})

View file

@ -0,0 +1,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
}
}

View file

@ -0,0 +1,35 @@
/**
* Activity categories inspired by p'a semana
* Mapped to NIP-52 't' (hashtag) tags
*/
export const ACTIVITY_CATEGORIES = {
concert: 'concert',
workshop: 'workshop',
market: 'market',
festival: 'festival',
exhibition: 'exhibition',
sport: 'sport',
theater: 'theater',
cinema: 'cinema',
party: 'party',
talk: 'talk',
conference: 'conference',
meetup: 'meetup',
food: 'food',
outdoor: 'outdoor',
kids: 'kids',
wellness: 'wellness',
technology: 'technology',
art: 'art',
music: 'music',
dance: 'dance',
literature: 'literature',
comedy: 'comedy',
charity: 'charity',
tradition: 'tradition',
other: 'other',
} as const
export type ActivityCategory = typeof ACTIVITY_CATEGORIES[keyof typeof ACTIVITY_CATEGORIES]
export const ALL_CATEGORIES = Object.values(ACTIVITY_CATEGORIES)

View file

@ -0,0 +1,28 @@
import type { ActivityCategory } from './category'
/**
* Temporal filter presets (p'a semana style)
*/
export type TemporalFilter = 'all' | 'today' | 'tomorrow' | 'this-week' | 'this-month'
/**
* Combined filter state for activity discovery
*/
export interface ActivityFilters {
temporal: TemporalFilter
categories: ActivityCategory[]
/** Free text search */
search?: string
/** Geohash prefix for geographic filtering */
geohash?: string
/** Filter by specific organizer pubkey */
organizerPubkey?: string
}
/**
* Default filter state
*/
export const DEFAULT_FILTERS: ActivityFilters = {
temporal: 'all',
categories: [],
}

View file

@ -0,0 +1,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
}

View file

@ -0,0 +1,42 @@
/**
* Database-backed ticket types (via LNbits events extension)
*/
export interface ActivityTicket {
id: string
wallet: string
/** Reference to the activity (LNbits event ID) */
activityId: string
/** Ticket holder name */
name: string | null
/** Ticket holder email */
email: string | null
/** Nostr pubkey or LNbits user ID */
userId: string | null
/** Whether ticket has been scanned/registered at the door */
registered: boolean
/** Whether payment has been confirmed */
paid: boolean
/** Ticket creation timestamp */
time: string
/** Registration/scan timestamp */
regTimestamp: string
}
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
export interface TicketPurchaseRequest {
activityId: string
userId: string
accessToken: string
}
export interface TicketPurchaseInvoice {
paymentHash: string
paymentRequest: string
}
export interface TicketPaymentStatus {
paid: boolean
ticketId?: string
}

View file

@ -0,0 +1,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>

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

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

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

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

View file

@ -0,0 +1,10 @@
<script setup lang="ts">
// Phase 3 will implement the full tickets view
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground">My Tickets</h1>
<p class="text-muted-foreground mt-2">Your purchased tickets will appear here</p>
</div>
</template>

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

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