refactor(events): rename activities module to events + wire VITE_APP_NAME for per-deployment branding #94
77 changed files with 849 additions and 1107 deletions
20
.env.example
20
.env.example
|
|
@ -1,4 +1,10 @@
|
||||||
# App Configuration
|
# App Configuration
|
||||||
|
# Per-standalone display name — sets browser tab title, PWA install
|
||||||
|
# name/short_name, and the brand string in console logs. Each standalone
|
||||||
|
# (events, wallet, chat, market, …) gets its own VITE_APP_NAME at build
|
||||||
|
# time via NixOS `services.webapp-standalones.<app>.displayName` (see
|
||||||
|
# server-deploy). cfaun ships the events app as "Bouge"; defaults to
|
||||||
|
# "Events" / "Wallet" / etc. when unset.
|
||||||
VITE_APP_NAME=MyApp
|
VITE_APP_NAME=MyApp
|
||||||
|
|
||||||
# Nostr Configuration
|
# Nostr Configuration
|
||||||
|
|
@ -14,7 +20,7 @@ VITE_WEBSOCKET_ENABLED=true
|
||||||
# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint).
|
# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint).
|
||||||
# Logged by the LNbits server at startup:
|
# Logged by the LNbits server at startup:
|
||||||
# `Nostr transport: starting with pubkey <hex>... on N relay(s)`
|
# `Nostr transport: starting with pubkey <hex>... on N relay(s)`
|
||||||
# Required for the activities ticket scanner; legacy HTTP path still
|
# Required for the events ticket scanner; legacy HTTP path still
|
||||||
# works without it.
|
# works without it.
|
||||||
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY=
|
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY=
|
||||||
|
|
||||||
|
|
@ -35,8 +41,8 @@ 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
|
# Events App Configuration
|
||||||
# Default language for the standalone activities app (fr, en, es)
|
# Default language for the standalone events app (fr, en, es)
|
||||||
VITE_DEFAULT_LOCALE=fr
|
VITE_DEFAULT_LOCALE=fr
|
||||||
# Default map center as "lat,lng" (defaults to France center if not set)
|
# Default map center as "lat,lng" (defaults to France center if not set)
|
||||||
VITE_DEFAULT_MAP_CENTER=42.9667,1.6000
|
VITE_DEFAULT_MAP_CENTER=42.9667,1.6000
|
||||||
|
|
@ -64,7 +70,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
|
||||||
#
|
#
|
||||||
# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined
|
# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined
|
||||||
# in the vite configs):
|
# in the vite configs):
|
||||||
# VITE_HUB_ACTIVITIES_URL=http://localhost:5181
|
# VITE_HUB_EVENTS_URL=http://localhost:5181
|
||||||
# VITE_HUB_LIBRA_URL=http://localhost:5180
|
# VITE_HUB_LIBRA_URL=http://localhost:5180
|
||||||
# VITE_HUB_WALLET_URL=http://localhost:5182
|
# VITE_HUB_WALLET_URL=http://localhost:5182
|
||||||
# VITE_HUB_CHAT_URL=http://localhost:5183
|
# VITE_HUB_CHAT_URL=http://localhost:5183
|
||||||
|
|
@ -74,7 +80,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
|
||||||
# VITE_HUB_RESTAURANT_URL=http://localhost:5187
|
# VITE_HUB_RESTAURANT_URL=http://localhost:5187
|
||||||
#
|
#
|
||||||
# In PATH-MODE production (recommended for demo) — note the trailing slash:
|
# In PATH-MODE production (recommended for demo) — note the trailing slash:
|
||||||
# VITE_HUB_ACTIVITIES_URL=https://demo.example.com/activities/
|
# VITE_HUB_EVENTS_URL=https://demo.example.com/events/
|
||||||
# VITE_HUB_LIBRA_URL=https://demo.example.com/libra/
|
# VITE_HUB_LIBRA_URL=https://demo.example.com/libra/
|
||||||
# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/
|
# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/
|
||||||
# VITE_HUB_CHAT_URL=https://demo.example.com/chat/
|
# VITE_HUB_CHAT_URL=https://demo.example.com/chat/
|
||||||
|
|
@ -84,11 +90,11 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
|
||||||
# VITE_HUB_RESTAURANT_URL=https://demo.example.com/restaurant/
|
# VITE_HUB_RESTAURANT_URL=https://demo.example.com/restaurant/
|
||||||
#
|
#
|
||||||
# In SUBDOMAIN-MODE production:
|
# In SUBDOMAIN-MODE production:
|
||||||
# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com
|
# VITE_HUB_EVENTS_URL=https://events.example.com
|
||||||
# VITE_HUB_LIBRA_URL=https://libra.example.com
|
# VITE_HUB_LIBRA_URL=https://libra.example.com
|
||||||
# ...etc
|
# ...etc
|
||||||
# ───────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
VITE_HUB_ACTIVITIES_URL=
|
VITE_HUB_EVENTS_URL=
|
||||||
VITE_HUB_LIBRA_URL=
|
VITE_HUB_LIBRA_URL=
|
||||||
VITE_HUB_WALLET_URL=
|
VITE_HUB_WALLET_URL=
|
||||||
VITE_HUB_CHAT_URL=
|
VITE_HUB_CHAT_URL=
|
||||||
|
|
|
||||||
|
|
@ -717,7 +717,7 @@ VITE_WEBSOCKET_ENABLED=true
|
||||||
## Payment Rails Pattern
|
## Payment Rails Pattern
|
||||||
|
|
||||||
Shared primitives for modules that mix Lightning + fiat (and, future,
|
Shared primitives for modules that mix Lightning + fiat (and, future,
|
||||||
cash / internal-wallet) payment rails. Activities is the first
|
cash / internal-wallet) payment rails. Events is the first
|
||||||
consumer; restaurant + marketplace will adopt the same primitives as
|
consumer; restaurant + marketplace will adopt the same primitives as
|
||||||
their backends gain fiat support.
|
their backends gain fiat support.
|
||||||
|
|
||||||
|
|
@ -784,7 +784,7 @@ type PaymentMethod = {
|
||||||
```
|
```
|
||||||
|
|
||||||
Module usage:
|
Module usage:
|
||||||
- **Activities** passes `[lightning, ...one entry per organizer provider]`.
|
- **Events** passes `[lightning, ...one entry per organizer provider]`.
|
||||||
- **Restaurant** (future) passes the subset of
|
- **Restaurant** (future) passes the subset of
|
||||||
`[lightning, cash, internal, ...fiat providers]` enabled by the
|
`[lightning, cash, internal, ...fiat providers]` enabled by the
|
||||||
restaurant's `accepts_*` flags.
|
restaurant's `accepts_*` flags.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Nostr patterns
|
# Nostr patterns
|
||||||
|
|
||||||
Living reference for reusable Nostr patterns that show up across modules
|
Living reference for reusable Nostr patterns that show up across modules
|
||||||
(activities, forum, market, chat, tasks, base, nostr-feed).
|
(events, forum, market, chat, tasks, base, nostr-feed).
|
||||||
|
|
||||||
**Read before writing any new Nostr code in this repo.** **Update whenever you
|
**Read before writing any new Nostr code in this repo.** **Update whenever you
|
||||||
introduce, refine, or correct a pattern.** Each section has a "Canonical
|
introduce, refine, or correct a pattern.** Each section has a "Canonical
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Treat `result.success === 0` as failure, not success
|
## Treat `result.success === 0` as failure, not success
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`if (!result || result.success <= 0) return null`.
|
`if (!result || result.success <= 0) return null`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -23,7 +23,7 @@ composable. Don't write code that silently treats both as success.
|
||||||
|
|
||||||
## Optimistic-on-success, not optimistic-on-click
|
## Optimistic-on-success, not optimistic-on-click
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` — local
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` — local
|
||||||
cache update after the `await` resolves with `success > 0`, before the
|
cache update after the `await` resolves with `success > 0`, before the
|
||||||
relay echoes the event back through the subscription.
|
relay echoes the event back through the subscription.
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ button flip twice.
|
||||||
|
|
||||||
## Pending-coord debounce: disable the button during in-flight publish
|
## Pending-coord debounce: disable the button during in-flight publish
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`pendingCoords: ref<Set<string>>` + `isPending(...)` predicate +
|
`pendingCoords: ref<Set<string>>` + `isPending(...)` predicate +
|
||||||
`try { … } finally { pendingCoords.value.delete(coord) }`.
|
`try { … } finally { pendingCoords.value.delete(coord) }`.
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ while a previous publish on activity B is still flying. A global
|
||||||
|
|
||||||
## Sign with `nostr-tools.finalizeEvent`, take privkey as bytes
|
## Sign with `nostr-tools.finalizeEvent`, take privkey as bytes
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`hexToUint8Array` helper + `finalizeEvent(template, signingKey)`.
|
`hexToUint8Array` helper + `finalizeEvent(template, signingKey)`.
|
||||||
|
|
||||||
`finalizeEvent` expects a `Uint8Array`, not a hex string. Several composables
|
`finalizeEvent` expects a `Uint8Array`, not a hex string. Several composables
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ in this file follows from that single fact.
|
||||||
|
|
||||||
## Strictly-monotonic `created_at` per coord
|
## Strictly-monotonic `created_at` per coord
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
|
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -31,7 +31,7 @@ than the last click on the same coord.
|
||||||
|
|
||||||
## Per-pubkey latest-wins state for derived counts
|
## Per-pubkey latest-wins state for derived counts
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`rsvpStates: ref<Map<coord, Map<pubkey, RSVPEntry>>>` + `upsertRSVPState` +
|
`rsvpStates: ref<Map<coord, Map<pubkey, RSVPEntry>>>` + `upsertRSVPState` +
|
||||||
`getRSVPCount` (count entries where status === 'accepted').
|
`getRSVPCount` (count entries where status === 'accepted').
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ any "who's currently in state X" question.
|
||||||
|
|
||||||
## Replaceable list, full-rewrite on toggle
|
## Replaceable list, full-rewrite on toggle
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useBookmarks.ts` —
|
**Canonical:** `src/modules/events/composables/useBookmarks.ts` —
|
||||||
NIP-51 kind 10003 bookmark list.
|
NIP-51 kind 10003 bookmark list.
|
||||||
|
|
||||||
For replaceable lists (10003 bookmarks, 10000 mute list, 10006 communities,
|
For replaceable lists (10003 bookmarks, 10000 mute list, 10006 communities,
|
||||||
|
|
@ -66,7 +66,7 @@ diverges on next refresh.
|
||||||
|
|
||||||
## Vue 3 reactivity for nested `ref<Map>`
|
## Vue 3 reactivity for nested `ref<Map>`
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`upsertRSVPState` (the `rsvpStates.value.set(coord, inner)` after mutating
|
`upsertRSVPState` (the `rsvpStates.value.set(coord, inner)` after mutating
|
||||||
`inner`).
|
`inner`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Subscribe, store the unsubscribe handle, clean up on unmount
|
## Subscribe, store the unsubscribe handle, clean up on unmount
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`loadRSVPs()` (subscribe block) + the matching `onUnmounted(() => unsubscribe?.())`.
|
`loadRSVPs()` (subscribe block) + the matching `onUnmounted(() => unsubscribe?.())`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -33,7 +33,7 @@ session-long vs view-long), not by accident.
|
||||||
|
|
||||||
## EOSE means "backfill done", not "all events delivered"
|
## EOSE means "backfill done", not "all events delivered"
|
||||||
|
|
||||||
**Canonical:** `src/modules/activities/composables/useRSVP.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`onEose: () => { isLoaded.value = true }`.
|
`onEose: () => { isLoaded.value = true }`.
|
||||||
|
|
||||||
`onEose` fires once, after the relay flushes everything stored that matches
|
`onEose` fires once, after the relay flushes everything stored that matches
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
|
@ -9,12 +9,12 @@
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||||
<title>Sortir — Activités</title>
|
<title>%VITE_APP_NAME%</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="Sortir">
|
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||||
<meta name="description" content="Découvrez les activités et événements près de chez vous">
|
<meta name="description" content="Discover events near you">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/activities-app/main.ts"></script>
|
<script type="module" src="/src/events-app/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -15,7 +15,7 @@ http {
|
||||||
# PATH-MODE deployment (recommended)
|
# PATH-MODE deployment (recommended)
|
||||||
#
|
#
|
||||||
# demo.<domain>.<com>/ — minimal AIO chakra hub
|
# demo.<domain>.<com>/ — minimal AIO chakra hub
|
||||||
# demo.<domain>.<com>/activities/ — Sortir / activities standalone
|
# demo.<domain>.<com>/events/ — events standalone
|
||||||
# demo.<domain>.<com>/market/ — marketplace standalone
|
# demo.<domain>.<com>/market/ — marketplace standalone
|
||||||
# demo.<domain>.<com>/wallet/ — wallet standalone
|
# demo.<domain>.<com>/wallet/ — wallet standalone
|
||||||
# demo.<domain>.<com>/chat/ — chat standalone
|
# demo.<domain>.<com>/chat/ — chat standalone
|
||||||
|
|
@ -46,11 +46,11 @@ http {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Activities (Sortir) ──────────────────────────────────────────
|
# ── Events ──────────────────────────────────────────
|
||||||
location = /activities { return 301 /activities/$is_args$args; }
|
location = /events { return 301 /events/$is_args$args; }
|
||||||
location /activities/ {
|
location /events/ {
|
||||||
alias /var/www/aio/dist-activities/;
|
alias /var/www/aio/dist-events/;
|
||||||
try_files $uri $uri/ /activities.html;
|
try_files $uri $uri/ /events.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Market ───────────────────────────────────────────────────────
|
# ── Market ───────────────────────────────────────────────────────
|
||||||
|
|
@ -107,13 +107,13 @@ http {
|
||||||
# If you want pretty subdomain URLs that funnel into the path-mode
|
# If you want pretty subdomain URLs that funnel into the path-mode
|
||||||
# canonical, add 301 redirects per app. Example:
|
# canonical, add 301 redirects per app. Example:
|
||||||
#
|
#
|
||||||
# events.demo.<domain>.<com> → demo.<domain>.<com>/activities/
|
# events.demo.<domain>.<com> → demo.<domain>.<com>/events/
|
||||||
# market.demo.<domain>.<com> → demo.<domain>.<com>/market/
|
# market.demo.<domain>.<com> → demo.<domain>.<com>/market/
|
||||||
# ───────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
server {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
server_name events.demo.<domain>.<com>;
|
server_name events.demo.<domain>.<com>;
|
||||||
return 301 https://demo.<domain>.<com>/activities/$request_uri;
|
return 301 https://demo.<domain>.<com>/events/$request_uri;
|
||||||
}
|
}
|
||||||
server {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
|
|
@ -154,7 +154,7 @@ http {
|
||||||
#
|
#
|
||||||
# server { server_name app.<domain>; root /var/www/aio/dist; ... }
|
# server { server_name app.<domain>; root /var/www/aio/dist; ... }
|
||||||
# server { server_name market.<domain>; root /var/www/aio/dist-market; ... }
|
# server { server_name market.<domain>; root /var/www/aio/dist-market; ... }
|
||||||
# server { server_name sortir.<domain>; root /var/www/aio/dist-activities; ... }
|
# server { server_name events.<domain>; root /var/www/aio/dist-events; ... }
|
||||||
# server { server_name wallet.<domain>; root /var/www/aio/dist-wallet; ... }
|
# server { server_name wallet.<domain>; root /var/www/aio/dist-wallet; ... }
|
||||||
# server { server_name chat.<domain>; root /var/www/aio/dist-chat; ... }
|
# server { server_name chat.<domain>; root /var/www/aio/dist-chat; ... }
|
||||||
# server { server_name forum.<domain>; root /var/www/aio/dist-forum; ... }
|
# server { server_name forum.<domain>; root /var/www/aio/dist-forum; ... }
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -9,9 +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",
|
"dev:events": "vite --host --config vite.events.config.ts",
|
||||||
"build:activities": "vue-tsc -b && vite build --config vite.activities.config.ts",
|
"build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
|
||||||
"preview:activities": "vite preview --host --config vite.activities.config.ts",
|
"preview:events": "vite preview --host --config vite.events.config.ts",
|
||||||
"dev:libra": "vite --host --config vite.libra.config.ts",
|
"dev:libra": "vite --host --config vite.libra.config.ts",
|
||||||
"build:libra": "vue-tsc -b && vite build --config vite.libra.config.ts",
|
"build:libra": "vue-tsc -b && vite build --config vite.libra.config.ts",
|
||||||
"preview:libra": "vite preview --host --config vite.libra.config.ts",
|
"preview:libra": "vite preview --host --config vite.libra.config.ts",
|
||||||
|
|
@ -33,8 +33,8 @@
|
||||||
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
|
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
|
||||||
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
|
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
|
||||||
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
|
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
|
||||||
"dev:all": "concurrently -n hub,libra,sortir,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
|
"dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
|
||||||
"build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
|
"build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import type { AppConfig } from './core/types'
|
||||||
/**
|
/**
|
||||||
* Minimal AIO hub configuration.
|
* Minimal AIO hub configuration.
|
||||||
* The all-in-one app at app.${domain} ships only the base module —
|
* The all-in-one app at app.${domain} ships only the base module —
|
||||||
* each feature module (wallet, chat, market, tasks, forum, activities,
|
* each feature module (wallet, chat, market, tasks, forum, events,
|
||||||
* libra) is now its own standalone PWA at its own subdomain.
|
* libra) is now its own standalone PWA at its own subdomain.
|
||||||
*/
|
*/
|
||||||
export const appConfig: AppConfig = {
|
export const appConfig: AppConfig = {
|
||||||
modules: {
|
modules: {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/rou
|
||||||
*
|
*
|
||||||
* The all-in-one app at app.${domain} now ships only the base module
|
* The all-in-one app at app.${domain} now ships only the base module
|
||||||
* plus a chakra icon hub linking out to the standalone module apps
|
* plus a chakra icon hub linking out to the standalone module apps
|
||||||
* (wallet, chat, market, tasks, forum, activities, libra).
|
* (wallet, chat, market, tasks, forum, events, libra).
|
||||||
*/
|
*/
|
||||||
export async function createAppInstance() {
|
export async function createAppInstance() {
|
||||||
console.log('🚀 Starting AIO hub...')
|
console.log('🚀 Starting AIO hub...')
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export function useModularNavigation() {
|
||||||
items.push({ name: t('nav.home'), href: '/', requiresAuth: true })
|
items.push({ name: t('nav.home'), href: '/', requiresAuth: true })
|
||||||
|
|
||||||
// Add navigation items based on enabled modules
|
// Add navigation items based on enabled modules
|
||||||
if (appConfig.modules.activities?.enabled) {
|
if (appConfig.modules.events?.enabled) {
|
||||||
items.push({
|
items.push({
|
||||||
name: t('nav.events'),
|
name: t('nav.events'),
|
||||||
href: '/events',
|
href: '/events',
|
||||||
|
|
@ -67,14 +67,20 @@ export function useModularNavigation() {
|
||||||
const userMenuItems = computed<NavigationItem[]>(() => {
|
const userMenuItems = computed<NavigationItem[]>(() => {
|
||||||
const items: NavigationItem[] = []
|
const items: NavigationItem[] = []
|
||||||
|
|
||||||
// Activities module items (events + tickets)
|
// Events module items (tickets + my events)
|
||||||
if (appConfig.modules.activities?.enabled) {
|
if (appConfig.modules.events?.enabled) {
|
||||||
items.push({
|
items.push({
|
||||||
name: 'My Tickets',
|
name: 'My Tickets',
|
||||||
href: '/my-tickets',
|
href: '/my-tickets',
|
||||||
icon: 'Ticket',
|
icon: 'Ticket',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
})
|
})
|
||||||
|
items.push({
|
||||||
|
name: 'My Events',
|
||||||
|
href: '/my-events',
|
||||||
|
icon: 'CalendarPlus',
|
||||||
|
requiresAuth: true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Market module items
|
// Market module items
|
||||||
|
|
|
||||||
|
|
@ -147,9 +147,9 @@ export const SERVICE_TOKENS = {
|
||||||
// Nostr transport (kind-21000 RPC over relays — LNbits backend)
|
// Nostr transport (kind-21000 RPC over relays — LNbits backend)
|
||||||
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),
|
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),
|
||||||
|
|
||||||
// Activities services (Nostr-native events + ticketing module)
|
// Events services (Nostr-native NIP-52 calendar events + LNbits ticketing)
|
||||||
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
EVENTS_NOSTR_SERVICE: Symbol('eventsNostrService'),
|
||||||
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
|
EVENTS_TICKET_API: Symbol('eventsTicketApi'),
|
||||||
TICKET_API: Symbol('ticketApi'),
|
TICKET_API: Symbol('ticketApi'),
|
||||||
|
|
||||||
// Invoice services
|
// Invoice services
|
||||||
|
|
|
||||||
|
|
@ -7,38 +7,38 @@ import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
||||||
import AppShell from '@/components/layout/AppShell.vue'
|
import AppShell from '@/components/layout/AppShell.vue'
|
||||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useActivitiesStore } from '@/modules/activities/stores/activities'
|
import { useEventsStore } from '@/modules/events/stores/events'
|
||||||
import { useActivities } from '@/modules/activities/composables/useActivities'
|
import { useEvents } from '@/modules/events/composables/useEvents'
|
||||||
import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
|
import { useApprovalState } from '@/modules/events/composables/useApprovalState'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
import type { TicketApiService } from '@/modules/events/services/TicketApiService'
|
||||||
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
|
import type { CreateEventRequest } from '@/modules/events/types/ticket'
|
||||||
import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue'
|
import CreateEventDialog from '@/modules/events/components/CreateEventDialog.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
const activitiesStore = useActivitiesStore()
|
const eventsStore = useEventsStore()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
const { isAdmin, autoApprove } = useApprovalState()
|
||||||
// Used to merge own LNbits drafts into the activities feed right after
|
// Used to merge own LNbits drafts into the events feed right after
|
||||||
// the user creates or edits an event — otherwise the new draft only
|
// the user creates or edits an event — otherwise the new draft only
|
||||||
// surfaces on the next ActivitiesPage subscribe cycle.
|
// surfaces on the next EventsPage subscribe cycle.
|
||||||
const { loadOwnEvents } = useActivities()
|
const { loadOwnEvents } = useEvents()
|
||||||
|
|
||||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||||
// Create lives in the bottom nav: when logged out, tapping it shows an
|
// Create lives in the bottom nav: when logged out, tapping it shows an
|
||||||
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
|
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
|
||||||
// opening the dialog. Per-app placement deliberation tracked at #53.
|
// opening the dialog. Per-app placement deliberation tracked at #53.
|
||||||
const tabs = computed<BottomTab[]>(() => [
|
const tabs = computed<BottomTab[]>(() => [
|
||||||
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
|
{ name: t('events.nav.feed'), icon: Search, path: '/events' },
|
||||||
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
|
{ name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' },
|
||||||
{
|
{
|
||||||
name: t('activities.createNew'),
|
name: t('events.createNew'),
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
toast.info('Log in to create an activity', {
|
toast.info('Log in to create an event', {
|
||||||
action: {
|
action: {
|
||||||
label: 'Log in',
|
label: 'Log in',
|
||||||
onClick: () => router.push('/login'),
|
onClick: () => router.push('/login'),
|
||||||
|
|
@ -48,52 +48,52 @@ const tabs = computed<BottomTab[]>(() => [
|
||||||
}
|
}
|
||||||
// Defensively clear any lingering edit selection so the Create
|
// Defensively clear any lingering edit selection so the Create
|
||||||
// tap always opens in Create mode regardless of a prior Edit.
|
// tap always opens in Create mode regardless of a prior Edit.
|
||||||
activitiesStore.editingEvent = null
|
eventsStore.editingEvent = null
|
||||||
activitiesStore.showCreateDialog = true
|
eventsStore.showCreateDialog = true
|
||||||
},
|
},
|
||||||
disabled: !isAuthenticated.value,
|
disabled: !isAuthenticated.value,
|
||||||
},
|
},
|
||||||
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
||||||
{
|
{
|
||||||
name: t('activities.nav.favorites'),
|
name: t('events.nav.favorites'),
|
||||||
icon: Heart,
|
icon: Heart,
|
||||||
// path kept so the tab stays active-highlighted while the user is
|
// path kept so the tab stays active-highlighted while the user is
|
||||||
// on /activities/favorites; onClick wins for the actual tap so we
|
// on /events/favorites; onClick wins for the actual tap so we
|
||||||
// can gate on auth (mirrors the Create tab pattern above).
|
// can gate on auth (mirrors the Create tab pattern above).
|
||||||
path: '/activities/favorites',
|
path: '/events/favorites',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
toast.info(t('activities.favorites.loginPrompt'), {
|
toast.info(t('events.favorites.loginPrompt'), {
|
||||||
action: {
|
action: {
|
||||||
label: t('activities.favorites.logIn'),
|
label: t('events.favorites.logIn'),
|
||||||
onClick: () => router.push('/login'),
|
onClick: () => router.push('/login'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.push('/activities/favorites')
|
router.push('/events/favorites')
|
||||||
},
|
},
|
||||||
disabled: !isAuthenticated.value,
|
disabled: !isAuthenticated.value,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
// Feed tab is active for the bare /activities route AND all sub-paths that
|
// Feed tab is active for the bare /events route AND all sub-paths that
|
||||||
// aren't owned by another tab (e.g. /activities/<id> detail pages).
|
// aren't owned by another tab (e.g. /events/<id> detail pages).
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
if (path === '/activities') {
|
if (path === '/events') {
|
||||||
return (
|
return (
|
||||||
route.path === '/activities' ||
|
route.path === '/events' ||
|
||||||
(route.path.startsWith('/activities/') &&
|
(route.path.startsWith('/events/') &&
|
||||||
!route.path.startsWith('/activities/calendar') &&
|
!route.path.startsWith('/events/calendar') &&
|
||||||
!route.path.startsWith('/activities/map') &&
|
!route.path.startsWith('/events/map') &&
|
||||||
!route.path.startsWith('/activities/favorites'))
|
!route.path.startsWith('/events/favorites'))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog mount lives at shell level so the Create tab works from any route
|
// Dialog mount lives at shell level so the Create tab works from any route
|
||||||
// within the activities standalone, not just /activities.
|
// within the events standalone, not just /events.
|
||||||
async function handleCreateEvent(eventData: CreateEventRequest) {
|
async function handleCreateEvent(eventData: CreateEventRequest) {
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
||||||
|
|
@ -105,7 +105,7 @@ async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest)
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
// PUT /events/{id} requires the event's wallet admin key.
|
// PUT /events/{id} requires the event's wallet admin key.
|
||||||
const wallet = (currentUser.value?.wallets ?? []).find(
|
const wallet = (currentUser.value?.wallets ?? []).find(
|
||||||
(w) => w.id === activitiesStore.editingEvent?.wallet,
|
(w) => w.id === eventsStore.editingEvent?.wallet,
|
||||||
)
|
)
|
||||||
const adminKey = wallet?.adminkey
|
const adminKey = wallet?.adminkey
|
||||||
if (!adminKey) {
|
if (!adminKey) {
|
||||||
|
|
@ -115,18 +115,18 @@ async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDialogOpenChange(open: boolean) {
|
function handleDialogOpenChange(open: boolean) {
|
||||||
activitiesStore.showCreateDialog = open
|
eventsStore.showCreateDialog = open
|
||||||
// Closing always clears the edit selection so the next "+ Create"
|
// Closing always clears the edit selection so the next "+ Create"
|
||||||
// opens clean instead of inheriting the last-edited event.
|
// opens clean instead of inheriting the last-edited event.
|
||||||
if (!open) activitiesStore.editingEvent = null
|
if (!open) eventsStore.editingEvent = null
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell :tabs="tabs" :is-active="isActive">
|
<AppShell :tabs="tabs" :is-active="isActive">
|
||||||
<CreateEventDialog
|
<CreateEventDialog
|
||||||
:open="activitiesStore.showCreateDialog"
|
:open="eventsStore.showCreateDialog"
|
||||||
:event="activitiesStore.editingEvent"
|
:event="eventsStore.editingEvent"
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
:auto-approve="autoApprove"
|
:auto-approve="autoApprove"
|
||||||
:on-create-event="handleCreateEvent"
|
:on-create-event="handleCreateEvent"
|
||||||
|
|
@ -8,8 +8,8 @@ function parseMapCenter(envValue: string | undefined, fallback: { lat: number; l
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standalone activities app configuration.
|
* Standalone events app configuration.
|
||||||
* Only enables base + activities modules.
|
* Only enables base + events modules.
|
||||||
*/
|
*/
|
||||||
export const appConfig: AppConfig = {
|
export const appConfig: AppConfig = {
|
||||||
modules: {
|
modules: {
|
||||||
|
|
@ -34,8 +34,8 @@ export const appConfig: AppConfig = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
activities: {
|
events: {
|
||||||
name: 'activities',
|
name: 'events',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
lazy: false,
|
lazy: false,
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -7,7 +7,7 @@ import { container } from '@/core/di-container'
|
||||||
|
|
||||||
import appConfig from './app.config'
|
import appConfig from './app.config'
|
||||||
import baseModule from '@/modules/base'
|
import baseModule from '@/modules/base'
|
||||||
import activitiesModule from '@/modules/activities'
|
import eventsModule from '@/modules/events'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
|
|
@ -16,30 +16,35 @@ import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||||
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||||
|
|
||||||
|
const APP_NAME = (import.meta.env.VITE_APP_NAME as string) || 'Events'
|
||||||
|
// Console label shows the brand in parens only when it differs from the
|
||||||
|
// default — avoids the redundant "Events (Events)" on unbranded builds.
|
||||||
|
const APP_LABEL = APP_NAME.toLowerCase() === 'events' ? 'Events' : `Events (${APP_NAME})`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the standalone activities app
|
* Initialize the standalone events app
|
||||||
*/
|
*/
|
||||||
export async function createAppInstance() {
|
export async function createAppInstance() {
|
||||||
console.log('🚀 Starting Sortir — Activities App...')
|
console.log(`🚀 Starting ${APP_LABEL}...`)
|
||||||
|
|
||||||
// Accept token from URL before anything else (cross-subdomain auth relay)
|
// Accept token from URL before anything else (cross-subdomain auth relay)
|
||||||
acceptTokenFromUrl('Sortir')
|
acceptTokenFromUrl(APP_NAME)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
// Collect routes from enabled modules only
|
// Collect routes from enabled modules only
|
||||||
const moduleRoutes = [
|
const moduleRoutes = [
|
||||||
...baseModule.routes || [],
|
...baseModule.routes || [],
|
||||||
...activitiesModule.routes || [],
|
...eventsModule.routes || [],
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
// Activities page is the home page in standalone mode
|
// Events page is the home page in standalone mode
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/activities'
|
redirect: '/events'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|
@ -87,9 +92,9 @@ export async function createAppInstance() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appConfig.modules.activities?.enabled) {
|
if (appConfig.modules.events?.enabled) {
|
||||||
moduleRegistrations.push(
|
moduleRegistrations.push(
|
||||||
pluginManager.register(activitiesModule, appConfig.modules.activities)
|
pluginManager.register(eventsModule, appConfig.modules.events)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +119,7 @@ export async function createAppInstance() {
|
||||||
;(window as any).__container = container
|
;(window as any).__container = container
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Sortir app initialized')
|
console.log(`✅ ${APP_LABEL} initialized`)
|
||||||
return { app, router }
|
return { app, router }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,10 +127,10 @@ export async function startApp() {
|
||||||
try {
|
try {
|
||||||
const { app } = await createAppInstance()
|
const { app } = await createAppInstance()
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
console.log('🎉 Sortir app started!')
|
console.log(`🎉 ${APP_LABEL} started!`)
|
||||||
eventBus.emit('app:started', {}, 'app')
|
eventBus.emit('app:started', {}, 'app')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('💥 Failed to start Sortir app:', error)
|
console.error(`💥 Failed to start ${APP_LABEL}:`, error)
|
||||||
document.getElementById('app')!.innerHTML = `
|
document.getElementById('app')!.innerHTML = `
|
||||||
<div style="padding: 20px; text-align: center; color: red;">
|
<div style="padding: 20px; text-align: center; color: red;">
|
||||||
<h1>Failed to Start</h1>
|
<h1>Failed to Start</h1>
|
||||||
|
|
@ -14,7 +14,7 @@ registerSW({
|
||||||
}, intervalMS)
|
}, intervalMS)
|
||||||
},
|
},
|
||||||
onOfflineReady() {
|
onOfflineReady() {
|
||||||
console.log('Sortir app ready to work offline')
|
console.log(`${(import.meta.env.VITE_APP_NAME as string) || 'Events'} ready to work offline`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -35,27 +35,27 @@ async function handleLogout() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
<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>
|
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('events.settings.title') }}</h1>
|
||||||
|
|
||||||
<!-- Account -->
|
<!-- Account -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.account') }}</h2>
|
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.account') }}</h2>
|
||||||
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
<p class="text-sm text-foreground font-mono truncate">
|
<p class="text-sm text-foreground font-mono truncate">
|
||||||
{{ userPubkey }}
|
{{ userPubkey }}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" class="w-full gap-2" @click="handleLogout">
|
<Button variant="outline" size="sm" class="w-full gap-2" @click="handleLogout">
|
||||||
<LogOut class="w-4 h-4" />
|
<LogOut class="w-4 h-4" />
|
||||||
{{ t('activities.settings.logOut') }}
|
{{ t('events.settings.logOut') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="bg-muted/50 rounded-lg p-4">
|
<div v-else class="bg-muted/50 rounded-lg p-4">
|
||||||
<p class="text-sm text-muted-foreground mb-3">
|
<p class="text-sm text-muted-foreground mb-3">
|
||||||
{{ t('activities.settings.loginPrompt') }}
|
{{ t('events.settings.loginPrompt') }}
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
|
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
|
||||||
<LogIn class="w-4 h-4" />
|
<LogIn class="w-4 h-4" />
|
||||||
{{ t('activities.settings.logIn') }}
|
{{ t('events.settings.logIn') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -64,9 +64,9 @@ async function handleLogout() {
|
||||||
|
|
||||||
<!-- Appearance -->
|
<!-- Appearance -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.appearance') }}</h2>
|
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.appearance') }}</h2>
|
||||||
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
|
<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>
|
<span class="text-sm text-foreground">{{ t('events.settings.theme') }}</span>
|
||||||
<Button variant="outline" size="icon" class="h-8 w-8" @click="toggleTheme">
|
<Button variant="outline" size="icon" class="h-8 w-8" @click="toggleTheme">
|
||||||
<Sun v-if="theme === 'dark'" class="w-4 h-4" />
|
<Sun v-if="theme === 'dark'" class="w-4 h-4" />
|
||||||
<Moon v-else class="w-4 h-4" />
|
<Moon v-else class="w-4 h-4" />
|
||||||
|
|
@ -78,7 +78,7 @@ async function handleLogout() {
|
||||||
|
|
||||||
<!-- Language -->
|
<!-- Language -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.language') }}</h2>
|
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('events.settings.language') }}</h2>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
v-for="lang in languages"
|
v-for="lang in languages"
|
||||||
|
|
@ -9,7 +9,6 @@ const messages: LocaleMessages = {
|
||||||
events: 'Events',
|
events: 'Events',
|
||||||
market: 'Market',
|
market: 'Market',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
activities: 'Activities',
|
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
logout: 'Logout'
|
logout: 'Logout'
|
||||||
},
|
},
|
||||||
|
|
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
|
||||||
de: 'German',
|
de: 'German',
|
||||||
zh: 'Chinese'
|
zh: 'Chinese'
|
||||||
},
|
},
|
||||||
activities: {
|
events: {
|
||||||
title: 'Activities',
|
title: 'Events',
|
||||||
createNew: 'Create Activity',
|
createNew: 'Create Event',
|
||||||
noActivities: 'No activities found',
|
noEvents: 'No events found',
|
||||||
filters: {
|
filters: {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
|
|
@ -135,20 +134,20 @@ const messages: LocaleMessages = {
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: 'Search activities...',
|
placeholder: 'Search events...',
|
||||||
noResults: 'No activities found',
|
noResults: 'No events found',
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: 'Favorites',
|
title: 'Favorites',
|
||||||
loginPrompt: 'Log in to save your favorite activities',
|
loginPrompt: 'Log in to save your favorite events',
|
||||||
empty: 'No favorites yet',
|
empty: 'No favorites yet',
|
||||||
emptyHint: 'Tap the heart icon on any activity to save it here',
|
emptyHint: 'Tap the heart icon on any event to save it here',
|
||||||
logIn: 'Log in',
|
logIn: 'Log in',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
account: 'Account',
|
account: 'Account',
|
||||||
loginPrompt: 'Log in to bookmark activities, RSVP, and purchase tickets.',
|
loginPrompt: 'Log in to bookmark events, RSVP, and purchase tickets.',
|
||||||
logIn: 'Log in',
|
logIn: 'Log in',
|
||||||
logOut: 'Log out',
|
logOut: 'Log out',
|
||||||
appearance: 'Appearance',
|
appearance: 'Appearance',
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ const messages: LocaleMessages = {
|
||||||
events: 'Eventos',
|
events: 'Eventos',
|
||||||
market: 'Mercado',
|
market: 'Mercado',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
activities: 'Actividades',
|
|
||||||
login: 'Iniciar Sesión',
|
login: 'Iniciar Sesión',
|
||||||
logout: 'Cerrar Sesión'
|
logout: 'Cerrar Sesión'
|
||||||
},
|
},
|
||||||
|
|
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
|
||||||
de: 'Alemán',
|
de: 'Alemán',
|
||||||
zh: 'Chino'
|
zh: 'Chino'
|
||||||
},
|
},
|
||||||
activities: {
|
events: {
|
||||||
title: 'Actividades',
|
title: 'Eventos',
|
||||||
createNew: 'Crear actividad',
|
createNew: 'Crear evento',
|
||||||
noActivities: 'No se encontraron actividades',
|
noEvents: 'No se encontraron eventos',
|
||||||
filters: {
|
filters: {
|
||||||
all: 'Todas',
|
all: 'Todas',
|
||||||
today: 'Hoy',
|
today: 'Hoy',
|
||||||
|
|
@ -135,20 +134,20 @@ const messages: LocaleMessages = {
|
||||||
settings: 'Ajustes',
|
settings: 'Ajustes',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: 'Buscar actividades...',
|
placeholder: 'Buscar eventos...',
|
||||||
noResults: 'No se encontraron actividades',
|
noResults: 'No se encontraron eventos',
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: 'Favoritos',
|
title: 'Favoritos',
|
||||||
loginPrompt: 'Inicia sesión para guardar tus actividades favoritas',
|
loginPrompt: 'Inicia sesión para guardar tus eventos favoritos',
|
||||||
empty: 'Aún no tienes favoritos',
|
empty: 'Aún no tienes favoritos',
|
||||||
emptyHint: 'Toca el corazón en cualquier actividad para guardarla aquí',
|
emptyHint: 'Toca el corazón en cualquier evento para guardarlo aquí',
|
||||||
logIn: 'Iniciar sesión',
|
logIn: 'Iniciar sesión',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Ajustes',
|
title: 'Ajustes',
|
||||||
account: 'Cuenta',
|
account: 'Cuenta',
|
||||||
loginPrompt: 'Inicia sesión para guardar actividades, confirmar asistencia y comprar boletos.',
|
loginPrompt: 'Inicia sesión para guardar eventos, confirmar asistencia y comprar boletos.',
|
||||||
logIn: 'Iniciar sesión',
|
logIn: 'Iniciar sesión',
|
||||||
logOut: 'Cerrar sesión',
|
logOut: 'Cerrar sesión',
|
||||||
appearance: 'Apariencia',
|
appearance: 'Apariencia',
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ const messages: LocaleMessages = {
|
||||||
events: 'Événements',
|
events: 'Événements',
|
||||||
market: 'Marché',
|
market: 'Marché',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
activities: 'Activités',
|
|
||||||
login: 'Connexion',
|
login: 'Connexion',
|
||||||
logout: 'Déconnexion'
|
logout: 'Déconnexion'
|
||||||
},
|
},
|
||||||
|
|
@ -55,10 +54,10 @@ const messages: LocaleMessages = {
|
||||||
de: 'Allemand',
|
de: 'Allemand',
|
||||||
zh: 'Chinois'
|
zh: 'Chinois'
|
||||||
},
|
},
|
||||||
activities: {
|
events: {
|
||||||
title: 'Activités',
|
title: 'Événements',
|
||||||
createNew: 'Créer une activité',
|
createNew: 'Créer un événement',
|
||||||
noActivities: 'Aucune activité trouvée',
|
noEvents: 'Aucun événement trouvé',
|
||||||
filters: {
|
filters: {
|
||||||
all: 'Tout',
|
all: 'Tout',
|
||||||
today: "Aujourd'hui",
|
today: "Aujourd'hui",
|
||||||
|
|
@ -135,20 +134,20 @@ const messages: LocaleMessages = {
|
||||||
settings: 'Réglages',
|
settings: 'Réglages',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: 'Rechercher des activités...',
|
placeholder: 'Rechercher des événements...',
|
||||||
noResults: 'Aucune activité trouvée',
|
noResults: 'Aucun événement trouvé',
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: 'Favoris',
|
title: 'Favoris',
|
||||||
loginPrompt: 'Connectez-vous pour sauvegarder vos activités préférées',
|
loginPrompt: 'Connectez-vous pour sauvegarder vos événements préférés',
|
||||||
empty: 'Pas encore de favoris',
|
empty: 'Pas encore de favoris',
|
||||||
emptyHint: "Appuyez sur le cœur d'une activité pour la sauvegarder ici",
|
emptyHint: "Appuyez sur le cœur d'un événement pour le sauvegarder ici",
|
||||||
logIn: 'Se connecter',
|
logIn: 'Se connecter',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Réglages',
|
title: 'Réglages',
|
||||||
account: 'Compte',
|
account: 'Compte',
|
||||||
loginPrompt: 'Connectez-vous pour sauvegarder des activités, confirmer votre présence et acheter des billets.',
|
loginPrompt: 'Connectez-vous pour sauvegarder des événements, confirmer votre présence et acheter des billets.',
|
||||||
logIn: 'Se connecter',
|
logIn: 'Se connecter',
|
||||||
logOut: 'Se déconnecter',
|
logOut: 'Se déconnecter',
|
||||||
appearance: 'Apparence',
|
appearance: 'Apparence',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ export interface LocaleMessages {
|
||||||
events: string
|
events: string
|
||||||
market: string
|
market: string
|
||||||
chat: string
|
chat: string
|
||||||
activities: string
|
|
||||||
login: string
|
login: string
|
||||||
logout: string
|
logout: string
|
||||||
}
|
}
|
||||||
|
|
@ -55,11 +54,11 @@ export interface LocaleMessages {
|
||||||
de: string
|
de: string
|
||||||
zh: string
|
zh: string
|
||||||
}
|
}
|
||||||
// Activities module
|
// Events module
|
||||||
activities?: {
|
events?: {
|
||||||
title: string
|
title: string
|
||||||
createNew: string
|
createNew: string
|
||||||
noActivities: string
|
noEvents: string
|
||||||
filters: {
|
filters: {
|
||||||
all: string
|
all: string
|
||||||
today: string
|
today: string
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export function installStrictAuthGuard(router: Router): void {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lenient guard — only routes with meta.requiresAuth === true require auth.
|
* Lenient guard — only routes with meta.requiresAuth === true require auth.
|
||||||
* Used by hub and the public standalones (forum, market, tasks, activities).
|
* Used by hub and the public standalones (forum, market, tasks, events).
|
||||||
*/
|
*/
|
||||||
export function installLenientAuthGuard(router: Router): void {
|
export function installLenientAuthGuard(router: Router): void {
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
|
|
|
||||||
|
|
@ -1,278 +0,0 @@
|
||||||
<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 { TicketApiService } from '../services/TicketApiService'
|
|
||||||
import type { CreateEventRequest } from '../types/ticket'
|
|
||||||
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 ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
|
||||||
if (!ticketApi) {
|
|
||||||
toast.error('Activities service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
|
||||||
if (!invoiceKey) {
|
|
||||||
toast.error('No wallet available. Please log in first.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isPublishing.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Compose ISO 8601 datetime strings the events extension parses.
|
|
||||||
const startIso = `${values.startDate}T${values.startTime}`
|
|
||||||
const endIso =
|
|
||||||
values.endDate && values.endTime
|
|
||||||
? `${values.endDate}T${values.endTime}`
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Fold summary + description into `info` since the events extension
|
|
||||||
// CreateEventRequest has no separate summary field.
|
|
||||||
const info =
|
|
||||||
values.summary && values.description
|
|
||||||
? `${values.summary}\n\n${values.description}`
|
|
||||||
: values.description || values.summary || ''
|
|
||||||
|
|
||||||
// Ticket-less activity — amount_tickets and price_per_ticket both
|
|
||||||
// pinned at 0 (events extension treats 0 as "unlimited / not
|
|
||||||
// ticketed" per models.py:45-46). Server-side `signer.sign_event`
|
|
||||||
// produces the kind-31922 calendar event and publishes via the
|
|
||||||
// operator's configured relays — no webapp signing path needed.
|
|
||||||
const eventData: CreateEventRequest = {
|
|
||||||
name: values.title,
|
|
||||||
info,
|
|
||||||
event_start_date: startIso,
|
|
||||||
event_end_date: endIso,
|
|
||||||
location: location.value || null,
|
|
||||||
banner: values.image || null,
|
|
||||||
categories: selectedCategories.value,
|
|
||||||
amount_tickets: 0,
|
|
||||||
price_per_ticket: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
await ticketApi.createEvent(eventData, invoiceKey)
|
|
||||||
|
|
||||||
// Approval workflow caveat: non-admin users on instances with
|
|
||||||
// `auto_approve=false` (the default) land in the proposal queue;
|
|
||||||
// their event isn't published to relays until an admin approves.
|
|
||||||
// Admins-and-auto-approve-on instances publish immediately.
|
|
||||||
toast.success('Activity created!')
|
|
||||||
emit('created')
|
|
||||||
handleClose()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create activity:', err)
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to create 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>
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<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(100dvh-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>
|
|
||||||
|
|
@ -18,8 +18,8 @@ const router = useRouter()
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
const { isBookmarked, toggleBookmark } = useBookmarks()
|
const { isBookmarked, toggleBookmark } = useBookmarks()
|
||||||
|
|
||||||
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||||
const bookmarked = computed(() => isBookmarked(activityKind.value, props.pubkey, props.dTag))
|
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
|
|
@ -31,7 +31,7 @@ function handleToggle() {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toggleBookmark(activityKind.value, props.pubkey, props.dTag)
|
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -3,22 +3,22 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
import type { ActivityCategory } from '../types/category'
|
import type { EventCategory } from '../types/category'
|
||||||
import { ALL_CATEGORIES } from '../types/category'
|
import { ALL_CATEGORIES } from '../types/category'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
selected: ActivityCategory[]
|
selected: EventCategory[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
toggle: [category: ActivityCategory]
|
toggle: [category: EventCategory]
|
||||||
clear: []
|
clear: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
function categoryLabel(cat: ActivityCategory): string {
|
function categoryLabel(cat: EventCategory): string {
|
||||||
return t(`activities.categories.${cat}`, cat)
|
return t(`events.categories.${cat}`, cat)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import type { ActivityCategory } from '../types/category'
|
import type { EventCategory } from '../types/category'
|
||||||
import { ALL_CATEGORIES } from '../types/category'
|
import { ALL_CATEGORIES } from '../types/category'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: ActivityCategory[]
|
modelValue: EventCategory[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: ActivityCategory[]]
|
'update:modelValue': [value: EventCategory[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
function toggle(cat: ActivityCategory) {
|
function toggle(cat: EventCategory) {
|
||||||
const current = [...props.modelValue]
|
const current = [...props.modelValue]
|
||||||
const idx = current.indexOf(cat)
|
const idx = current.indexOf(cat)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
|
@ -25,8 +25,8 @@ function toggle(cat: ActivityCategory) {
|
||||||
emit('update:modelValue', current)
|
emit('update:modelValue', current)
|
||||||
}
|
}
|
||||||
|
|
||||||
function label(cat: ActivityCategory): string {
|
function label(cat: EventCategory): string {
|
||||||
return t(`activities.categories.${cat}`, cat)
|
return t(`events.categories.${cat}`, cat)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -577,7 +577,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
class="cursor-pointer text-xs capitalize"
|
class="cursor-pointer text-xs capitalize"
|
||||||
@click="toggleCategory(cat)"
|
@click="toggleCategory(cat)"
|
||||||
>
|
>
|
||||||
{{ t(`activities.categories.${cat}`, cat) }}
|
{{ t(`events.categories.${cat}`, cat) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -8,15 +8,15 @@ import {
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activities: Activity[]
|
events: Event[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
selectDate: [date: Date]
|
selectDate: [date: Date]
|
||||||
selectActivity: [activity: Activity]
|
selectEvent: [event: Event]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { dateLocale } = useDateLocale()
|
const { dateLocale } = useDateLocale()
|
||||||
|
|
@ -47,31 +47,31 @@ const calendarDays = computed(() => {
|
||||||
return eachDayOfInterval({ start: calStart, end: calEnd })
|
return eachDayOfInterval({ start: calStart, end: calEnd })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map of date string -> activities on that day
|
// Map of date string -> events on that day
|
||||||
const activityDayMap = computed(() => {
|
const eventDayMap = computed(() => {
|
||||||
const map = new Map<string, Activity[]>()
|
const map = new Map<string, Event[]>()
|
||||||
for (const activity of props.activities) {
|
for (const event of props.events) {
|
||||||
if (!activity.startDate || isNaN(activity.startDate.getTime())) continue
|
if (!event.startDate || isNaN(event.startDate.getTime())) continue
|
||||||
const key = format(activity.startDate, 'yyyy-MM-dd')
|
const key = format(event.startDate, 'yyyy-MM-dd')
|
||||||
if (!map.has(key)) map.set(key, [])
|
if (!map.has(key)) map.set(key, [])
|
||||||
map.get(key)!.push(activity)
|
map.get(key)!.push(event)
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
function getActivitiesForDay(date: Date): Activity[] {
|
function getEventsForDay(date: Date): Event[] {
|
||||||
const key = format(date, 'yyyy-MM-dd')
|
const key = format(date, 'yyyy-MM-dd')
|
||||||
return activityDayMap.value.get(key) ?? []
|
return eventDayMap.value.get(key) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDotCount(date: Date): number {
|
function getDotCount(date: Date): number {
|
||||||
return Math.min(getActivitiesForDay(date).length, 3)
|
return Math.min(getEventsForDay(date).length, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDay = ref<Date | null>(null)
|
const selectedDay = ref<Date | null>(null)
|
||||||
const selectedDayActivities = computed(() => {
|
const selectedDayEvents = computed(() => {
|
||||||
if (!selectedDay.value) return []
|
if (!selectedDay.value) return []
|
||||||
return getActivitiesForDay(selectedDay.value)
|
return getEventsForDay(selectedDay.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectDay(date: Date) {
|
function selectDay(date: Date) {
|
||||||
|
|
@ -133,7 +133,7 @@ function nextMonth() {
|
||||||
@click="selectDay(date)"
|
@click="selectDay(date)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ format(date, 'd') }}</span>
|
<span class="text-sm">{{ format(date, 'd') }}</span>
|
||||||
<!-- Activity dots -->
|
<!-- Event dots -->
|
||||||
<div v-if="getDotCount(date) > 0" class="flex gap-0.5 mt-0.5">
|
<div v-if="getDotCount(date) > 0" class="flex gap-0.5 mt-0.5">
|
||||||
<div
|
<div
|
||||||
v-for="i in getDotCount(date)"
|
v-for="i in getDotCount(date)"
|
||||||
|
|
@ -145,36 +145,36 @@ function nextMonth() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selected day activities -->
|
<!-- Selected day events -->
|
||||||
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
||||||
<h3 class="text-sm font-medium text-muted-foreground">
|
<h3 class="text-sm font-medium text-muted-foreground">
|
||||||
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
|
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
|
||||||
<span v-if="selectedDayActivities.length > 0" class="ml-1">
|
<span v-if="selectedDayEvents.length > 0" class="ml-1">
|
||||||
({{ selectedDayActivities.length }})
|
({{ selectedDayEvents.length }})
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div v-if="selectedDayActivities.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
|
<div v-if="selectedDayEvents.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
|
||||||
No activities on this day
|
No events on this day
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="activity in selectedDayActivities"
|
v-for="event in selectedDayEvents"
|
||||||
:key="activity.nostrEventId"
|
:key="event.nostrEventId"
|
||||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-muted cursor-pointer"
|
class="flex items-center gap-3 p-2 rounded-lg hover:bg-muted cursor-pointer"
|
||||||
@click="emit('selectActivity', activity)"
|
@click="emit('selectEvent', event)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="activity.image"
|
v-if="event.image"
|
||||||
:src="activity.image"
|
:src="event.image"
|
||||||
:alt="activity.title"
|
:alt="event.title"
|
||||||
class="w-12 h-12 rounded object-cover shrink-0"
|
class="w-12 h-12 rounded object-cover shrink-0"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
|
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
{{ activity.type === 'time' ? format(activity.startDate, 'HH:mm') : '' }}
|
{{ event.type === 'time' ? format(event.startDate, 'HH:mm') : '' }}
|
||||||
{{ activity.location ? `· ${activity.location}` : '' }}
|
{{ event.location ? `· ${event.location}` : '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -8,24 +8,24 @@ import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vu
|
||||||
import BookmarkButton from './BookmarkButton.vue'
|
import BookmarkButton from './BookmarkButton.vue'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activity: Activity
|
event: Event
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
click: [activity: Activity]
|
click: [event: Event]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { dateLocale } = useDateLocale()
|
const { dateLocale } = useDateLocale()
|
||||||
const { paidCount } = useOwnedTickets()
|
const { paidCount } = useOwnedTickets()
|
||||||
|
|
||||||
const ownedCount = computed(() => paidCount(props.activity.id))
|
const ownedCount = computed(() => paidCount(props.event.id))
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
const a = props.activity
|
const a = props.event
|
||||||
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
|
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
|
||||||
try {
|
try {
|
||||||
const opts = { locale: dateLocale.value }
|
const opts = { locale: dateLocale.value }
|
||||||
|
|
@ -41,26 +41,26 @@ const dateDisplay = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const categoryLabel = computed(() => {
|
const categoryLabel = computed(() => {
|
||||||
if (!props.activity.category) return null
|
if (!props.event.category) return null
|
||||||
return t(`activities.categories.${props.activity.category}`, props.activity.category)
|
return t(`events.categories.${props.event.category}`, props.event.category)
|
||||||
})
|
})
|
||||||
|
|
||||||
const priceDisplay = computed(() => {
|
const priceDisplay = computed(() => {
|
||||||
const info = props.activity.ticketInfo
|
const info = props.event.ticketInfo
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
if (info.price === 0) return t('activities.detail.free')
|
if (info.price === 0) return t('events.detail.free')
|
||||||
return `${info.price} ${info.currency}`
|
return `${info.price} ${info.currency}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const placeholderBg = computed(() => {
|
const placeholderBg = computed(() => {
|
||||||
// Generate a consistent hue from the activity title
|
// Generate a consistent hue from the event title
|
||||||
const hash = props.activity.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
const hash = props.event.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||||
const hue = hash % 360
|
const hue = hash % 360
|
||||||
return `hsl(${hue}, 40%, 85%)`
|
return `hsl(${hue}, 40%, 85%)`
|
||||||
})
|
})
|
||||||
|
|
||||||
const isPast = computed(() => {
|
const isPast = computed(() => {
|
||||||
const a = props.activity
|
const a = props.event
|
||||||
const end = a.endDate ?? a.startDate
|
const end = a.endDate ?? a.startDate
|
||||||
if (!end || isNaN(end.getTime())) return false
|
if (!end || isNaN(end.getTime())) return false
|
||||||
return end.getTime() < Date.now()
|
return end.getTime() < Date.now()
|
||||||
|
|
@ -70,14 +70,14 @@ const isPast = computed(() => {
|
||||||
<template>
|
<template>
|
||||||
<Card
|
<Card
|
||||||
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
|
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
|
||||||
@click="emit('click', activity)"
|
@click="emit('click', event)"
|
||||||
>
|
>
|
||||||
<!-- Image / Placeholder -->
|
<!-- Image / Placeholder -->
|
||||||
<div class="relative aspect-[16/9] overflow-hidden">
|
<div class="relative aspect-[16/9] overflow-hidden">
|
||||||
<img
|
<img
|
||||||
v-if="activity.image"
|
v-if="event.image"
|
||||||
:src="activity.image"
|
:src="event.image"
|
||||||
:alt="activity.title"
|
:alt="event.title"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
@ -101,7 +101,7 @@ const isPast = computed(() => {
|
||||||
<!-- Ownership badge — the creator can spot their own events at a
|
<!-- Ownership badge — the creator can spot their own events at a
|
||||||
glance on the feed. -->
|
glance on the feed. -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="activity.isMine"
|
v-if="event.isMine"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||||
>
|
>
|
||||||
|
|
@ -118,18 +118,18 @@ const isPast = computed(() => {
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<!-- Pending/rejected overlay for the creator's own non-approved
|
<!-- Pending/rejected overlay for the creator's own non-approved
|
||||||
drafts. Only present when the activity originated from a
|
drafts. Only present when the event originated from a
|
||||||
local LNbits event (Nostr-sourced activities have no
|
local LNbits event (Nostr-sourced events have no
|
||||||
lnbitsStatus). -->
|
lnbitsStatus). -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
|
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||||
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||||
class="absolute bottom-2 left-2 text-xs capitalize"
|
class="absolute bottom-2 left-2 text-xs capitalize"
|
||||||
>
|
>
|
||||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<!-- Past badge — shown when the activity has already ended.
|
<!-- Past badge — shown when the event has already ended.
|
||||||
Only relevant on the feed when the "Past events" filter
|
Only relevant on the feed when the "Past events" filter
|
||||||
chip is toggled on (otherwise these cards aren't rendered);
|
chip is toggled on (otherwise these cards aren't rendered);
|
||||||
on the detail page the card view isn't used. Suppressed
|
on the detail page the card view isn't used. Suppressed
|
||||||
|
|
@ -137,12 +137,12 @@ const isPast = computed(() => {
|
||||||
slot — that case is the creator's own past draft, which is
|
slot — that case is the creator's own past draft, which is
|
||||||
vanishingly rare and the status hint is more actionable. -->
|
vanishingly rare and the status hint is more actionable. -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
|
v-if="isPast && !(event.lnbitsStatus && event.lnbitsStatus !== 'approved')"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||||
>
|
>
|
||||||
<History class="w-3 h-3" />
|
<History class="w-3 h-3" />
|
||||||
{{ t('activities.filters.past', 'Past') }}
|
{{ t('events.filters.past', 'Past') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -150,20 +150,20 @@ const isPast = computed(() => {
|
||||||
<!-- Title + Bookmark -->
|
<!-- Title + Bookmark -->
|
||||||
<div class="flex items-start gap-1">
|
<div class="flex items-start gap-1">
|
||||||
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
|
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
|
||||||
{{ activity.title }}
|
{{ event.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<BookmarkButton
|
<BookmarkButton
|
||||||
:pubkey="activity.organizer.pubkey"
|
:pubkey="event.organizer.pubkey"
|
||||||
:d-tag="activity.id"
|
:d-tag="event.id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary -->
|
<!-- Summary -->
|
||||||
<p
|
<p
|
||||||
v-if="activity.summary"
|
v-if="event.summary"
|
||||||
class="text-sm text-muted-foreground line-clamp-2"
|
class="text-sm text-muted-foreground line-clamp-2"
|
||||||
>
|
>
|
||||||
{{ activity.summary }}
|
{{ event.summary }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-auto space-y-1.5 pt-2">
|
<div class="mt-auto space-y-1.5 pt-2">
|
||||||
|
|
@ -175,34 +175,34 @@ const isPast = computed(() => {
|
||||||
|
|
||||||
<!-- Location -->
|
<!-- Location -->
|
||||||
<div
|
<div
|
||||||
v-if="activity.location"
|
v-if="event.location"
|
||||||
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
<MapPin class="w-3.5 h-3.5 shrink-0" />
|
<MapPin class="w-3.5 h-3.5 shrink-0" />
|
||||||
<span class="truncate">{{ activity.location }}</span>
|
<span class="truncate">{{ event.location }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tickets available. `available === undefined` means
|
<!-- Tickets available. `available === undefined` means
|
||||||
unlimited capacity (no `tickets_available` tag was
|
unlimited capacity (no `tickets_available` tag was
|
||||||
published); show the price-only line in that case. -->
|
published); show the price-only line in that case. -->
|
||||||
<div
|
<div
|
||||||
v-if="activity.ticketInfo"
|
v-if="event.ticketInfo"
|
||||||
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Ticket class="w-3.5 h-3.5 shrink-0" />
|
<Ticket class="w-3.5 h-3.5 shrink-0" />
|
||||||
<span v-if="activity.ticketInfo.available === undefined">
|
<span v-if="event.ticketInfo.available === undefined">
|
||||||
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="activity.ticketInfo.available > 0">
|
<span v-else-if="event.ticketInfo.available > 0">
|
||||||
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
|
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-destructive font-medium">
|
<span v-else class="text-destructive font-medium">
|
||||||
{{ t('activities.detail.soldOut') }}
|
{{ t('events.detail.soldOut') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Owned tickets — shown when the current user holds at
|
<!-- Owned tickets — shown when the current user holds at
|
||||||
least one paid ticket for this activity. Sits next to
|
least one paid ticket for this event. Sits next to
|
||||||
the availability line so the buyer can see at a glance
|
the availability line so the buyer can see at a glance
|
||||||
whether they've already bought in. -->
|
whether they've already bought in. -->
|
||||||
<div
|
<div
|
||||||
|
|
@ -211,7 +211,7 @@ const isPast = computed(() => {
|
||||||
>
|
>
|
||||||
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
|
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
|
{{ t('events.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { CalendarSearch } from 'lucide-vue-next'
|
import { CalendarSearch } from 'lucide-vue-next'
|
||||||
import ActivityCard from './ActivityCard.vue'
|
import EventCard from './EventCard.vue'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
activities: Activity[]
|
events: Event[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [activity: Activity]
|
select: [event: Event]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
@ -35,25 +35,25 @@ const { t } = useI18n()
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div
|
<div
|
||||||
v-else-if="activities.length === 0"
|
v-else-if="events.length === 0"
|
||||||
class="flex flex-col items-center justify-center py-16 text-center"
|
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" />
|
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||||
<h3 class="text-lg font-medium text-foreground mb-1">
|
<h3 class="text-lg font-medium text-foreground mb-1">
|
||||||
{{ t('activities.noActivities') }}
|
{{ t('events.noEvents') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{{ t('activities.search.noResults') }}
|
{{ t('events.search.noResults') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity grid -->
|
<!-- Event grid -->
|
||||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<ActivityCard
|
<EventCard
|
||||||
v-for="activity in activities"
|
v-for="event in events"
|
||||||
:key="activity.nostrEventId"
|
:key="event.nostrEventId"
|
||||||
:activity="activity"
|
:event="event"
|
||||||
@click="emit('select', activity)"
|
@click="emit('select', event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -3,10 +3,10 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activities: Activity[]
|
events: Event[]
|
||||||
center?: { lat: number; lng: number }
|
center?: { lat: number; lng: number }
|
||||||
zoom?: number
|
zoom?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -54,19 +54,19 @@ function updateMarkers() {
|
||||||
|
|
||||||
markerGroup.clearLayers()
|
markerGroup.clearLayers()
|
||||||
|
|
||||||
const geoActivities = props.activities.filter(a => a.coordinates)
|
const geoEvents = props.events.filter(a => a.coordinates)
|
||||||
|
|
||||||
for (const activity of geoActivities) {
|
for (const event of geoEvents) {
|
||||||
const { lat, lng } = activity.coordinates!
|
const { lat, lng } = event.coordinates!
|
||||||
|
|
||||||
const marker = L.marker([lat, lng], { icon: defaultIcon })
|
const marker = L.marker([lat, lng], { icon: defaultIcon })
|
||||||
|
|
||||||
const popupContent = `
|
const popupContent = `
|
||||||
<div style="min-width: 200px; cursor: pointer;" class="activity-popup">
|
<div style="min-width: 200px; cursor: pointer;" class="event-popup">
|
||||||
${activity.image ? `<img src="${activity.image}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;" />` : ''}
|
${event.image ? `<img src="${event.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>
|
<div style="font-weight: 600; font-size: 14px; margin-bottom: 4px;">${escapeHtml(event.title)}</div>
|
||||||
${activity.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(activity.location)}</div>` : ''}
|
${event.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(event.location)}</div>` : ''}
|
||||||
<div style="font-size: 12px; color: #888;">📅 ${activity.startDate.toLocaleDateString()}</div>
|
<div style="font-size: 12px; color: #888;">📅 ${event.startDate.toLocaleDateString()}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -79,10 +79,10 @@ function updateMarkers() {
|
||||||
const popup = marker.getPopup()
|
const popup = marker.getPopup()
|
||||||
if (popup) {
|
if (popup) {
|
||||||
const el = popup.getElement()
|
const el = popup.getElement()
|
||||||
const content = el?.querySelector('.activity-popup')
|
const content = el?.querySelector('.event-popup')
|
||||||
if (content) {
|
if (content) {
|
||||||
(content as HTMLElement).onclick = () => {
|
(content as HTMLElement).onclick = () => {
|
||||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,11 +91,11 @@ function updateMarkers() {
|
||||||
markerGroup.addLayer(marker)
|
markerGroup.addLayer(marker)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fit bounds only on first load, not when new activities stream in
|
// Fit bounds only on first load, not when new events stream in
|
||||||
if (!hasFittedBounds && geoActivities.length > 0) {
|
if (!hasFittedBounds && geoEvents.length > 0) {
|
||||||
hasFittedBounds = true
|
hasFittedBounds = true
|
||||||
const bounds = L.latLngBounds(
|
const bounds = L.latLngBounds(
|
||||||
geoActivities.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
|
geoEvents.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
|
||||||
)
|
)
|
||||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 })
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 })
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +107,7 @@ function escapeHtml(text: string): string {
|
||||||
return div.innerHTML
|
return div.innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.activities, updateMarkers, { deep: true })
|
watch(() => props.events, updateMarkers, { deep: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initMap()
|
initMap()
|
||||||
|
|
@ -7,14 +7,14 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activities: Activity[]
|
events: Event[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [activity: Activity]
|
select: [event: Event]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
@ -22,7 +22,7 @@ const { dateLocale } = useDateLocale()
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const searchOptions: FuzzySearchOptions<Activity> = {
|
const searchOptions: FuzzySearchOptions<Event> = {
|
||||||
fuseOptions: {
|
fuseOptions: {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: 'title', weight: 0.5 },
|
{ name: 'title', weight: 0.5 },
|
||||||
|
|
@ -39,7 +39,7 @@ const searchOptions: FuzzySearchOptions<Activity> = {
|
||||||
resultLimit: 8,
|
resultLimit: 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
const activitiesRef = computed(() => props.activities)
|
const eventsRef = computed(() => props.events)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
|
@ -47,26 +47,26 @@ const {
|
||||||
isSearching,
|
isSearching,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
} = useFuzzySearch(activitiesRef, searchOptions)
|
} = useFuzzySearch(eventsRef, searchOptions)
|
||||||
|
|
||||||
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||||
const showNoResults = 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 {
|
function formatDate(event: Event): string {
|
||||||
if (!activity.startDate || isNaN(activity.startDate.getTime())) return ''
|
if (!event.startDate || isNaN(event.startDate.getTime())) return ''
|
||||||
try {
|
try {
|
||||||
const opts = { locale: dateLocale.value }
|
const opts = { locale: dateLocale.value }
|
||||||
if (activity.type === 'date') return format(activity.startDate, 'MMM d', opts)
|
if (event.type === 'date') return format(event.startDate, 'MMM d', opts)
|
||||||
return format(activity.startDate, 'MMM d · HH:mm', opts)
|
return format(event.startDate, 'MMM d · HH:mm', opts)
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(activity: Activity) {
|
function handleSelect(event: Event) {
|
||||||
clearSearch()
|
clearSearch()
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
emit('select', activity)
|
emit('select', event)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClear() {
|
function handleClear() {
|
||||||
|
|
@ -110,7 +110,7 @@ watch(isOpen, (open) => {
|
||||||
:model-value="searchQuery"
|
:model-value="searchQuery"
|
||||||
@update:model-value="handleInput"
|
@update:model-value="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
:placeholder="t('activities.search.placeholder')"
|
:placeholder="t('events.search.placeholder')"
|
||||||
class="pl-9 pr-9"
|
class="pl-9 pr-9"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -131,21 +131,21 @@ watch(isOpen, (open) => {
|
||||||
>
|
>
|
||||||
<!-- No results -->
|
<!-- No results -->
|
||||||
<div v-if="showNoResults" class="p-4 text-sm text-muted-foreground text-center">
|
<div v-if="showNoResults" class="p-4 text-sm text-muted-foreground text-center">
|
||||||
{{ t('activities.search.noResults') }}
|
{{ t('events.search.noResults') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Result items -->
|
<!-- Result items -->
|
||||||
<button
|
<button
|
||||||
v-for="activity in filteredItems"
|
v-for="event in filteredItems"
|
||||||
:key="activity.nostrEventId"
|
:key="event.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"
|
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)"
|
@click="handleSelect(event)"
|
||||||
>
|
>
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<img
|
<img
|
||||||
v-if="activity.image"
|
v-if="event.image"
|
||||||
:src="activity.image"
|
:src="event.image"
|
||||||
:alt="activity.title"
|
:alt="event.title"
|
||||||
class="w-10 h-10 rounded object-cover shrink-0"
|
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">
|
<div v-else class="w-10 h-10 rounded bg-muted flex items-center justify-center shrink-0">
|
||||||
|
|
@ -154,12 +154,12 @@ watch(isOpen, (open) => {
|
||||||
|
|
||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
|
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
|
||||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
<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="formatDate(event)" class="truncate">{{ formatDate(event) }}</span>
|
||||||
<span v-if="activity.location" class="flex items-center gap-0.5 truncate">
|
<span v-if="event.location" class="flex items-center gap-0.5 truncate">
|
||||||
<MapPin class="w-2.5 h-2.5 shrink-0" />
|
<MapPin class="w-2.5 h-2.5 shrink-0" />
|
||||||
{{ activity.location }}
|
{{ event.location }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -20,15 +20,15 @@ const { t } = useI18n()
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP()
|
const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP()
|
||||||
|
|
||||||
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||||
const myStatus = computed(() => getMyRSVP(activityKind.value, props.pubkey, props.dTag))
|
const myStatus = computed(() => getMyRSVP(eventKind.value, props.pubkey, props.dTag))
|
||||||
const goingCount = computed(() => getRSVPCount(activityKind.value, props.pubkey, props.dTag))
|
const goingCount = computed(() => getRSVPCount(eventKind.value, props.pubkey, props.dTag))
|
||||||
const pending = computed(() => isPending(activityKind.value, props.pubkey, props.dTag))
|
const pending = computed(() => isPending(eventKind.value, props.pubkey, props.dTag))
|
||||||
|
|
||||||
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
|
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
|
||||||
{ status: 'accepted', labelKey: 'activities.detail.going', icon: Check },
|
{ status: 'accepted', labelKey: 'events.detail.going', icon: Check },
|
||||||
{ status: 'tentative', labelKey: 'activities.detail.maybe', icon: HelpCircle },
|
{ status: 'tentative', labelKey: 'events.detail.maybe', icon: HelpCircle },
|
||||||
{ status: 'declined', labelKey: 'activities.detail.notGoing', icon: X },
|
{ status: 'declined', labelKey: 'events.detail.notGoing', icon: X },
|
||||||
]
|
]
|
||||||
|
|
||||||
const statusLabel: Record<RSVPStatus, string> = {
|
const statusLabel: Record<RSVPStatus, string> = {
|
||||||
|
|
@ -47,7 +47,7 @@ async function handleClick(status: RSVPStatus) {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const published = await setRSVP(activityKind.value, props.pubkey, props.dTag, status)
|
const published = await setRSVP(eventKind.value, props.pubkey, props.dTag, status)
|
||||||
if (published) {
|
if (published) {
|
||||||
toast.success(statusLabel[published])
|
toast.success(statusLabel[published])
|
||||||
} else if (!pending.value) {
|
} else if (!pending.value) {
|
||||||
|
|
@ -14,11 +14,11 @@ const emit = defineEmits<{
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const options: { value: TemporalFilter; labelKey: string }[] = [
|
const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
{ value: 'all', labelKey: 'activities.filters.all' },
|
{ value: 'all', labelKey: 'events.filters.all' },
|
||||||
{ value: 'today', labelKey: 'activities.filters.today' },
|
{ value: 'today', labelKey: 'events.filters.today' },
|
||||||
{ value: 'tomorrow', labelKey: 'activities.filters.tomorrow' },
|
{ value: 'tomorrow', labelKey: 'events.filters.tomorrow' },
|
||||||
{ value: 'this-week', labelKey: 'activities.filters.thisWeek' },
|
{ value: 'this-week', labelKey: 'events.filters.thisWeek' },
|
||||||
{ value: 'this-month', labelKey: 'activities.filters.thisMonth' },
|
{ value: 'this-month', labelKey: 'events.filters.thisMonth' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ import type { TicketApiService } from '../services/TicketApiService'
|
||||||
* when in doubt). Probe re-runs whenever auth flips to authenticated.
|
* when in doubt). Probe re-runs whenever auth flips to authenticated.
|
||||||
*
|
*
|
||||||
* Used by every surface that opens the edit-mode CreateEventDialog
|
* Used by every surface that opens the edit-mode CreateEventDialog
|
||||||
* (activities-app/App.vue shell mount, activities EventsPage). Keeps
|
* (events-app/App.vue shell mount, events EventsPage). Keeps
|
||||||
* the probe logic single-source-of-truth.
|
* the probe logic single-source-of-truth.
|
||||||
*/
|
*/
|
||||||
export function useApprovalState() {
|
export function useApprovalState() {
|
||||||
|
|
@ -5,7 +5,7 @@ import { useAuth } from '@/composables/useAuthService'
|
||||||
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-51 Bookmarks (kind 10003) for saving favorite activities.
|
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
|
||||||
*
|
*
|
||||||
* Stores references to NIP-52 calendar events as 'a' tags:
|
* Stores references to NIP-52 calendar events as 'a' tags:
|
||||||
* ['a', '<kind>:<pubkey>:<d-tag>']
|
* ['a', '<kind>:<pubkey>:<d-tag>']
|
||||||
|
|
@ -17,7 +17,7 @@ import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||||
const BOOKMARK_KIND = 10003
|
const BOOKMARK_KIND = 10003
|
||||||
|
|
||||||
interface BookmarkState {
|
interface BookmarkState {
|
||||||
/** Set of bookmarked activity coordinates: "kind:pubkey:d-tag" */
|
/** Set of bookmarked event coordinates: "kind:pubkey:d-tag" */
|
||||||
bookmarkedCoords: Set<string>
|
bookmarkedCoords: Set<string>
|
||||||
/** The latest bookmark event we've seen */
|
/** The latest bookmark event we've seen */
|
||||||
lastEventId: string | null
|
lastEventId: string | null
|
||||||
|
|
@ -36,8 +36,8 @@ export function useBookmarks() {
|
||||||
|
|
||||||
const bookmarkedIds = computed(() => state.value.bookmarkedCoords)
|
const bookmarkedIds = computed(() => state.value.bookmarkedCoords)
|
||||||
|
|
||||||
function isBookmarked(activityKind: number, pubkey: string, dTag: string): boolean {
|
function isBookmarked(eventKind: number, pubkey: string, dTag: string): boolean {
|
||||||
return state.value.bookmarkedCoords.has(`${activityKind}:${pubkey}:${dTag}`)
|
return state.value.bookmarkedCoords.has(`${eventKind}:${pubkey}:${dTag}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBookmarkedByDTag(dTag: string): boolean {
|
function isBookmarkedByDTag(dTag: string): boolean {
|
||||||
|
|
@ -87,12 +87,12 @@ export function useBookmarks() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list.
|
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
|
||||||
*/
|
*/
|
||||||
async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) {
|
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
|
||||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
||||||
|
|
||||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||||
const newCoords = new Set(state.value.bookmarkedCoords)
|
const newCoords = new Set(state.value.bookmarkedCoords)
|
||||||
|
|
||||||
if (newCoords.has(coord)) {
|
if (newCoords.has(coord)) {
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
import type { EventsNostrService } from '../services/EventsNostrService'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useEventsStore } from '../stores/events'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for loading a single activity by its d-tag identifier.
|
* Composable for loading a single event by its d-tag identifier.
|
||||||
* First checks the store cache, then queries relays if not found.
|
* First checks the store cache, then queries relays if not found.
|
||||||
*/
|
*/
|
||||||
export function useActivityDetail(activityId: string) {
|
export function useEventDetail(eventId: string) {
|
||||||
const store = useActivitiesStore()
|
const store = useEventsStore()
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
const activity = computed<Activity | undefined>(() =>
|
const event = computed<Event | undefined>(() =>
|
||||||
store.getActivityById(activityId)
|
store.getEventById(eventId)
|
||||||
)
|
)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
// Already in cache
|
// Already in cache
|
||||||
if (activity.value) return
|
if (event.value) return
|
||||||
|
|
||||||
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||||
if (!nostrService) {
|
if (!nostrService) {
|
||||||
error.value = 'Activities service not available'
|
error.value = 'Events service not available'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,16 +33,16 @@ export function useActivityDetail(activityId: string) {
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
// Scope both the subscription and the one-shot query to this
|
// Scope both the subscription and the one-shot query to this
|
||||||
// activity's d-tag. Without this scope, the query asks every
|
// event's d-tag. Without this scope, the query asks every
|
||||||
// relay for every kind-31922/31923 event and races a 5s timeout
|
// relay for every kind-31922/31923 event and races a 5s timeout
|
||||||
// to find ours — on a cold page refresh that race is often lost
|
// to find ours — on a cold page refresh that race is often lost
|
||||||
// even when the activity is reachable.
|
// even when the event is reachable.
|
||||||
const detailFilters = { dTags: [activityId] }
|
const detailFilters = { dTags: [eventId] }
|
||||||
|
|
||||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||||
(incoming) => {
|
(incoming) => {
|
||||||
store.upsertActivity(incoming)
|
store.upsertEvent(incoming)
|
||||||
if (incoming.id === activityId) {
|
if (incoming.id === eventId) {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -50,17 +50,17 @@ export function useActivityDetail(activityId: string) {
|
||||||
)
|
)
|
||||||
|
|
||||||
const results = await nostrService.queryCalendarEvents(detailFilters)
|
const results = await nostrService.queryCalendarEvents(detailFilters)
|
||||||
store.upsertActivities(results)
|
store.upsertEvents(results)
|
||||||
|
|
||||||
// If we still don't have it after query, stop loading
|
// If we still don't have it after query, stop loading
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
if (!activity.value) {
|
if (!event.value) {
|
||||||
error.value = 'Activity not found'
|
error.value = 'Event not found'
|
||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load activity'
|
error.value = err instanceof Error ? err.message : 'Failed to load event'
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +76,7 @@ export function useActivityDetail(activityId: string) {
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activity,
|
event,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
reload: load,
|
reload: load,
|
||||||
|
|
@ -3,61 +3,61 @@ import {
|
||||||
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
||||||
startOfMonth, endOfMonth, addDays, isSameDay,
|
startOfMonth, endOfMonth, addDays, isSameDay,
|
||||||
} from 'date-fns'
|
} from 'date-fns'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
import type { ActivityCategory } from '../types/category'
|
import type { EventCategory } from '../types/category'
|
||||||
import type { TemporalFilter, ActivityFilters } from '../types/filters'
|
import type { TemporalFilter, EventFilters } from '../types/filters'
|
||||||
import { DEFAULT_FILTERS } from '../types/filters'
|
import { DEFAULT_FILTERS } from '../types/filters'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for managing activity filter state and applying filters reactively.
|
* Composable for managing event filter state and applying filters reactively.
|
||||||
*/
|
*/
|
||||||
export function useActivityFilters() {
|
export function useEventFilters() {
|
||||||
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||||
const selectedCategories = ref<ActivityCategory[]>([])
|
const selectedCategories = ref<EventCategory[]>([])
|
||||||
const selectedDate = ref<Date | undefined>(undefined)
|
const selectedDate = ref<Date | undefined>(undefined)
|
||||||
/**
|
/**
|
||||||
* When true, the feed is narrowed to activities the current user
|
* When true, the feed is narrowed to events the current user
|
||||||
* holds at least one paid ticket for. Crossed with the
|
* holds at least one paid ticket for. Crossed with the
|
||||||
* `ownedActivityIds` set from useOwnedTickets in useActivities
|
* `ownedEventIds` set from useOwnedTickets in useEvents
|
||||||
* (this composable stays free of ticket fetching).
|
* (this composable stays free of ticket fetching).
|
||||||
*/
|
*/
|
||||||
const onlyOwnedTickets = ref(false)
|
const onlyOwnedTickets = ref(false)
|
||||||
/**
|
/**
|
||||||
* When true, the feed is narrowed to activities the current user
|
* When true, the feed is narrowed to events the current user
|
||||||
* is hosting (organizer pubkey matches the signed-in user, or the
|
* is hosting (organizer pubkey matches the signed-in user, or the
|
||||||
* row is a local LNbits draft of theirs). Reads `activity.isMine`
|
* row is a local LNbits draft of theirs). Reads `event.isMine`
|
||||||
* which `useActivities.tagOwnership()` populates.
|
* which `useEvents.tagOwnership()` populates.
|
||||||
*/
|
*/
|
||||||
const onlyHosting = ref(false)
|
const onlyHosting = ref(false)
|
||||||
/**
|
/**
|
||||||
* When false (default), activities that have already ended are
|
* When false (default), events that have already ended are
|
||||||
* hidden from the feed. Toggling on includes them so the user can
|
* hidden from the feed. Toggling on includes them so the user can
|
||||||
* browse past events. The date-picker overrides this — picking a
|
* browse past events. The date-picker overrides this — picking a
|
||||||
* specific past date shows that day's activities regardless,
|
* specific past date shows that day's events regardless,
|
||||||
* mirroring how it overrides the temporal pills.
|
* mirroring how it overrides the temporal pills.
|
||||||
*/
|
*/
|
||||||
const showPast = ref(false)
|
const showPast = ref(false)
|
||||||
|
|
||||||
const filters = computed<ActivityFilters>(() => ({
|
const filters = computed<EventFilters>(() => ({
|
||||||
temporal: temporal.value,
|
temporal: temporal.value,
|
||||||
categories: selectedCategories.value,
|
categories: selectedCategories.value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the current filters to a list of activities.
|
* Apply the current filters to a list of events.
|
||||||
*/
|
*/
|
||||||
function applyFilters(activities: Activity[]): Activity[] {
|
function applyFilters(events: Event[]): Event[] {
|
||||||
let result = activities
|
let result = events
|
||||||
|
|
||||||
// Specific date filter (from DatePickerStrip) takes priority over
|
// Specific date filter (from DatePickerStrip) takes priority over
|
||||||
// temporal. Picking a date also bypasses the past/upcoming split
|
// temporal. Picking a date also bypasses the past/upcoming split
|
||||||
// so the user can browse activities for any day they choose.
|
// so the user can browse events for any day they choose.
|
||||||
if (selectedDate.value) {
|
if (selectedDate.value) {
|
||||||
const dayStart = startOfDay(selectedDate.value)
|
const dayStart = startOfDay(selectedDate.value)
|
||||||
const dayEnd = endOfDay(selectedDate.value)
|
const dayEnd = endOfDay(selectedDate.value)
|
||||||
result = result.filter(a => {
|
result = result.filter(a => {
|
||||||
const activityEnd = a.endDate ?? a.startDate
|
const eventEnd = a.endDate ?? a.startDate
|
||||||
return a.startDate <= dayEnd && activityEnd >= dayStart
|
return a.startDate <= dayEnd && eventEnd >= dayStart
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Temporal filter
|
// Temporal filter
|
||||||
|
|
@ -69,8 +69,8 @@ export function useActivityFilters() {
|
||||||
// showPast=true shows only the days already passed this week.
|
// showPast=true shows only the days already passed this week.
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
result = result.filter(a => {
|
result = result.filter(a => {
|
||||||
const activityEnd = a.endDate ?? a.startDate
|
const eventEnd = a.endDate ?? a.startDate
|
||||||
return showPast.value ? activityEnd < now : activityEnd >= now
|
return showPast.value ? eventEnd < now : eventEnd >= now
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,8 +81,8 @@ export function useActivityFilters() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hosting filter — activities the signed-in user organizes.
|
// Hosting filter — events the signed-in user organizes.
|
||||||
// Read off `activity.isMine` which `useActivities.tagOwnership()`
|
// Read off `event.isMine` which `useEvents.tagOwnership()`
|
||||||
// populates from organizer-pubkey match + LNbits drafts.
|
// populates from organizer-pubkey match + LNbits drafts.
|
||||||
if (onlyHosting.value) {
|
if (onlyHosting.value) {
|
||||||
result = result.filter(a => a.isMine === true)
|
result = result.filter(a => a.isMine === true)
|
||||||
|
|
@ -105,7 +105,7 @@ export function useActivityFilters() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCategory(category: ActivityCategory) {
|
function toggleCategory(category: EventCategory) {
|
||||||
const idx = selectedCategories.value.indexOf(category)
|
const idx = selectedCategories.value.indexOf(category)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
selectedCategories.value.splice(idx, 1)
|
selectedCategories.value.splice(idx, 1)
|
||||||
|
|
@ -174,8 +174,8 @@ export function useActivityFilters() {
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Activity[] {
|
function applyTemporalFilter(events: Event[], filter: TemporalFilter): Event[] {
|
||||||
if (filter === 'all') return activities
|
if (filter === 'all') return events
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
let start: Date
|
let start: Date
|
||||||
|
|
@ -199,12 +199,12 @@ function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Ac
|
||||||
end = endOfMonth(now)
|
end = endOfMonth(now)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
return activities
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities.filter(a => {
|
return events.filter(a => {
|
||||||
const activityEnd = a.endDate ?? a.startDate
|
const eventEnd = a.endDate ?? a.startDate
|
||||||
// Activity overlaps with the filter range
|
// Event overlaps with the filter range
|
||||||
return a.startDate <= end && activityEnd >= start
|
return a.startDate <= end && eventEnd >= start
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
import type { EventsNostrService } from '../services/EventsNostrService'
|
||||||
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
|
import type { CalendarEventFilters } from '../services/EventsNostrService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
import type { TicketedEvent } from '../types/ticket'
|
||||||
import { ticketedEventToActivity } from '../types/activity'
|
import { ticketedEventToEvent } from '../types/event'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useEventsStore } from '../stores/events'
|
||||||
import { useActivityFilters } from './useActivityFilters'
|
import { useEventFilters } from './useEventFilters'
|
||||||
import { useOwnedTickets } from './useOwnedTickets'
|
import { useOwnedTickets } from './useOwnedTickets'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main composable for activities discovery.
|
* Main composable for events discovery.
|
||||||
* Subscribes to NIP-52 events via ActivitiesNostrService and manages the activity feed.
|
* Subscribes to NIP-52 events via EventsNostrService and manages the event feed.
|
||||||
*/
|
*/
|
||||||
export function useActivities() {
|
export function useEvents() {
|
||||||
const store = useActivitiesStore()
|
const store = useEventsStore()
|
||||||
const filters = useActivityFilters()
|
const filters = useEventFilters()
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
const { ownedActivityIds } = useOwnedTickets()
|
const { ownedEventIds } = useOwnedTickets()
|
||||||
|
|
||||||
const isSubscribed = ref(false)
|
const isSubscribed = ref(false)
|
||||||
const subscriptionError = ref<string | null>(null)
|
const subscriptionError = ref<string | null>(null)
|
||||||
|
|
@ -27,27 +27,27 @@ export function useActivities() {
|
||||||
/**
|
/**
|
||||||
* Merge the caller's own LNbits events (any status) into the feed.
|
* Merge the caller's own LNbits events (any status) into the feed.
|
||||||
*
|
*
|
||||||
* The `/activities` feed is Nostr-driven, so an event that hasn't
|
* The `/events` feed is Nostr-driven, so an event that hasn't
|
||||||
* been published yet — typically because it's still `proposed` under
|
* been published yet — typically because it's still `proposed` under
|
||||||
* auto_approve=off — would silently vanish from the creator's view
|
* auto_approve=off — would silently vanish from the creator's view
|
||||||
* until an admin approves it. Pull own events from the events
|
* until an admin approves it. Pull own events from the events
|
||||||
* extension and upsert them as Activities so users see their own
|
* extension and upsert them as Events so users see their own
|
||||||
* drafts with a Pending-review badge.
|
* drafts with a Pending-review badge.
|
||||||
*
|
*
|
||||||
* Once an event is approved and the Nostr relay delivers the kind
|
* Once an event is approved and the Nostr relay delivers the kind
|
||||||
* 31922/31923 event, the relay-sourced Activity has a newer
|
* 31922/31923 event, the relay-sourced Event has a newer
|
||||||
* createdAt and wins on upsert (it lacks `lnbitsStatus`, so the
|
* createdAt and wins on upsert (it lacks `lnbitsStatus`, so the
|
||||||
* badge disappears).
|
* badge disappears).
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Stamp `isMine` on a Nostr-sourced activity when the organizer
|
* Stamp `isMine` on a Nostr-sourced event when the organizer
|
||||||
* pubkey matches the logged-in user's Nostr key. LNbits drafts come
|
* pubkey matches the logged-in user's Nostr key. LNbits drafts come
|
||||||
* pre-tagged via the adapter.
|
* pre-tagged via the adapter.
|
||||||
*/
|
*/
|
||||||
function tagOwnership(activity: { organizer: { pubkey: string }; isMine?: boolean }) {
|
function tagOwnership(event: { organizer: { pubkey: string }; isMine?: boolean }) {
|
||||||
const myPubkey = currentUser.value?.pubkey
|
const myPubkey = currentUser.value?.pubkey
|
||||||
if (myPubkey && activity.organizer.pubkey === myPubkey) {
|
if (myPubkey && event.organizer.pubkey === myPubkey) {
|
||||||
activity.isMine = true
|
event.isMine = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,21 +60,21 @@ export function useActivities() {
|
||||||
try {
|
try {
|
||||||
const mine = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
|
const mine = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
|
||||||
for (const ev of mine) {
|
for (const ev of mine) {
|
||||||
store.upsertActivity(ticketedEventToActivity(ev))
|
store.upsertEvent(ticketedEventToEvent(ev))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[useActivities] loadOwnEvents failed:', err)
|
console.warn('[useEvents] loadOwnEvents failed:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtered and sorted activities (from all activities, filters handle time range)
|
// Filtered and sorted events (from all events, filters handle time range)
|
||||||
const filteredActivities = computed(() => {
|
const filteredEvents = computed(() => {
|
||||||
const all = store.activities.sort(
|
const all = store.events.sort(
|
||||||
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
||||||
)
|
)
|
||||||
const filtered = filters.applyFilters(all)
|
const filtered = filters.applyFilters(all)
|
||||||
if (!filters.onlyOwnedTickets.value) return filtered
|
if (!filters.onlyOwnedTickets.value) return filtered
|
||||||
const owned = ownedActivityIds.value
|
const owned = ownedEventIds.value
|
||||||
return filtered.filter(a => owned.has(a.id))
|
return filtered.filter(a => owned.has(a.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -84,9 +84,9 @@ export function useActivities() {
|
||||||
function subscribe(eventFilters?: CalendarEventFilters) {
|
function subscribe(eventFilters?: CalendarEventFilters) {
|
||||||
if (isSubscribed.value) return
|
if (isSubscribed.value) return
|
||||||
|
|
||||||
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||||
if (!nostrService) {
|
if (!nostrService) {
|
||||||
subscriptionError.value = 'Activities service not available'
|
subscriptionError.value = 'Events service not available'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,9 +95,9 @@ export function useActivities() {
|
||||||
subscriptionError.value = null
|
subscriptionError.value = null
|
||||||
|
|
||||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||||
(activity) => {
|
(event) => {
|
||||||
tagOwnership(activity)
|
tagOwnership(event)
|
||||||
store.upsertActivity(activity)
|
store.upsertEvent(event)
|
||||||
store.isLoading = false
|
store.isLoading = false
|
||||||
},
|
},
|
||||||
eventFilters
|
eventFilters
|
||||||
|
|
@ -123,20 +123,20 @@ export function useActivities() {
|
||||||
* One-shot query for calendar events.
|
* One-shot query for calendar events.
|
||||||
*/
|
*/
|
||||||
async function query(eventFilters?: CalendarEventFilters) {
|
async function query(eventFilters?: CalendarEventFilters) {
|
||||||
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||||
if (!nostrService) {
|
if (!nostrService) {
|
||||||
subscriptionError.value = 'Activities service not available'
|
subscriptionError.value = 'Events service not available'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
store.isLoading = true
|
store.isLoading = true
|
||||||
subscriptionError.value = null
|
subscriptionError.value = null
|
||||||
const activities = await nostrService.queryCalendarEvents(eventFilters)
|
const events = await nostrService.queryCalendarEvents(eventFilters)
|
||||||
for (const a of activities) tagOwnership(a)
|
for (const a of events) tagOwnership(a)
|
||||||
store.upsertActivities(activities)
|
store.upsertEvents(events)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
|
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query events'
|
||||||
} finally {
|
} finally {
|
||||||
store.isLoading = false
|
store.isLoading = false
|
||||||
}
|
}
|
||||||
|
|
@ -169,8 +169,8 @@ export function useActivities() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
activities: filteredActivities,
|
events: filteredEvents,
|
||||||
allActivities: computed(() => store.activities),
|
allEvents: computed(() => store.events),
|
||||||
isLoading: computed(() => store.isLoading),
|
isLoading: computed(() => store.isLoading),
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
error: subscriptionError,
|
error: subscriptionError,
|
||||||
|
|
@ -5,7 +5,7 @@ import { useAuth } from '@/composables/useAuthService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
import type { TicketedEvent } from '../types/ticket'
|
||||||
|
|
||||||
export function useEvents() {
|
export function useMyEvents() {
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function useEvents() {
|
||||||
// can still browse, they just won't see their own pending events.
|
// can still browse, they just won't see their own pending events.
|
||||||
// Log so a flaky probe is debuggable from the console without
|
// Log so a flaky probe is debuggable from the console without
|
||||||
// toast-spamming the user on every transient failure.
|
// toast-spamming the user on every transient failure.
|
||||||
console.warn('[useEvents] fetchMyEvents failed, showing public feed only:', err)
|
console.warn('[useMyEvents] fetchMyEvents failed, showing public feed only:', err)
|
||||||
return publicEvents
|
return publicEvents
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +94,7 @@ export function useOrganizerProfile(pubkey: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch-fetch profiles for multiple pubkeys (for activity cards).
|
* Batch-fetch profiles for multiple pubkeys (for event cards).
|
||||||
*/
|
*/
|
||||||
export function useBatchProfiles() {
|
export function useBatchProfiles() {
|
||||||
function fetchProfiles(pubkeys: string[]) {
|
function fetchProfiles(pubkeys: string[]) {
|
||||||
|
|
@ -2,12 +2,12 @@ import { computed, ref, watch } from 'vue'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { ActivityTicket } from '../types/ticket'
|
import type { EventTicket } from '../types/ticket'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module-level singleton: owned-ticket lookup keyed by activity id
|
* Module-level singleton: owned-ticket lookup keyed by event id
|
||||||
* (== LNbits event id == NIP-52 d-tag, all the same string by
|
* (== LNbits event id == NIP-52 d-tag, all the same string by
|
||||||
* extension contract). Lives at module scope so every <ActivityCard>
|
* extension contract). Lives at module scope so every <EventCard>
|
||||||
* + the detail page + the feed filter share ONE underlying fetch
|
* + the detail page + the feed filter share ONE underlying fetch
|
||||||
* instead of each instance hitting the API.
|
* instead of each instance hitting the API.
|
||||||
*
|
*
|
||||||
|
|
@ -18,7 +18,7 @@ import type { ActivityTicket } from '../types/ticket'
|
||||||
* atomically.
|
* atomically.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const tickets = ref<ActivityTicket[]>([])
|
const tickets = ref<EventTicket[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<Error | null>(null)
|
const error = ref<Error | null>(null)
|
||||||
let hasAutoLoaded = false
|
let hasAutoLoaded = false
|
||||||
|
|
@ -49,36 +49,36 @@ async function fetchTickets(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketsByActivity = computed<Map<string, ActivityTicket[]>>(() => {
|
const ticketsByEvent = computed<Map<string, EventTicket[]>>(() => {
|
||||||
const m = new Map<string, ActivityTicket[]>()
|
const m = new Map<string, EventTicket[]>()
|
||||||
for (const ticket of tickets.value) {
|
for (const ticket of tickets.value) {
|
||||||
const existing = m.get(ticket.activityId)
|
const existing = m.get(ticket.eventId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.push(ticket)
|
existing.push(ticket)
|
||||||
} else {
|
} else {
|
||||||
m.set(ticket.activityId, [ticket])
|
m.set(ticket.eventId, [ticket])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
|
|
||||||
const ownedActivityIds = computed<Set<string>>(() => {
|
const ownedEventIds = computed<Set<string>>(() => {
|
||||||
const s = new Set<string>()
|
const s = new Set<string>()
|
||||||
for (const ticket of tickets.value) {
|
for (const ticket of tickets.value) {
|
||||||
if (ticket.paid) s.add(ticket.activityId)
|
if (ticket.paid) s.add(ticket.eventId)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
|
|
||||||
function getTickets(activityId: string): ActivityTicket[] {
|
function getTickets(eventId: string): EventTicket[] {
|
||||||
return ticketsByActivity.value.get(activityId) ?? []
|
return ticketsByEvent.value.get(eventId) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of paid ticket rows for an activity. With the
|
/** Number of paid ticket rows for an event. With the
|
||||||
* multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2),
|
* multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2),
|
||||||
* this matches the number of attendees / scannable QRs. */
|
* this matches the number of attendees / scannable QRs. */
|
||||||
function paidCount(activityId: string): number {
|
function paidCount(eventId: string): number {
|
||||||
return getTickets(activityId).filter(t => t.paid).length
|
return getTickets(eventId).filter(t => t.paid).length
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOwnedTickets() {
|
export function useOwnedTickets() {
|
||||||
|
|
@ -115,8 +115,8 @@ export function useOwnedTickets() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tickets,
|
tickets,
|
||||||
ticketsByActivity,
|
ticketsByEvent,
|
||||||
ownedActivityIds,
|
ownedEventIds,
|
||||||
getTickets,
|
getTickets,
|
||||||
paidCount,
|
paidCount,
|
||||||
refresh: fetchTickets,
|
refresh: fetchTickets,
|
||||||
|
|
@ -20,11 +20,11 @@ interface RSVPEntry {
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache: activityCoord -> user's own (latest) RSVP entry
|
// Cache: eventCoord -> user's own (latest) RSVP entry
|
||||||
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
||||||
// Cache: activityCoord -> (pubkey -> latest RSVP entry from that pubkey).
|
// Cache: eventCoord -> (pubkey -> latest RSVP entry from that pubkey).
|
||||||
// NIP-52 RSVPs (kind 31925) are replaceable per (kind, pubkey, d-tag), so a
|
// NIP-52 RSVPs (kind 31925) are replaceable per (kind, pubkey, d-tag), so a
|
||||||
// user's earlier RSVP for an activity is superseded by their later one. The
|
// user's earlier RSVP for an event is superseded by their later one. The
|
||||||
// "going" count is derived from this map (count of pubkeys whose *latest*
|
// "going" count is derived from this map (count of pubkeys whose *latest*
|
||||||
// RSVP has status === 'accepted'), not by summing every accepted event seen
|
// RSVP has status === 'accepted'), not by summing every accepted event seen
|
||||||
// — that would double-count replacements and never decrement on flip.
|
// — that would double-count replacements and never decrement on flip.
|
||||||
|
|
@ -51,7 +51,7 @@ function upsertRSVPState(coord: string, pubkey: string, entry: RSVPEntry) {
|
||||||
if (existing && existing.createdAt >= entry.createdAt) return
|
if (existing && existing.createdAt >= entry.createdAt) return
|
||||||
inner.set(pubkey, entry)
|
inner.set(pubkey, entry)
|
||||||
// Re-set on the outer map so the ref's reactive proxy notifies dependents
|
// Re-set on the outer map so the ref's reactive proxy notifies dependents
|
||||||
// (Vue 3's deep reactivity doesn't reach into nested Map values).
|
// (Vue 3's deep reevent doesn't reach into nested Map values).
|
||||||
rsvpStates.value.set(coord, inner)
|
rsvpStates.value.set(coord, inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,19 +60,19 @@ export function useRSVP() {
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user's RSVP status for an activity.
|
* Get the user's RSVP status for an event.
|
||||||
*/
|
*/
|
||||||
function getMyRSVP(activityKind: number, pubkey: string, dTag: string): RSVPStatus | null {
|
function getMyRSVP(eventKind: number, pubkey: string, dTag: string): RSVPStatus | null {
|
||||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||||
return rsvpCache.value.get(coord)?.status ?? null
|
return rsvpCache.value.get(coord)?.status ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RSVP count for an activity = number of pubkeys whose latest RSVP for
|
* RSVP count for an event = number of pubkeys whose latest RSVP for
|
||||||
* this activity has status 'accepted'.
|
* this event has status 'accepted'.
|
||||||
*/
|
*/
|
||||||
function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number {
|
function getRSVPCount(eventKind: number, pubkey: string, dTag: string): number {
|
||||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||||
const inner = rsvpStates.value.get(coord)
|
const inner = rsvpStates.value.get(coord)
|
||||||
if (!inner) return 0
|
if (!inner) return 0
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|
@ -83,7 +83,7 @@ export function useRSVP() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the user's RSVPs and counts for visible activities from relays.
|
* Load the user's RSVPs and counts for visible events from relays.
|
||||||
*/
|
*/
|
||||||
function loadRSVPs() {
|
function loadRSVPs() {
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
|
@ -130,39 +130,39 @@ export function useRSVP() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a publish is currently in flight for the given activity. Bind
|
* Whether a publish is currently in flight for the given event. Bind
|
||||||
* to the RSVP buttons' `:disabled` so users can't queue racing clicks.
|
* to the RSVP buttons' `:disabled` so users can't queue racing clicks.
|
||||||
*/
|
*/
|
||||||
function isPending(activityKind: number, pubkey: string, dTag: string): boolean {
|
function isPending(eventKind: number, pubkey: string, dTag: string): boolean {
|
||||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||||
return pendingCoords.value.has(coord)
|
return pendingCoords.value.has(coord)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish an RSVP for an activity.
|
* Publish an RSVP for an event.
|
||||||
* Clicking the same status again removes the RSVP (publishes 'declined').
|
* Clicking the same status again removes the RSVP (publishes 'declined').
|
||||||
*
|
*
|
||||||
* Returns the status that was published on success, or null if the publish
|
* Returns the status that was published on success, or null if the publish
|
||||||
* was rejected, blocked, or threw — caller should toast accordingly.
|
* was rejected, blocked, or threw — caller should toast accordingly.
|
||||||
*/
|
*/
|
||||||
async function setRSVP(
|
async function setRSVP(
|
||||||
activityKind: number,
|
eventKind: number,
|
||||||
activityPubkey: string,
|
eventPubkey: string,
|
||||||
activityDTag: string,
|
eventDTag: string,
|
||||||
status: RSVPStatus
|
status: RSVPStatus
|
||||||
): Promise<RSVPStatus | null> {
|
): Promise<RSVPStatus | null> {
|
||||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
|
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
|
||||||
|
|
||||||
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
const coord = `${eventKind}:${eventPubkey}:${eventDTag}`
|
||||||
|
|
||||||
// Throttle: refuse a second click while the first is still publishing.
|
// Throttle: refuse a second click while the first is still publishing.
|
||||||
if (pendingCoords.value.has(coord)) return null
|
if (pendingCoords.value.has(coord)) return null
|
||||||
|
|
||||||
// Toggle: if already this status, decline instead.
|
// Toggle: if already this status, decline instead.
|
||||||
const currentStatus = getMyRSVP(activityKind, activityPubkey, activityDTag)
|
const currentStatus = getMyRSVP(eventKind, eventPubkey, eventDTag)
|
||||||
const newStatus = currentStatus === status ? 'declined' : status
|
const newStatus = currentStatus === status ? 'declined' : status
|
||||||
|
|
||||||
const dTag = `rsvp-${activityDTag}`
|
const dTag = `rsvp-${eventDTag}`
|
||||||
|
|
||||||
// Strictly-monotonic created_at per coord so two clicks in the same
|
// Strictly-monotonic created_at per coord so two clicks in the same
|
||||||
// wall-clock second don't both stamp the same timestamp (relays would
|
// wall-clock second don't both stamp the same timestamp (relays would
|
||||||
|
|
@ -181,7 +181,7 @@ export function useRSVP() {
|
||||||
['status', newStatus],
|
['status', newStatus],
|
||||||
['L', 'status'],
|
['L', 'status'],
|
||||||
['l', newStatus, 'status'],
|
['l', newStatus, 'status'],
|
||||||
['p', activityPubkey],
|
['p', eventPubkey],
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ export interface EventStats {
|
||||||
* route via HTTP rather than the kind-21000 nostr-transport RPC
|
* route via HTTP rather than the kind-21000 nostr-transport RPC
|
||||||
* because post-#9 the webapp no longer holds a raw user prvkey.
|
* because post-#9 the webapp no longer holds a raw user prvkey.
|
||||||
*/
|
*/
|
||||||
export function useTicketScanner(activityId: Ref<string>) {
|
export function useTicketScanner(eventId: Ref<string>) {
|
||||||
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
||||||
const { currentUser } = useAuth()
|
const { currentUser } = useAuth()
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
const statsError = ref<string | null>(null)
|
const statsError = ref<string | null>(null)
|
||||||
/** Session-local dedup. Hidden from UI; only guards repeat decodes. */
|
/** Session-local dedup. Hidden from UI; only guards repeat decodes. */
|
||||||
const scanned = useLocalStorage<ScanRecord[]>(
|
const scanned = useLocalStorage<ScanRecord[]>(
|
||||||
() => `activities_scanned_${activityId.value}`,
|
() => `events_scanned_${eventId.value}`,
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStats(): Promise<void> {
|
async function refreshStats(): Promise<void> {
|
||||||
if (!activityId.value) return
|
if (!eventId.value) return
|
||||||
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
|
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
|
||||||
if (!adminKey) {
|
if (!adminKey) {
|
||||||
statsError.value = 'No wallet admin key available'
|
statsError.value = 'No wallet admin key available'
|
||||||
|
|
@ -100,7 +100,7 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
statsLoading.value = true
|
statsLoading.value = true
|
||||||
statsError.value = null
|
statsError.value = null
|
||||||
try {
|
try {
|
||||||
const data = await ticketApi.getEventStats(activityId.value, adminKey)
|
const data = await ticketApi.getEventStats(eventId.value, adminKey)
|
||||||
eventStats.value = {
|
eventStats.value = {
|
||||||
sold: data.sold,
|
sold: data.sold,
|
||||||
registered: data.registered,
|
registered: data.registered,
|
||||||
|
|
@ -3,11 +3,11 @@ import { useAsyncState } from '@vueuse/core'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { ActivityTicket } from '../types/ticket'
|
import type { EventTicket } from '../types/ticket'
|
||||||
|
|
||||||
interface GroupedTickets {
|
interface GroupedTickets {
|
||||||
eventId: string
|
eventId: string
|
||||||
tickets: ActivityTicket[]
|
tickets: EventTicket[]
|
||||||
paidCount: number
|
paidCount: number
|
||||||
pendingCount: number
|
pendingCount: number
|
||||||
registeredCount: number
|
registeredCount: number
|
||||||
|
|
@ -26,7 +26,7 @@ export function useUserTickets() {
|
||||||
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
||||||
return await ticketApi.fetchUserTickets(currentUser.value.id, accessToken)
|
return await ticketApi.fetchUserTickets(currentUser.value.id, accessToken)
|
||||||
},
|
},
|
||||||
[] as ActivityTicket[],
|
[] as EventTicket[],
|
||||||
{
|
{
|
||||||
immediate: false,
|
immediate: false,
|
||||||
resetOnExecute: false,
|
resetOnExecute: false,
|
||||||
|
|
@ -71,7 +71,7 @@ export function useUserTickets() {
|
||||||
const groups = new Map<string, GroupedTickets>()
|
const groups = new Map<string, GroupedTickets>()
|
||||||
|
|
||||||
sortedTickets.value.forEach(ticket => {
|
sortedTickets.value.forEach(ticket => {
|
||||||
const eventKey = ticket.activityId
|
const eventKey = ticket.eventId
|
||||||
if (!groups.has(eventKey)) {
|
if (!groups.has(eventKey)) {
|
||||||
groups.set(eventKey, {
|
groups.set(eventKey, {
|
||||||
eventId: eventKey,
|
eventId: eventKey,
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
|
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
|
||||||
import { SERVICE_TOKENS } from '@/core/di-container'
|
import { SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { ActivitiesNostrService } from './services/ActivitiesNostrService'
|
import { EventsNostrService } from './services/EventsNostrService'
|
||||||
import { TicketApiService, type TicketApiConfig } from './services/TicketApiService'
|
import { TicketApiService, type TicketApiConfig } from './services/TicketApiService'
|
||||||
import PurchaseTicketDialog from './components/PurchaseTicketDialog.vue'
|
import PurchaseTicketDialog from './components/PurchaseTicketDialog.vue'
|
||||||
|
|
||||||
export interface ActivitiesModuleConfig {
|
export interface EventsModuleConfig {
|
||||||
apiConfig: TicketApiConfig
|
apiConfig: TicketApiConfig
|
||||||
defaultMapCenter?: { lat: number; lng: number }
|
defaultMapCenter?: { lat: number; lng: number }
|
||||||
maxTicketsPerUser?: number
|
maxTicketsPerUser?: number
|
||||||
|
|
@ -13,59 +13,59 @@ export interface ActivitiesModuleConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activities Module Plugin
|
* Events Module Plugin
|
||||||
*
|
*
|
||||||
* Nostr-native communal events module using NIP-52 Calendar Events
|
* Nostr-native communal events module using NIP-52 Calendar Events
|
||||||
* for discovery, with database-backed ticketing via LNbits.
|
* for discovery, with database-backed ticketing via LNbits.
|
||||||
*/
|
*/
|
||||||
export const activitiesModule = createModulePlugin({
|
export const eventsModule = createModulePlugin({
|
||||||
name: 'activities',
|
name: 'events',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
dependencies: ['base'],
|
dependencies: ['base'],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/activities',
|
path: '/events',
|
||||||
name: 'activities',
|
name: 'events',
|
||||||
component: () => import('./views/ActivitiesPage.vue'),
|
component: () => import('./views/EventsPage.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Activities',
|
title: (import.meta.env.VITE_APP_NAME as string) || 'Events',
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/activities/calendar',
|
path: '/events/calendar',
|
||||||
name: 'activities-calendar',
|
name: 'events-calendar',
|
||||||
component: () => import('./views/ActivitiesCalendarPage.vue'),
|
component: () => import('./views/EventsCalendarPage.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Calendar',
|
title: 'Calendar',
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/activities/map',
|
path: '/events/map',
|
||||||
name: 'activities-map',
|
name: 'events-map',
|
||||||
component: () => import('./views/ActivitiesMapPage.vue'),
|
component: () => import('./views/EventsMapPage.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Map',
|
title: 'Map',
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/activities/favorites',
|
path: '/events/favorites',
|
||||||
name: 'activities-favorites',
|
name: 'events-favorites',
|
||||||
component: () => import('./views/ActivitiesFavoritesPage.vue'),
|
component: () => import('./views/EventsFavoritesPage.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Favorites',
|
title: 'Favorites',
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/activities/:id',
|
path: '/events/:id',
|
||||||
name: 'activity-detail',
|
name: 'event-detail',
|
||||||
component: () => import('./views/ActivityDetailPage.vue'),
|
component: () => import('./views/EventDetailPage.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Activity',
|
title: 'Event',
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -79,7 +79,7 @@ export const activitiesModule = createModulePlugin({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/scan/:activityId',
|
path: '/scan/:eventId',
|
||||||
name: 'scan-tickets',
|
name: 'scan-tickets',
|
||||||
component: () => import('./views/ScanTicketsPage.vue'),
|
component: () => import('./views/ScanTicketsPage.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
|
@ -88,12 +88,12 @@ export const activitiesModule = createModulePlugin({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/events',
|
path: '/my-events',
|
||||||
name: 'events',
|
name: 'my-events',
|
||||||
component: () => import('./views/EventsPage.vue'),
|
component: () => import('./views/MyEventsPage.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Events',
|
title: 'My Events',
|
||||||
requiresAuth: false,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -106,27 +106,27 @@ export const activitiesModule = createModulePlugin({
|
||||||
{
|
{
|
||||||
event: 'payment:completed',
|
event: 'payment:completed',
|
||||||
handler: (event) => {
|
handler: (event) => {
|
||||||
console.log('Activities module: payment completed', event.data)
|
console.log('Events module: payment completed', event.data)
|
||||||
},
|
},
|
||||||
description: 'Handle payment completion for ticket purchases',
|
description: 'Handle payment completion for ticket purchases',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
onInstall: async (_app, options) => {
|
onInstall: async (_app, options) => {
|
||||||
const config = options?.config as ActivitiesModuleConfig | undefined
|
const config = options?.config as EventsModuleConfig | undefined
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error('Activities module requires configuration')
|
throw new Error('Events module requires configuration')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { container } = await import('@/core/di-container')
|
const { container } = await import('@/core/di-container')
|
||||||
|
|
||||||
// 1. Create services
|
// 1. Create services
|
||||||
const nostrService = new ActivitiesNostrService()
|
const nostrService = new EventsNostrService()
|
||||||
const ticketApi = new TicketApiService(config.apiConfig)
|
const ticketApi = new TicketApiService(config.apiConfig)
|
||||||
|
|
||||||
// 2. Register in DI container BEFORE initialization
|
// 2. Register in DI container BEFORE initialization
|
||||||
container.provide(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE, nostrService)
|
container.provide(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE, nostrService)
|
||||||
container.provide(SERVICE_TOKENS.ACTIVITIES_TICKET_API, ticketApi)
|
container.provide(SERVICE_TOKENS.EVENTS_TICKET_API, ticketApi)
|
||||||
container.provide(SERVICE_TOKENS.TICKET_API, ticketApi)
|
container.provide(SERVICE_TOKENS.TICKET_API, ticketApi)
|
||||||
|
|
||||||
// 3. Initialize the Nostr service (needs RelayHub dependency)
|
// 3. Initialize the Nostr service (needs RelayHub dependency)
|
||||||
|
|
@ -138,16 +138,16 @@ export const activitiesModule = createModulePlugin({
|
||||||
|
|
||||||
onUninstall: async () => {
|
onUninstall: async () => {
|
||||||
const { container } = await import('@/core/di-container')
|
const { container } = await import('@/core/di-container')
|
||||||
container.remove(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
container.remove(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||||
container.remove(SERVICE_TOKENS.ACTIVITIES_TICKET_API)
|
container.remove(SERVICE_TOKENS.EVENTS_TICKET_API)
|
||||||
container.remove(SERVICE_TOKENS.TICKET_API)
|
container.remove(SERVICE_TOKENS.TICKET_API)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default activitiesModule
|
export default eventsModule
|
||||||
|
|
||||||
// Re-export types for external use
|
// Re-export types for external use
|
||||||
export type { Activity, OrganizerInfo, ActivityTicketInfo } from './types/activity'
|
export type { Event, OrganizerInfo, EventTicketInfo } from './types/event'
|
||||||
export type { ActivityTicket, TicketStatus, TicketedEvent, CreateEventRequest } from './types/ticket'
|
export type { EventTicket, TicketStatus, TicketedEvent, CreateEventRequest } from './types/ticket'
|
||||||
export type { ActivityCategory } from './types/category'
|
export type { EventCategory } from './types/category'
|
||||||
export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52'
|
export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52'
|
||||||
|
|
@ -7,10 +7,10 @@ import {
|
||||||
parseCalendarDateEvent,
|
parseCalendarDateEvent,
|
||||||
} from '../types/nip52'
|
} from '../types/nip52'
|
||||||
import {
|
import {
|
||||||
calendarTimeEventToActivity,
|
calendarTimeEventToEvent,
|
||||||
calendarDateEventToActivity,
|
calendarDateEventToEvent,
|
||||||
type Activity,
|
type Event,
|
||||||
} from '../types/activity'
|
} from '../types/event'
|
||||||
|
|
||||||
export interface CalendarEventFilters {
|
export interface CalendarEventFilters {
|
||||||
/** Only return events created after this timestamp */
|
/** Only return events created after this timestamp */
|
||||||
|
|
@ -35,13 +35,13 @@ export interface CalendarEventFilters {
|
||||||
* 66076d6) — `POST /events/api/v1/events` constructs and signs the
|
* 66076d6) — `POST /events/api/v1/events` constructs and signs the
|
||||||
* event via NostrSigner and broadcasts it to the operator's configured
|
* event via NostrSigner and broadcasts it to the operator's configured
|
||||||
* relays. The webapp constructs only the request payload; see
|
* relays. The webapp constructs only the request payload; see
|
||||||
* CreateActivityDialog for the flow.
|
* CreateEventDialog for the flow.
|
||||||
*
|
*
|
||||||
* Extends BaseService for standardized dependency injection and lifecycle.
|
* Extends BaseService for standardized dependency injection and lifecycle.
|
||||||
*/
|
*/
|
||||||
export class ActivitiesNostrService extends BaseService {
|
export class EventsNostrService extends BaseService {
|
||||||
protected readonly metadata = {
|
protected readonly metadata = {
|
||||||
name: 'ActivitiesNostrService',
|
name: 'EventsNostrService',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
dependencies: ['RelayHub'],
|
dependencies: ['RelayHub'],
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +49,7 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
private activeUnsubscribes: Array<() => void> = []
|
private activeUnsubscribes: Array<() => void> = []
|
||||||
|
|
||||||
protected async onInitialize(): Promise<void> {
|
protected async onInitialize(): Promise<void> {
|
||||||
this.debug('ActivitiesNostrService initialized')
|
this.debug('EventsNostrService initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,7 +57,7 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
* Returns an unsubscribe function.
|
* Returns an unsubscribe function.
|
||||||
*/
|
*/
|
||||||
subscribeToCalendarEvents(
|
subscribeToCalendarEvents(
|
||||||
onActivity: (activity: Activity) => void,
|
onEvent: (event: Event) => void,
|
||||||
filters?: CalendarEventFilters
|
filters?: CalendarEventFilters
|
||||||
): () => void {
|
): () => void {
|
||||||
if (!this.relayHub) {
|
if (!this.relayHub) {
|
||||||
|
|
@ -66,15 +66,15 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
|
|
||||||
const nostrFilters = this.buildNostrFilters(filters)
|
const nostrFilters = this.buildNostrFilters(filters)
|
||||||
|
|
||||||
const subscriptionId = `activities-calendar-${Date.now()}`
|
const subscriptionId = `events-calendar-${Date.now()}`
|
||||||
|
|
||||||
const config: SubscriptionConfig = {
|
const config: SubscriptionConfig = {
|
||||||
id: subscriptionId,
|
id: subscriptionId,
|
||||||
filters: nostrFilters,
|
filters: nostrFilters,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (nostrEvent: NostrEvent) => {
|
||||||
const activity = this.parseNostrEventToActivity(event)
|
const event = this.parseNostrEventToEvent(nostrEvent)
|
||||||
if (activity) {
|
if (event) {
|
||||||
onActivity(activity)
|
onEvent(event)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEose: () => {
|
onEose: () => {
|
||||||
|
|
@ -94,29 +94,29 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
/**
|
/**
|
||||||
* Query relays for calendar events (one-shot, not a subscription).
|
* Query relays for calendar events (one-shot, not a subscription).
|
||||||
*/
|
*/
|
||||||
async queryCalendarEvents(filters?: CalendarEventFilters): Promise<Activity[]> {
|
async queryCalendarEvents(filters?: CalendarEventFilters): Promise<Event[]> {
|
||||||
if (!this.relayHub) {
|
if (!this.relayHub) {
|
||||||
throw new Error('RelayHub not available')
|
throw new Error('RelayHub not available')
|
||||||
}
|
}
|
||||||
|
|
||||||
const nostrFilters = this.buildNostrFilters(filters)
|
const nostrFilters = this.buildNostrFilters(filters)
|
||||||
const events: NostrEvent[] = await this.relayHub.queryEvents(nostrFilters)
|
const nostrEvents: NostrEvent[] = await this.relayHub.queryEvents(nostrFilters)
|
||||||
|
|
||||||
const activities: Activity[] = []
|
const events: Event[] = []
|
||||||
for (const event of events) {
|
for (const nostrEvent of nostrEvents) {
|
||||||
const activity = this.parseNostrEventToActivity(event)
|
const event = this.parseNostrEventToEvent(nostrEvent)
|
||||||
if (activity) {
|
if (event) {
|
||||||
activities.push(activity)
|
events.push(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a raw Nostr event into an Activity view model.
|
* Parse a raw Nostr event into an Event view model.
|
||||||
*/
|
*/
|
||||||
private parseNostrEventToActivity(event: NostrEvent): Activity | null {
|
private parseNostrEventToEvent(event: NostrEvent): Event | null {
|
||||||
// Skip task events — they reuse NIP-52 kinds but can be identified by
|
// Skip task events — they reuse NIP-52 kinds but can be identified by
|
||||||
// task-specific tags (event-type:task, status, recurrence)
|
// task-specific tags (event-type:task, status, recurrence)
|
||||||
const tags = event.tags ?? []
|
const tags = event.tags ?? []
|
||||||
|
|
@ -126,12 +126,12 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
|
|
||||||
if (event.kind === NIP52_KINDS.CALENDAR_TIME_EVENT) {
|
if (event.kind === NIP52_KINDS.CALENDAR_TIME_EVENT) {
|
||||||
const parsed = parseCalendarTimeEvent(event)
|
const parsed = parseCalendarTimeEvent(event)
|
||||||
if (parsed) return calendarTimeEventToActivity(parsed)
|
if (parsed) return calendarTimeEventToEvent(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind === NIP52_KINDS.CALENDAR_DATE_EVENT) {
|
if (event.kind === NIP52_KINDS.CALENDAR_DATE_EVENT) {
|
||||||
const parsed = parseCalendarDateEvent(event)
|
const parsed = parseCalendarDateEvent(event)
|
||||||
if (parsed) return calendarDateEventToActivity(parsed)
|
if (parsed) return calendarDateEventToEvent(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
ActivityTicket,
|
EventTicket,
|
||||||
ActivityTicketExtra,
|
EventTicketExtra,
|
||||||
CreateTicketRequest,
|
CreateTicketRequest,
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
TicketPurchaseInvoice,
|
TicketPurchaseInvoice,
|
||||||
|
|
@ -27,7 +27,7 @@ export class TicketApiService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all public events from the LNbits events extension.
|
* Fetch all public events from the LNbits events extension.
|
||||||
* Used to correlate Nostr activities with ticketed events.
|
* Used to correlate Nostr events with ticketed events.
|
||||||
*/
|
*/
|
||||||
async fetchTicketedEvents(): Promise<any[]> {
|
async fetchTicketedEvents(): Promise<any[]> {
|
||||||
const response = await this.request(
|
const response = await this.request(
|
||||||
|
|
@ -133,7 +133,7 @@ export class TicketApiService {
|
||||||
async fetchUserTickets(
|
async fetchUserTickets(
|
||||||
userId: string,
|
userId: string,
|
||||||
accessToken: string
|
accessToken: string
|
||||||
): Promise<ActivityTicket[]> {
|
): Promise<EventTicket[]> {
|
||||||
const data = await this.request(
|
const data = await this.request(
|
||||||
`/events/api/v1/tickets/user/${userId}`,
|
`/events/api/v1/tickets/user/${userId}`,
|
||||||
{
|
{
|
||||||
|
|
@ -147,7 +147,7 @@ export class TicketApiService {
|
||||||
return (data as any[]).map(t => ({
|
return (data as any[]).map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
wallet: t.wallet,
|
wallet: t.wallet,
|
||||||
activityId: t.event,
|
eventId: t.event,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
email: t.email,
|
email: t.email,
|
||||||
userId: t.user_id,
|
userId: t.user_id,
|
||||||
|
|
@ -155,14 +155,14 @@ export class TicketApiService {
|
||||||
paid: t.paid,
|
paid: t.paid,
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as ActivityTicketExtra | undefined,
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate/register a ticket at the door (scan).
|
* Validate/register a ticket at the door (scan).
|
||||||
*/
|
*/
|
||||||
async validateTicket(ticketId: string): Promise<ActivityTicket[]> {
|
async validateTicket(ticketId: string): Promise<EventTicket[]> {
|
||||||
const data = await this.request(
|
const data = await this.request(
|
||||||
`/events/api/v1/register/ticket/${ticketId}`,
|
`/events/api/v1/register/ticket/${ticketId}`,
|
||||||
{ method: 'GET' }
|
{ method: 'GET' }
|
||||||
|
|
@ -171,7 +171,7 @@ export class TicketApiService {
|
||||||
return (data as any[]).map(t => ({
|
return (data as any[]).map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
wallet: t.wallet,
|
wallet: t.wallet,
|
||||||
activityId: t.event,
|
eventId: t.event,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
email: t.email,
|
email: t.email,
|
||||||
userId: t.user_id,
|
userId: t.user_id,
|
||||||
|
|
@ -179,7 +179,7 @@ export class TicketApiService {
|
||||||
paid: t.paid,
|
paid: t.paid,
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as ActivityTicketExtra | undefined,
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +229,7 @@ export class TicketApiService {
|
||||||
async resendTicketEmail(
|
async resendTicketEmail(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
adminKey: string,
|
adminKey: string,
|
||||||
): Promise<ActivityTicket> {
|
): Promise<EventTicket> {
|
||||||
const t = await this.request(
|
const t = await this.request(
|
||||||
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
||||||
{
|
{
|
||||||
|
|
@ -240,7 +240,7 @@ export class TicketApiService {
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
wallet: t.wallet,
|
wallet: t.wallet,
|
||||||
activityId: t.event,
|
eventId: t.event,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
email: t.email,
|
email: t.email,
|
||||||
userId: t.user_id,
|
userId: t.user_id,
|
||||||
|
|
@ -248,7 +248,7 @@ export class TicketApiService {
|
||||||
paid: t.paid,
|
paid: t.paid,
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as ActivityTicketExtra | undefined,
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,7 +286,7 @@ export class TicketApiService {
|
||||||
* unpaid / already-registered / not-owned cases with HTTP errors
|
* unpaid / already-registered / not-owned cases with HTTP errors
|
||||||
* whose `detail` becomes the thrown Error message.
|
* whose `detail` becomes the thrown Error message.
|
||||||
*/
|
*/
|
||||||
async registerTicket(ticketId: string, adminKey: string): Promise<ActivityTicket> {
|
async registerTicket(ticketId: string, adminKey: string): Promise<EventTicket> {
|
||||||
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
|
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'X-API-KEY': adminKey },
|
headers: { 'X-API-KEY': adminKey },
|
||||||
|
|
@ -294,7 +294,7 @@ export class TicketApiService {
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
wallet: t.wallet,
|
wallet: t.wallet,
|
||||||
activityId: t.event,
|
eventId: t.event,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
email: t.email,
|
email: t.email,
|
||||||
userId: t.user_id,
|
userId: t.user_id,
|
||||||
|
|
@ -302,7 +302,7 @@ export class TicketApiService {
|
||||||
paid: t.paid,
|
paid: t.paid,
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as ActivityTicketExtra | undefined,
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,37 +1,37 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
import type { TicketedEvent } from '../types/ticket'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pinia store for cached activities from Nostr relays.
|
* Pinia store for cached events from Nostr relays.
|
||||||
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
|
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
|
||||||
*/
|
*/
|
||||||
export const useActivitiesStore = defineStore('activities', () => {
|
export const useEventsStore = defineStore('events', () => {
|
||||||
// State
|
// State
|
||||||
const activitiesMap = ref<Map<string, Activity>>(new Map())
|
const eventsMap = ref<Map<string, Event>>(new Map())
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const lastUpdated = ref<Date | null>(null)
|
const lastUpdated = ref<Date | null>(null)
|
||||||
/** Toggle by the standalone bottom-nav Create tab; mounted dialog lives
|
/** Toggle by the standalone bottom-nav Create tab; mounted dialog lives
|
||||||
* in activities-app/App.vue so it's available from every route. */
|
* in events-app/App.vue so it's available from every route. */
|
||||||
const showCreateDialog = ref(false)
|
const showCreateDialog = ref(false)
|
||||||
/** When set, the shell-mounted CreateEventDialog opens in edit mode
|
/** When set, the shell-mounted CreateEventDialog opens in edit mode
|
||||||
* for this LNbits event. Cleared when the dialog closes. */
|
* for this LNbits event. Cleared when the dialog closes. */
|
||||||
const editingEvent = ref<TicketedEvent | null>(null)
|
const editingEvent = ref<TicketedEvent | null>(null)
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const activities = computed(() => Array.from(activitiesMap.value.values()))
|
const events = computed(() => Array.from(eventsMap.value.values()))
|
||||||
|
|
||||||
const upcomingActivities = computed(() => {
|
const upcomingEvents = computed(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
return activities.value
|
return events.value
|
||||||
.filter(a => a.startDate >= now || (a.endDate && a.endDate >= now))
|
.filter(a => a.startDate >= now || (a.endDate && a.endDate >= now))
|
||||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||||
})
|
})
|
||||||
|
|
||||||
const pastActivities = computed(() => {
|
const pastEvents = computed(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
return activities.value
|
return events.value
|
||||||
.filter(a => {
|
.filter(a => {
|
||||||
const endOrStart = a.endDate ?? a.startDate
|
const endOrStart = a.endDate ?? a.startDate
|
||||||
return endOrStart < now
|
return endOrStart < now
|
||||||
|
|
@ -42,68 +42,68 @@ export const useActivitiesStore = defineStore('activities', () => {
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update an activity in the store.
|
* Add or update an event in the store.
|
||||||
* Deduplicates by id (d-tag). Newer events replace older ones.
|
* Deduplicates by id (d-tag). Newer events replace older ones.
|
||||||
*/
|
*/
|
||||||
function upsertActivity(activity: Activity) {
|
function upsertEvent(event: Event) {
|
||||||
const existing = activitiesMap.value.get(activity.id)
|
const existing = eventsMap.value.get(event.id)
|
||||||
|
|
||||||
// Only update if this is a newer version
|
// Only update if this is a newer version
|
||||||
if (!existing || activity.createdAt >= existing.createdAt) {
|
if (!existing || event.createdAt >= existing.createdAt) {
|
||||||
activitiesMap.value.set(activity.id, activity)
|
eventsMap.value.set(event.id, event)
|
||||||
lastUpdated.value = new Date()
|
lastUpdated.value = new Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add multiple activities (batch upsert).
|
* Add multiple events (batch upsert).
|
||||||
*/
|
*/
|
||||||
function upsertActivities(newActivities: Activity[]) {
|
function upsertEvents(newEvents: Event[]) {
|
||||||
for (const activity of newActivities) {
|
for (const event of newEvents) {
|
||||||
upsertActivity(activity)
|
upsertEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an activity from the store.
|
* Remove an event from the store.
|
||||||
*/
|
*/
|
||||||
function removeActivity(id: string) {
|
function removeEvent(id: string) {
|
||||||
activitiesMap.value.delete(id)
|
eventsMap.value.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all cached activities.
|
* Clear all cached events.
|
||||||
*/
|
*/
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
activitiesMap.value.clear()
|
eventsMap.value.clear()
|
||||||
lastUpdated.value = null
|
lastUpdated.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single activity by its id (d-tag).
|
* Get a single event by its id (d-tag).
|
||||||
*/
|
*/
|
||||||
function getActivityById(id: string): Activity | undefined {
|
function getEventById(id: string): Event | undefined {
|
||||||
return activitiesMap.value.get(id)
|
return eventsMap.value.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
activitiesMap,
|
eventsMap,
|
||||||
isLoading,
|
isLoading,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
showCreateDialog,
|
showCreateDialog,
|
||||||
editingEvent,
|
editingEvent,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
activities,
|
events,
|
||||||
upcomingActivities,
|
upcomingEvents,
|
||||||
pastActivities,
|
pastEvents,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
upsertActivity,
|
upsertEvent,
|
||||||
upsertActivities,
|
upsertEvents,
|
||||||
removeActivity,
|
removeEvent,
|
||||||
clearAll,
|
clearAll,
|
||||||
getActivityById,
|
getEventById,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Activity categories inspired by p'a semana
|
* Event categories inspired by p'a semana
|
||||||
* Mapped to NIP-52 't' (hashtag) tags
|
* Mapped to NIP-52 't' (hashtag) tags
|
||||||
*/
|
*/
|
||||||
export const ACTIVITY_CATEGORIES = {
|
export const EVENT_CATEGORIES = {
|
||||||
concert: 'concert',
|
concert: 'concert',
|
||||||
workshop: 'workshop',
|
workshop: 'workshop',
|
||||||
market: 'market',
|
market: 'market',
|
||||||
|
|
@ -30,6 +30,6 @@ export const ACTIVITY_CATEGORIES = {
|
||||||
other: 'other',
|
other: 'other',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ActivityCategory = typeof ACTIVITY_CATEGORIES[keyof typeof ACTIVITY_CATEGORIES]
|
export type EventCategory = typeof EVENT_CATEGORIES[keyof typeof EVENT_CATEGORIES]
|
||||||
|
|
||||||
export const ALL_CATEGORIES = Object.values(ACTIVITY_CATEGORIES)
|
export const ALL_CATEGORIES = Object.values(EVENT_CATEGORIES)
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import ngeohash from 'ngeohash'
|
import ngeohash from 'ngeohash'
|
||||||
import type { ActivityCategory } from './category'
|
import type { EventCategory } from './category'
|
||||||
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
|
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
|
||||||
import type { TicketedEvent } from './ticket'
|
import type { TicketedEvent } from './ticket'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified view model for displaying activities in the UI.
|
* Unified view model for displaying events in the UI.
|
||||||
* Created from NIP-52 CalendarTimeEvent or CalendarDateEvent.
|
* Created from NIP-52 CalendarTimeEvent or CalendarDateEvent.
|
||||||
*/
|
*/
|
||||||
export interface Activity {
|
export interface Event {
|
||||||
/** Unique identifier (NIP-52 d-tag) */
|
/** Unique identifier (NIP-52 d-tag) */
|
||||||
id: string
|
id: string
|
||||||
/** Nostr event ID */
|
/** Nostr event ID */
|
||||||
|
|
@ -16,7 +16,7 @@ export interface Activity {
|
||||||
type: 'date' | 'time'
|
type: 'date' | 'time'
|
||||||
/** Organizer information */
|
/** Organizer information */
|
||||||
organizer: OrganizerInfo
|
organizer: OrganizerInfo
|
||||||
/** Activity title */
|
/** Event title */
|
||||||
title: string
|
title: string
|
||||||
/** Brief summary */
|
/** Brief summary */
|
||||||
summary?: string
|
summary?: string
|
||||||
|
|
@ -37,18 +37,18 @@ export interface Activity {
|
||||||
/** NIP-52 geohash (g tag) */
|
/** NIP-52 geohash (g tag) */
|
||||||
geohash?: string
|
geohash?: string
|
||||||
/** Primary category */
|
/** Primary category */
|
||||||
category?: ActivityCategory
|
category?: EventCategory
|
||||||
/** All hashtags/tags */
|
/** All hashtags/tags */
|
||||||
tags: string[]
|
tags: string[]
|
||||||
/** Ticket pricing info (if ticketed) */
|
/** Ticket pricing info (if ticketed) */
|
||||||
ticketInfo?: ActivityTicketInfo
|
ticketInfo?: EventTicketInfo
|
||||||
/** Whether this is a private/invite-only event */
|
/** Whether this is a private/invite-only event */
|
||||||
isPrivate: boolean
|
isPrivate: boolean
|
||||||
/** Nostr event created_at timestamp */
|
/** Nostr event created_at timestamp */
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
/**
|
/**
|
||||||
* LNbits approval status, when the activity came from the events
|
* LNbits approval status, when the event came from the events
|
||||||
* extension rather than a Nostr relay. Undefined for activities
|
* extension rather than a Nostr relay. Undefined for events
|
||||||
* sourced from Nostr (approved by definition — only published
|
* sourced from Nostr (approved by definition — only published
|
||||||
* events make it onto relays). Used to render a "Pending review"
|
* events make it onto relays). Used to render a "Pending review"
|
||||||
* badge for the creator's own non-approved drafts.
|
* badge for the creator's own non-approved drafts.
|
||||||
|
|
@ -56,7 +56,7 @@ export interface Activity {
|
||||||
lnbitsStatus?: 'approved' | 'proposed' | 'rejected'
|
lnbitsStatus?: 'approved' | 'proposed' | 'rejected'
|
||||||
/**
|
/**
|
||||||
* Belongs to the current user. Set by the adapter for own LNbits
|
* Belongs to the current user. Set by the adapter for own LNbits
|
||||||
* drafts and by the activities-subscribe callback when the Nostr
|
* drafts and by the events-subscribe callback when the Nostr
|
||||||
* organizer pubkey matches the logged-in user. Used to render a
|
* organizer pubkey matches the logged-in user. Used to render a
|
||||||
* "Yours" badge on the feed so the creator can spot their events
|
* "Yours" badge on the feed so the creator can spot their events
|
||||||
* at a glance.
|
* at a glance.
|
||||||
|
|
@ -71,7 +71,7 @@ export interface OrganizerInfo {
|
||||||
nip05?: string
|
nip05?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityTicketInfo {
|
export interface EventTicketInfo {
|
||||||
price: number
|
price: number
|
||||||
currency: string
|
currency: string
|
||||||
/** Remaining capacity. Undefined means unlimited. */
|
/** Remaining capacity. Undefined means unlimited. */
|
||||||
|
|
@ -84,7 +84,7 @@ export interface ActivityTicketInfo {
|
||||||
fiatCurrency?: string
|
fiatCurrency?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo | undefined {
|
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
|
||||||
if (!ticket) return undefined
|
if (!ticket) return undefined
|
||||||
return {
|
return {
|
||||||
price: ticket.price,
|
price: ticket.price,
|
||||||
|
|
@ -97,10 +97,10 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo |
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a CalendarTimeEvent to an Activity view model
|
* Convert a CalendarTimeEvent to an Event view model
|
||||||
*/
|
*/
|
||||||
export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Activity {
|
export function calendarTimeEventToEvent(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Event {
|
||||||
const category = event.hashtags[0] as ActivityCategory | undefined
|
const category = event.hashtags[0] as EventCategory | undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.dTag,
|
id: event.dTag,
|
||||||
|
|
@ -129,10 +129,10 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a CalendarDateEvent to an Activity view model
|
* Convert a CalendarDateEvent to an Event view model
|
||||||
*/
|
*/
|
||||||
export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Activity {
|
export function calendarDateEventToEvent(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Event {
|
||||||
const category = event.hashtags[0] as ActivityCategory | undefined
|
const category = event.hashtags[0] as EventCategory | undefined
|
||||||
|
|
||||||
// Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC
|
// Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC
|
||||||
const parseIsoDate = (dateStr: string): Date => {
|
const parseIsoDate = (dateStr: string): Date => {
|
||||||
|
|
@ -166,21 +166,21 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an LNbits TicketedEvent to an Activity view model.
|
* Convert an LNbits TicketedEvent to an Event view model.
|
||||||
*
|
*
|
||||||
* Used to surface the caller's own pending events on the activities
|
* Used to surface the caller's own pending events on the events
|
||||||
* feed alongside Nostr-published activities. Once an event is approved
|
* feed alongside Nostr-published events. Once an event is approved
|
||||||
* and published, the Nostr-derived Activity (newer createdAt) wins on
|
* and published, the Nostr-derived Event (newer createdAt) wins on
|
||||||
* upsert in the activities store and this draft version is replaced.
|
* upsert in the events store and this draft version is replaced.
|
||||||
*
|
*
|
||||||
* The wire format for dates mirrors how nostr_publisher emits NIP-52:
|
* The wire format for dates mirrors how nostr_publisher emits NIP-52:
|
||||||
* - "YYYY-MM-DD" → date-based (kind 31922 on publish)
|
* - "YYYY-MM-DD" → date-based (kind 31922 on publish)
|
||||||
* - "YYYY-MM-DDTHH:MM..." → time-based (kind 31923 on publish)
|
* - "YYYY-MM-DDTHH:MM..." → time-based (kind 31923 on publish)
|
||||||
*/
|
*/
|
||||||
export function ticketedEventToActivity(
|
export function ticketedEventToEvent(
|
||||||
event: TicketedEvent,
|
event: TicketedEvent,
|
||||||
organizer?: Partial<OrganizerInfo>,
|
organizer?: Partial<OrganizerInfo>,
|
||||||
): Activity {
|
): Event {
|
||||||
const hasTime = event.event_start_date.includes('T')
|
const hasTime = event.event_start_date.includes('T')
|
||||||
const startDate = hasTime
|
const startDate = hasTime
|
||||||
? new Date(event.event_start_date)
|
? new Date(event.event_start_date)
|
||||||
|
|
@ -192,7 +192,7 @@ export function ticketedEventToActivity(
|
||||||
: parseDateOnly(endRaw)
|
: parseDateOnly(endRaw)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const category = event.categories?.[0] as ActivityCategory | undefined
|
const category = event.categories?.[0] as EventCategory | undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -204,7 +204,7 @@ export function ticketedEventToActivity(
|
||||||
organizer: {
|
organizer: {
|
||||||
// Pending events have no Nostr pubkey yet. Empty string is fine
|
// Pending events have no Nostr pubkey yet. Empty string is fine
|
||||||
// — the card layer falls back gracefully and the OrganizerCard
|
// — the card layer falls back gracefully and the OrganizerCard
|
||||||
// is only shown for approved (Nostr-sourced) activities anyway.
|
// is only shown for approved (Nostr-sourced) events anyway.
|
||||||
pubkey: '',
|
pubkey: '',
|
||||||
...organizer,
|
...organizer,
|
||||||
},
|
},
|
||||||
|
|
@ -221,7 +221,7 @@ export function ticketedEventToActivity(
|
||||||
// FastAPI serialization). new Date() handles both ISO strings and
|
// FastAPI serialization). new Date() handles both ISO strings and
|
||||||
// numeric epoch — same shape used in useEvents sorting.
|
// numeric epoch — same shape used in useEvents sorting.
|
||||||
createdAt: new Date(event.time) || new Date(),
|
createdAt: new Date(event.time) || new Date(),
|
||||||
lnbitsStatus: event.status as Activity['lnbitsStatus'],
|
lnbitsStatus: event.status as Event['lnbitsStatus'],
|
||||||
// fetchMyEvents only returns the caller's own events, so anything
|
// fetchMyEvents only returns the caller's own events, so anything
|
||||||
// reaching this adapter is by definition mine.
|
// reaching this adapter is by definition mine.
|
||||||
isMine: true,
|
isMine: true,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ActivityCategory } from './category'
|
import type { EventCategory } from './category'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Temporal filter presets (p'a semana style)
|
* Temporal filter presets (p'a semana style)
|
||||||
|
|
@ -6,11 +6,11 @@ import type { ActivityCategory } from './category'
|
||||||
export type TemporalFilter = 'all' | 'today' | 'tomorrow' | 'this-week' | 'this-month'
|
export type TemporalFilter = 'all' | 'today' | 'tomorrow' | 'this-week' | 'this-month'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combined filter state for activity discovery
|
* Combined filter state for event discovery
|
||||||
*/
|
*/
|
||||||
export interface ActivityFilters {
|
export interface EventFilters {
|
||||||
temporal: TemporalFilter
|
temporal: TemporalFilter
|
||||||
categories: ActivityCategory[]
|
categories: EventCategory[]
|
||||||
/** Free text search */
|
/** Free text search */
|
||||||
search?: string
|
search?: string
|
||||||
/** Geohash prefix for geographic filtering */
|
/** Geohash prefix for geographic filtering */
|
||||||
|
|
@ -22,7 +22,7 @@ export interface ActivityFilters {
|
||||||
/**
|
/**
|
||||||
* Default filter state
|
* Default filter state
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_FILTERS: ActivityFilters = {
|
export const DEFAULT_FILTERS: EventFilters = {
|
||||||
temporal: 'all',
|
temporal: 'all',
|
||||||
categories: [],
|
categories: [],
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* Database-backed ticket types (via LNbits events extension).
|
* Database-backed ticket types (via LNbits events extension).
|
||||||
*
|
*
|
||||||
* Wire-format types — names match the snake_case fields the events
|
* Wire-format types — names match the snake_case fields the events
|
||||||
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
|
* extension serves over HTTP. Camel-cased aliases (e.g. EventTicket
|
||||||
* below) are the webapp-internal view models after adapter conversion.
|
* below) are the webapp-internal view models after adapter conversion.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ export interface EventExtra {
|
||||||
notification_body: string
|
notification_body: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityTicketExtra {
|
export interface EventTicketExtra {
|
||||||
applied_promo_code?: string | null
|
applied_promo_code?: string | null
|
||||||
sats_paid?: number | null
|
sats_paid?: number | null
|
||||||
refund_address?: string | null
|
refund_address?: string | null
|
||||||
|
|
@ -39,11 +39,11 @@ export interface ActivityTicketExtra {
|
||||||
refunded: boolean
|
refunded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityTicket {
|
export interface EventTicket {
|
||||||
id: string
|
id: string
|
||||||
wallet: string
|
wallet: string
|
||||||
/** Reference to the activity (LNbits event ID) */
|
/** Reference to the event (LNbits event ID) */
|
||||||
activityId: string
|
eventId: string
|
||||||
/** Ticket holder name */
|
/** Ticket holder name */
|
||||||
name: string | null
|
name: string | null
|
||||||
/** Ticket holder email */
|
/** Ticket holder email */
|
||||||
|
|
@ -60,7 +60,7 @@ export interface ActivityTicket {
|
||||||
regTimestamp: string
|
regTimestamp: string
|
||||||
/** Optional metadata — promo code applied, sats paid, notification
|
/** Optional metadata — promo code applied, sats paid, notification
|
||||||
* delivery flags, refund state. May be absent on older tickets. */
|
* delivery flags, refund state. May be absent on older tickets. */
|
||||||
extra?: ActivityTicketExtra
|
extra?: EventTicketExtra
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
||||||
|
|
@ -68,7 +68,7 @@ export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
||||||
export type PaymentMethod = 'lightning' | 'fiat'
|
export type PaymentMethod = 'lightning' | 'fiat'
|
||||||
|
|
||||||
export interface TicketPurchaseRequest {
|
export interface TicketPurchaseRequest {
|
||||||
activityId: string
|
eventId: string
|
||||||
userId: string
|
userId: string
|
||||||
accessToken: string
|
accessToken: string
|
||||||
/** Lightning (default) or fiat. Only meaningful if the event has
|
/** Lightning (default) or fiat. Only meaningful if the event has
|
||||||
|
|
@ -10,14 +10,14 @@ import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
|
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
import { useEventDetail } from '../composables/useEventDetail'
|
||||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
import RSVPButton from '../components/RSVPButton.vue'
|
import RSVPButton from '../components/RSVPButton.vue'
|
||||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||||
import { NIP52_KINDS } from '../types/nip52'
|
import { NIP52_KINDS } from '../types/nip52'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useEventsStore } from '../stores/events'
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
import { toastService } from '@/core/services/ToastService'
|
import { toastService } from '@/core/services/ToastService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
|
@ -28,16 +28,16 @@ const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const activityId = route.params.id as string
|
const eventId = route.params.id as string
|
||||||
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
|
const { event, isLoading, error, reload } = useEventDetail(eventId)
|
||||||
const { dateLocale } = useDateLocale()
|
const { dateLocale } = useDateLocale()
|
||||||
|
|
||||||
// Owner-edit affordance: the NIP-52 d-tag we use for the activity id is
|
// Owner-edit affordance: the NIP-52 d-tag we use for the event id is
|
||||||
// the same as the LNbits event id (set at publish time in
|
// the same as the LNbits event id (set at publish time in
|
||||||
// nostr_publisher.build_nip52_event). Look the user's own events up
|
// nostr_publisher.build_nip52_event). Look the user's own events up
|
||||||
// once and offer an Edit button on a match.
|
// once and offer an Edit button on a match.
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
const activitiesStore = useActivitiesStore()
|
const eventsStore = useEventsStore()
|
||||||
const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
|
const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
|
||||||
|
|
||||||
async function loadOwnedEvent() {
|
async function loadOwnedEvent() {
|
||||||
|
|
@ -49,7 +49,7 @@ async function loadOwnedEvent() {
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
const mine = await ticketApi.fetchMyEvents(invoiceKey)
|
const mine = await ticketApi.fetchMyEvents(invoiceKey)
|
||||||
ownedLnbitsEvent.value =
|
ownedLnbitsEvent.value =
|
||||||
(mine as TicketedEvent[]).find((e) => e.id === activityId) ?? null
|
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null
|
||||||
} catch {
|
} catch {
|
||||||
ownedLnbitsEvent.value = null
|
ownedLnbitsEvent.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -60,17 +60,17 @@ watch(isAuthenticated, () => loadOwnedEvent())
|
||||||
|
|
||||||
function openEditDialog() {
|
function openEditDialog() {
|
||||||
if (!ownedLnbitsEvent.value) return
|
if (!ownedLnbitsEvent.value) return
|
||||||
activitiesStore.editingEvent = ownedLnbitsEvent.value
|
eventsStore.editingEvent = ownedLnbitsEvent.value
|
||||||
activitiesStore.showCreateDialog = true
|
eventsStore.showCreateDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openScannerPage() {
|
function openScannerPage() {
|
||||||
router.push({ name: 'scan-tickets', params: { activityId } })
|
router.push({ name: 'scan-tickets', params: { eventId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
if (!activity.value) return ''
|
if (!event.value) return ''
|
||||||
const a = activity.value
|
const a = event.value
|
||||||
const opts = { locale: dateLocale.value }
|
const opts = { locale: dateLocale.value }
|
||||||
if (a.type === 'date') {
|
if (a.type === 'date') {
|
||||||
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
|
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
|
||||||
|
|
@ -94,22 +94,22 @@ const dateDisplay = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const categoryLabel = computed(() => {
|
const categoryLabel = computed(() => {
|
||||||
if (!activity.value?.category) return null
|
if (!event.value?.category) return null
|
||||||
return t(`activities.categories.${activity.value.category}`, activity.value.category)
|
return t(`events.categories.${event.value.category}`, event.value.category)
|
||||||
})
|
})
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ name: 'activities' })
|
router.push({ name: 'events' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ticket purchase + owned-tickets surface ----------------------
|
// --- Ticket purchase + owned-tickets surface ----------------------
|
||||||
|
|
||||||
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
|
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||||
|
|
||||||
const ownedPaidCount = computed(() => paidCount(activityId))
|
const ownedPaidCount = computed(() => paidCount(eventId))
|
||||||
|
|
||||||
const purchaseEvent = computed(() => {
|
const purchaseEvent = computed(() => {
|
||||||
const a = activity.value
|
const a = event.value
|
||||||
if (!a || !a.ticketInfo) return null
|
if (!a || !a.ticketInfo) return null
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
|
@ -125,7 +125,7 @@ const purchaseEvent = computed(() => {
|
||||||
// available === 0 → sold out, button hidden
|
// available === 0 → sold out, button hidden
|
||||||
// available > 0 → button shown
|
// available > 0 → button shown
|
||||||
const canBuyTicket = computed(() => {
|
const canBuyTicket = computed(() => {
|
||||||
const info = activity.value?.ticketInfo
|
const info = event.value?.ticketInfo
|
||||||
if (!info) return false
|
if (!info) return false
|
||||||
return info.available === undefined || info.available > 0
|
return info.available === undefined || info.available > 0
|
||||||
})
|
})
|
||||||
|
|
@ -134,7 +134,7 @@ const canBuyTicket = computed(() => {
|
||||||
// buy CTA so the flow is unambiguous — date alone is easy to miss
|
// buy CTA so the flow is unambiguous — date alone is easy to miss
|
||||||
// on a long detail page.
|
// on a long detail page.
|
||||||
const isPast = computed(() => {
|
const isPast = computed(() => {
|
||||||
const a = activity.value
|
const a = event.value
|
||||||
if (!a) return false
|
if (!a) return false
|
||||||
const end = a.endDate ?? a.startDate
|
const end = a.endDate ?? a.startDate
|
||||||
if (!end || isNaN(end.getTime())) return false
|
if (!end || isNaN(end.getTime())) return false
|
||||||
|
|
@ -145,9 +145,9 @@ const showPurchaseDialog = ref(false)
|
||||||
|
|
||||||
function openPurchaseDialog() {
|
function openPurchaseDialog() {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
toastService.info(t('activities.detail.loginToBuyTickets'), {
|
toastService.info(t('events.detail.loginToBuyTickets'), {
|
||||||
action: {
|
action: {
|
||||||
label: t('activities.detail.logIn'),
|
label: t('events.detail.logIn'),
|
||||||
onClick: () => router.push('/login'),
|
onClick: () => router.push('/login'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -200,9 +200,9 @@ function goToMyTickets() {
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<BookmarkButton
|
<BookmarkButton
|
||||||
v-if="activity"
|
v-if="event"
|
||||||
:pubkey="activity.organizer.pubkey"
|
:pubkey="event.organizer.pubkey"
|
||||||
:d-tag="activity.id"
|
:d-tag="event.id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -217,18 +217,18 @@ function goToMyTickets() {
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-else-if="error" class="text-center py-16">
|
<div v-else-if="error" class="text-center py-16">
|
||||||
<h2 class="text-xl font-semibold text-foreground mb-2">Activity not found</h2>
|
<h2 class="text-xl font-semibold text-foreground mb-2">Event not found</h2>
|
||||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||||
<Button variant="outline" @click="reload">Retry</Button>
|
<Button variant="outline" @click="reload">Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Detail content -->
|
<!-- Detail content -->
|
||||||
<div v-else-if="activity" class="space-y-6">
|
<div v-else-if="event" class="space-y-6">
|
||||||
<!-- Hero image -->
|
<!-- Hero image -->
|
||||||
<div v-if="activity.image" class="rounded-lg overflow-hidden">
|
<div v-if="event.image" class="rounded-lg overflow-hidden">
|
||||||
<img
|
<img
|
||||||
:src="activity.image"
|
:src="event.image"
|
||||||
:alt="activity.title"
|
:alt="event.title"
|
||||||
class="w-full aspect-[16/9] object-cover"
|
class="w-full aspect-[16/9] object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -240,28 +240,28 @@ function goToMyTickets() {
|
||||||
{{ categoryLabel }}
|
{{ categoryLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
|
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||||
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||||
class="shrink-0 mt-1 capitalize"
|
class="shrink-0 mt-1 capitalize"
|
||||||
>
|
>
|
||||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="activity.isMine"
|
v-if="event.isMine"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="shrink-0 mt-1"
|
class="shrink-0 mt-1"
|
||||||
>
|
>
|
||||||
Yours
|
Yours
|
||||||
</Badge>
|
</Badge>
|
||||||
<div v-for="tag in activity.tags.slice(1)" :key="tag">
|
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
||||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||||
{{ activity.title }}
|
{{ event.title }}
|
||||||
</h1>
|
</h1>
|
||||||
<p v-if="activity.summary" class="text-muted-foreground mt-2">
|
<p v-if="event.summary" class="text-muted-foreground mt-2">
|
||||||
{{ activity.summary }}
|
{{ event.summary }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -273,52 +273,52 @@ function goToMyTickets() {
|
||||||
<div class="bg-muted/50 rounded-lg p-4 space-y-1">
|
<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">
|
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
<Calendar class="w-4 h-4" />
|
<Calendar class="w-4 h-4" />
|
||||||
{{ t('activities.detail.when') }}
|
{{ t('events.detail.when') }}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
|
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
|
||||||
<p v-if="activity.timezone" class="text-xs text-muted-foreground/70">
|
<p v-if="event.timezone" class="text-xs text-muted-foreground/70">
|
||||||
{{ activity.timezone }}
|
{{ event.timezone }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Where -->
|
<!-- Where -->
|
||||||
<div v-if="activity.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
|
<div v-if="event.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">
|
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
<MapPin class="w-4 h-4" />
|
<MapPin class="w-4 h-4" />
|
||||||
{{ t('activities.detail.location') }}
|
{{ t('events.detail.location') }}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">{{ activity.location }}</p>
|
<p class="text-sm text-muted-foreground">{{ event.location }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RSVP -->
|
<!-- RSVP -->
|
||||||
<!-- The NIP-52 RSVP `a` tag must reference the activity's actual kind
|
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
|
||||||
(31922 for date-based, 31923 for time-based). Without this prop the
|
(31922 for date-based, 31923 for time-based). Without this prop the
|
||||||
button would default to time-based for every activity, leaving RSVPs
|
button would default to time-based for every event, leaving RSVPs
|
||||||
on date-based activities pointing at a non-existent event coord. -->
|
on date-based events pointing at a non-existent event coord. -->
|
||||||
<RSVPButton
|
<RSVPButton
|
||||||
:pubkey="activity.organizer.pubkey"
|
:pubkey="event.organizer.pubkey"
|
||||||
:d-tag="activity.id"
|
:d-tag="event.id"
|
||||||
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Tickets — gated on the activity carrying ticketInfo (set
|
<!-- Tickets — gated on the event carrying ticketInfo (set
|
||||||
by the calendar→Activity converter from the AIO custom
|
by the calendar→Event converter from the AIO custom
|
||||||
tickets_* tags on the published event). Sections render
|
tickets_* tags on the published event). Sections render
|
||||||
bottom-up: availability count, then existing owned
|
bottom-up: availability count, then existing owned
|
||||||
tickets (when count > 0) above a Purchase CTA (when
|
tickets (when count > 0) above a Purchase CTA (when
|
||||||
capacity remains). -->
|
capacity remains). -->
|
||||||
<div v-if="activity.ticketInfo" class="space-y-3">
|
<div v-if="event.ticketInfo" class="space-y-3">
|
||||||
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Ticket class="w-4 h-4 shrink-0" />
|
<Ticket class="w-4 h-4 shrink-0" />
|
||||||
<span v-if="activity.ticketInfo.available === undefined">
|
<span v-if="event.ticketInfo.available === undefined">
|
||||||
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="activity.ticketInfo.available > 0">
|
<span v-else-if="event.ticketInfo.available > 0">
|
||||||
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
|
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-destructive font-medium">
|
<span v-else class="text-destructive font-medium">
|
||||||
{{ t('activities.detail.soldOut') }}
|
{{ t('events.detail.soldOut') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -328,11 +328,11 @@ function goToMyTickets() {
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
|
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
|
||||||
{{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
{{ t('activities.detail.viewMyTickets', 'View in My Tickets') }}
|
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -341,7 +341,7 @@ function goToMyTickets() {
|
||||||
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
|
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
|
||||||
>
|
>
|
||||||
<History class="w-4 h-4 shrink-0" />
|
<History class="w-4 h-4 shrink-0" />
|
||||||
{{ t('activities.detail.pastEvent', 'This event has already happened') }}
|
{{ t('events.detail.pastEvent', 'This event has already happened') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="canBuyTicket">
|
<div v-else-if="canBuyTicket">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -351,12 +351,12 @@ function goToMyTickets() {
|
||||||
>
|
>
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
{{ ownedPaidCount > 0
|
{{ ownedPaidCount > 0
|
||||||
? t('activities.detail.buyAnotherTicket', 'Buy another ticket')
|
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
||||||
: t('activities.detail.buyTicket', 'Buy ticket') }}
|
: t('events.detail.buyTicket', 'Buy ticket') }}
|
||||||
<span class="ml-2 opacity-80 font-normal">
|
<span class="ml-2 opacity-80 font-normal">
|
||||||
{{ activity.ticketInfo.price === 0
|
{{ event.ticketInfo.price === 0
|
||||||
? t('activities.detail.free')
|
? t('events.detail.free')
|
||||||
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
|
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -364,7 +364,7 @@ function goToMyTickets() {
|
||||||
v-else-if="ownedPaidCount === 0"
|
v-else-if="ownedPaidCount === 0"
|
||||||
class="text-sm text-destructive text-center"
|
class="text-sm text-destructive text-center"
|
||||||
>
|
>
|
||||||
{{ t('activities.detail.soldOut') }}
|
{{ t('events.detail.soldOut') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -378,20 +378,20 @@ function goToMyTickets() {
|
||||||
<!-- Organizer -->
|
<!-- Organizer -->
|
||||||
<div class="bg-muted/50 rounded-lg p-4">
|
<div class="bg-muted/50 rounded-lg p-4">
|
||||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
{{ t('activities.detail.organizer') }}
|
{{ t('events.detail.organizer') }}
|
||||||
</p>
|
</p>
|
||||||
<OrganizerCard :pubkey="activity.organizer.pubkey" />
|
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="prose prose-sm max-w-none text-foreground">
|
<div class="prose prose-sm max-w-none text-foreground">
|
||||||
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
|
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- External references -->
|
<!-- External references -->
|
||||||
<div v-if="activity.tags.length > 0" class="space-y-2">
|
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||||
<!-- References would go here in future phases -->
|
<!-- References would go here in future phases -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
27
src/modules/events/views/EventsCalendarPage.vue
Normal file
27
src/modules/events/views/EventsCalendarPage.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useEvents } from '../composables/useEvents'
|
||||||
|
import EventCalendarView from '../components/EventCalendarView.vue'
|
||||||
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { allEvents, subscribe } = useEvents()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
subscribe()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSelectEvent(event: Event) {
|
||||||
|
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||||
|
<EventCalendarView
|
||||||
|
:events="allEvents"
|
||||||
|
@select-event="handleSelectEvent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -7,29 +7,29 @@ import { Button } from '@/components/ui/button'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useBookmarks } from '../composables/useBookmarks'
|
import { useBookmarks } from '../composables/useBookmarks'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useEventsStore } from '../stores/events'
|
||||||
import ActivityList from '../components/ActivityList.vue'
|
import EventList from '../components/EventList.vue'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
const { isBookmarkedByDTag, isLoaded } = useBookmarks()
|
const { isBookmarkedByDTag, isLoaded } = useBookmarks()
|
||||||
const store = useActivitiesStore()
|
const store = useEventsStore()
|
||||||
|
|
||||||
const favoriteActivities = computed(() => {
|
const favoriteEvents = computed(() => {
|
||||||
return store.activities.filter(a => isBookmarkedByDTag(a.id))
|
return store.events.filter(a => isBookmarkedByDTag(a.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSelect(activity: Activity) {
|
function handleSelect(event: Event) {
|
||||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
toast.info(t('activities.favorites.loginPrompt'), {
|
toast.info(t('events.favorites.loginPrompt'), {
|
||||||
action: {
|
action: {
|
||||||
label: t('activities.favorites.logIn'),
|
label: t('events.favorites.logIn'),
|
||||||
onClick: () => router.push('/login'),
|
onClick: () => router.push('/login'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -39,14 +39,14 @@ onMounted(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="container mx-auto px-4 py-6">
|
||||||
<h1 class="text-2xl font-bold text-foreground mb-4">{{ t('activities.favorites.title') }}</h1>
|
<h1 class="text-2xl font-bold text-foreground mb-4">{{ t('events.favorites.title') }}</h1>
|
||||||
|
|
||||||
<!-- Not authenticated -->
|
<!-- Not authenticated -->
|
||||||
<div v-if="!isAuthenticated" class="flex flex-col items-center justify-center py-16 text-center">
|
<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" />
|
<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>
|
<p class="text-muted-foreground mb-3">{{ t('events.favorites.loginPrompt') }}</p>
|
||||||
<Button variant="outline" size="sm" @click="router.push('/login')">
|
<Button variant="outline" size="sm" @click="router.push('/login')">
|
||||||
{{ t('activities.favorites.logIn') }}
|
{{ t('events.favorites.logIn') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -56,16 +56,16 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<div v-else-if="favoriteActivities.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
|
<div v-else-if="favoriteEvents.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" />
|
<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-muted-foreground">{{ t('events.favorites.empty') }}</p>
|
||||||
<p class="text-sm text-muted-foreground/70 mt-1">{{ t('activities.favorites.emptyHint') }}</p>
|
<p class="text-sm text-muted-foreground/70 mt-1">{{ t('events.favorites.emptyHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Favorites list -->
|
<!-- Favorites list -->
|
||||||
<ActivityList
|
<EventList
|
||||||
v-else
|
v-else
|
||||||
:activities="favoriteActivities"
|
:events="favoriteEvents"
|
||||||
@select="handleSelect"
|
@select="handleSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
55
src/modules/events/views/EventsMapPage.vue
Normal file
55
src/modules/events/views/EventsMapPage.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { Map } from 'lucide-vue-next'
|
||||||
|
import { useEvents } from '../composables/useEvents'
|
||||||
|
import EventMap from '../components/EventMap.vue'
|
||||||
|
|
||||||
|
const { allEvents, isLoading, subscribe } = useEvents()
|
||||||
|
|
||||||
|
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 geoEvents = computed(() =>
|
||||||
|
allEvents.value.filter(a => a.coordinates)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
subscribe()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-[calc(100dvh-3.5rem)]">
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div v-if="isLoading && geoEvents.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 events -->
|
||||||
|
<div v-else-if="!isLoading && geoEvents.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 events found</p>
|
||||||
|
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map -->
|
||||||
|
<EventMap
|
||||||
|
v-else
|
||||||
|
:events="geoEvents"
|
||||||
|
:center="mapCenter"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Event count -->
|
||||||
|
<div v-if="geoEvents.length > 0" class="px-4 py-2 text-xs text-muted-foreground border-t bg-background">
|
||||||
|
{{ geoEvents.length }} event{{ geoEvents.length === 1 ? '' : 's' }} on map
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -9,20 +9,20 @@ import {
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
|
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
|
||||||
import { useActivities } from '../composables/useActivities'
|
import { useEvents } from '../composables/useEvents'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
|
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||||
import DatePickerStrip from '../components/DatePickerStrip.vue'
|
import DatePickerStrip from '../components/DatePickerStrip.vue'
|
||||||
import ActivityList from '../components/ActivityList.vue'
|
import EventList from '../components/EventList.vue'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activities,
|
events,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
temporal,
|
temporal,
|
||||||
|
|
@ -41,7 +41,7 @@ const {
|
||||||
togglePast,
|
togglePast,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
subscribe,
|
subscribe,
|
||||||
} = useActivities()
|
} = useEvents()
|
||||||
|
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
|
@ -51,8 +51,8 @@ onMounted(() => {
|
||||||
subscribe()
|
subscribe()
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSelectActivity(activity: Activity) {
|
function handleSelectEvent(event: Event) {
|
||||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -61,15 +61,15 @@ function handleSelectActivity(activity: Activity) {
|
||||||
<!-- Page header -->
|
<!-- Page header -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||||
{{ t('activities.title') }}
|
{{ t('events.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search with dropdown overlay -->
|
<!-- Search with dropdown overlay -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<ActivitySearchOverlay
|
<EventSearchOverlay
|
||||||
:activities="activities"
|
:events="events"
|
||||||
@select="handleSelectActivity"
|
@select="handleSelectEvent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -84,7 +84,7 @@ function handleSelectActivity(activity: Activity) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
||||||
"Hosting") narrow the feed to activities the signed-in user
|
"Hosting") narrow the feed to events the signed-in user
|
||||||
has skin in and are hidden when logged out. The "Past events"
|
has skin in and are hidden when logged out. The "Past events"
|
||||||
chip is always visible since past-browsing doesn't require an
|
chip is always visible since past-browsing doesn't require an
|
||||||
account. -->
|
account. -->
|
||||||
|
|
@ -97,7 +97,7 @@ function handleSelectActivity(activity: Activity) {
|
||||||
@click="toggleOwnedTickets"
|
@click="toggleOwnedTickets"
|
||||||
>
|
>
|
||||||
<Ticket class="w-3.5 h-3.5" />
|
<Ticket class="w-3.5 h-3.5" />
|
||||||
{{ t('activities.filters.myTickets', 'My tickets') }}
|
{{ t('events.filters.myTickets', 'My tickets') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
:variant="onlyHosting ? 'default' : 'outline'"
|
:variant="onlyHosting ? 'default' : 'outline'"
|
||||||
|
|
@ -106,7 +106,7 @@ function handleSelectActivity(activity: Activity) {
|
||||||
@click="toggleHosting"
|
@click="toggleHosting"
|
||||||
>
|
>
|
||||||
<Megaphone class="w-3.5 h-3.5" />
|
<Megaphone class="w-3.5 h-3.5" />
|
||||||
{{ t('activities.filters.hosting', 'Hosting') }}
|
{{ t('events.filters.hosting', 'Hosting') }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -116,7 +116,7 @@ function handleSelectActivity(activity: Activity) {
|
||||||
@click="togglePast"
|
@click="togglePast"
|
||||||
>
|
>
|
||||||
<History class="w-3.5 h-3.5" />
|
<History class="w-3.5 h-3.5" />
|
||||||
{{ t('activities.filters.pastEvents', 'Past events') }}
|
{{ t('events.filters.pastEvents', 'Past events') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -154,11 +154,11 @@ function handleSelectActivity(activity: Activity) {
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity feed -->
|
<!-- Event feed -->
|
||||||
<ActivityList
|
<EventList
|
||||||
:activities="activities"
|
:events="events"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@select="handleSelectActivity"
|
@select="handleSelectEvent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useEvents } from '../composables/useEvents'
|
import { useMyEvents } from '../composables/useMyEvents'
|
||||||
import { useApprovalState } from '../composables/useApprovalState'
|
import { useApprovalState } from '../composables/useApprovalState'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
@ -17,7 +17,7 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
||||||
|
|
||||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useMyEvents()
|
||||||
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
const { isAdmin, autoApprove } = useApprovalState()
|
||||||
|
|
||||||
|
|
@ -152,7 +152,7 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
|
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
|
||||||
<p class="text-muted-foreground mb-4">You haven't purchased any tickets yet</p>
|
<p class="text-muted-foreground mb-4">You haven't purchased any tickets yet</p>
|
||||||
<Button @click="$router.push('/activities')">Browse Activities</Button>
|
<Button @click="$router.push('/events')">Browse Events</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="tickets.length > 0">
|
<div v-else-if="tickets.length > 0">
|
||||||
|
|
@ -17,13 +17,13 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
import { useEventDetail } from '../composables/useEventDetail'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const activityId = ref(route.params.activityId as string)
|
const eventId = ref(route.params.eventId as string)
|
||||||
const { activity } = useActivityDetail(activityId.value)
|
const { event } = useEventDetail(eventId.value)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
|
@ -35,7 +35,7 @@ const {
|
||||||
refreshStats,
|
refreshStats,
|
||||||
onDecode,
|
onDecode,
|
||||||
resume,
|
resume,
|
||||||
} = useTicketScanner(activityId)
|
} = useTicketScanner(eventId)
|
||||||
|
|
||||||
const scannerOpen = ref(true)
|
const scannerOpen = ref(true)
|
||||||
const activeTab = ref<'scanner' | 'list'>('scanner')
|
const activeTab = ref<'scanner' | 'list'>('scanner')
|
||||||
|
|
@ -53,10 +53,10 @@ const lastScanVariant = computed(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Backend-authoritative roster. Falls back to the activity nostr
|
// Backend-authoritative roster. Falls back to the event nostr
|
||||||
// event's `tickets_sold` tag if the RPC hasn't completed yet.
|
// event's `tickets_sold` tag if the RPC hasn't completed yet.
|
||||||
const soldCount = computed(
|
const soldCount = computed(
|
||||||
() => eventStats.value?.sold ?? activity.value?.ticketInfo?.sold,
|
() => eventStats.value?.sold ?? event.value?.ticketInfo?.sold,
|
||||||
)
|
)
|
||||||
const registeredCount = computed(() => eventStats.value?.registered ?? 0)
|
const registeredCount = computed(() => eventStats.value?.registered ?? 0)
|
||||||
const remainingCount = computed(() => {
|
const remainingCount = computed(() => {
|
||||||
|
|
@ -78,7 +78,7 @@ function handleResult(qrText: string) {
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (window.history.length > 1) router.back()
|
if (window.history.length > 1) router.back()
|
||||||
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
|
else router.push({ name: 'event-detail', params: { id: eventId.value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
|
|
@ -111,8 +111,8 @@ function fmtTime(iso: string) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
|
||||||
<p v-if="activity" class="text-sm text-muted-foreground mb-4">
|
<p v-if="event" class="text-sm text-muted-foreground mb-4">
|
||||||
{{ activity.title }}
|
{{ event.title }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Counts strip — backend-authoritative. Source: the
|
<!-- Counts strip — backend-authoritative. Source: the
|
||||||
|
|
@ -204,7 +204,7 @@ export function useMarket() {
|
||||||
// Logged-in user has no published market event yet — show their
|
// Logged-in user has no published market event yet — show their
|
||||||
// namespace as "My Market". Avoids leaking VITE_APP_NAME (which
|
// namespace as "My Market". Avoids leaking VITE_APP_NAME (which
|
||||||
// is the brand of whichever standalone app is bundled, e.g.
|
// is the brand of whichever standalone app is bundled, e.g.
|
||||||
// "Sortir" for activities) into the market label.
|
// "Bouge" for the events app) into the market label.
|
||||||
name: 'My Market',
|
name: 'My Market',
|
||||||
description: 'A communal market to sell your goods',
|
description: 'A communal market to sell your goods',
|
||||||
merchants: [],
|
merchants: [],
|
||||||
|
|
|
||||||
|
|
@ -451,7 +451,7 @@ const placeOrder = async () => {
|
||||||
// Try to get pubkey from main auth first, fallback to auth service
|
// Try to get pubkey from main auth first, fallback to auth service
|
||||||
const userPubkey = auth.currentUser.value?.pubkey || authService.user.value?.pubkey
|
const userPubkey = auth.currentUser.value?.pubkey || authService.user.value?.pubkey
|
||||||
|
|
||||||
// Friendly toast instead of throw — same pattern as Activities favorites prompt.
|
// Friendly toast instead of throw — same pattern as Events favorites prompt.
|
||||||
if (!auth.isAuthenticated.value) {
|
if (!auth.isAuthenticated.value) {
|
||||||
toast.info(t('market.auth.loginPrompt'), {
|
toast.info(t('market.auth.loginPrompt'), {
|
||||||
action: {
|
action: {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const modules: Module[] = [
|
||||||
{ label: 'Restaurant', chakra: 'Muladhara', icon: UtensilsCrossed, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_RESTAURANT_URL', status: 'alpha' },
|
{ label: 'Restaurant', chakra: 'Muladhara', icon: UtensilsCrossed, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_RESTAURANT_URL', status: 'alpha' },
|
||||||
{ label: 'Market', chakra: 'Muladhara', icon: Store, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_MARKET_URL', status: 'alpha' },
|
{ label: 'Market', chakra: 'Muladhara', icon: Store, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_MARKET_URL', status: 'alpha' },
|
||||||
{ label: 'Wallet', chakra: 'Manipura', icon: Wallet, bgClass: '', glow: 'rgba(255,200,0,0.5)', envKey: 'VITE_HUB_WALLET_URL', status: 'alpha', authRequired: true },
|
{ label: 'Wallet', chakra: 'Manipura', icon: Wallet, bgClass: '', glow: 'rgba(255,200,0,0.5)', envKey: 'VITE_HUB_WALLET_URL', status: 'alpha', authRequired: true },
|
||||||
{ label: 'Activities', chakra: 'Swadhisthana', icon: CalendarDays, bgClass: '', glow: 'rgba(255,165,0,0.5)', envKey: 'VITE_HUB_ACTIVITIES_URL', status: 'beta' },
|
{ label: 'Events', chakra: 'Swadhisthana', icon: CalendarDays, bgClass: '', glow: 'rgba(255,165,0,0.5)', envKey: 'VITE_HUB_EVENTS_URL', status: 'beta' },
|
||||||
{ label: 'Chat', chakra: 'Anahata', icon: MessageCircle, bgClass: '', glow: 'rgba(0,200,80,0.5)', envKey: 'VITE_HUB_CHAT_URL', status: 'alpha', authRequired: true },
|
{ label: 'Chat', chakra: 'Anahata', icon: MessageCircle, bgClass: '', glow: 'rgba(0,200,80,0.5)', envKey: 'VITE_HUB_CHAT_URL', status: 'alpha', authRequired: true },
|
||||||
{ label: 'Forum', chakra: 'Vishuddha', icon: Newspaper, bgClass: '', glow: 'rgba(60,120,255,0.5)', envKey: 'VITE_HUB_FORUM_URL', status: 'alpha' },
|
{ label: 'Forum', chakra: 'Vishuddha', icon: Newspaper, bgClass: '', glow: 'rgba(60,120,255,0.5)', envKey: 'VITE_HUB_FORUM_URL', status: 'alpha' },
|
||||||
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },
|
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@ import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||||
import { visualizer } from 'rollup-plugin-visualizer'
|
import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin to rewrite dev server requests to activities.html
|
* Plugin to rewrite dev server requests to events.html
|
||||||
* (SPA fallback for the standalone activities app entry point)
|
* (SPA fallback for the standalone events app entry point)
|
||||||
*/
|
*/
|
||||||
function activitiesHtmlPlugin(): Plugin {
|
function eventsHtmlPlugin(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'activities-html-rewrite',
|
name: 'events-html-rewrite',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
// Rewrite all non-asset requests to activities.html.
|
// Rewrite all non-asset requests to events.html.
|
||||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
// contain dots and would otherwise get mistaken for an asset request.
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
const path = req.url ? req.url.split('?')[0] : ''
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
|
|
@ -26,7 +26,7 @@ function activitiesHtmlPlugin(): Plugin {
|
||||||
!req.url.startsWith('/node_modules/') &&
|
!req.url.startsWith('/node_modules/') &&
|
||||||
!path.includes('.')
|
!path.includes('.')
|
||||||
) {
|
) {
|
||||||
req.url = '/activities.html'
|
req.url = '/events.html'
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
@ -35,22 +35,30 @@ function activitiesHtmlPlugin(): Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite config for the standalone Sortir activities app.
|
* Vite config for the standalone events app.
|
||||||
*
|
*
|
||||||
* Set VITE_BASE_PATH to deploy under a path prefix:
|
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||||
* VITE_BASE_PATH=/sortir/ → app.ariege.io/sortir/ (shared auth)
|
* VITE_BASE_PATH=/events/ → app.ariege.io/events/ (shared auth)
|
||||||
* (default: /) → sortir.ariege.io (standalone subdomain)
|
* (default: /) → bouge.ariege.io (standalone subdomain)
|
||||||
|
*
|
||||||
|
* Set VITE_APP_NAME to brand the standalone (PWA name, HTML title, console
|
||||||
|
* logs). cfaun overrides to "Bouge" via NixOS. Defaults to "Events".
|
||||||
*/
|
*/
|
||||||
|
const APP_NAME = process.env.VITE_APP_NAME || 'Events'
|
||||||
|
// Surface the resolved value back into env so Vite's HTML %VITE_APP_NAME%
|
||||||
|
// substitution picks up the fallback when nothing was explicitly set.
|
||||||
|
process.env.VITE_APP_NAME = APP_NAME
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig(({ mode }) => ({
|
||||||
base: process.env.VITE_BASE_PATH || '/',
|
base: process.env.VITE_BASE_PATH || '/',
|
||||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||||
cacheDir: 'node_modules/.vite-activities',
|
cacheDir: 'node_modules/.vite-events',
|
||||||
server: {
|
server: {
|
||||||
port: 5181,
|
port: 5181,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
activitiesHtmlPlugin(),
|
eventsHtmlPlugin(),
|
||||||
vue(),
|
vue(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
|
@ -61,7 +69,7 @@ export default defineConfig(({ mode }) => ({
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
// Scope the service worker to only handle requests within this app's path
|
// Scope the service worker to only handle requests within this app's path
|
||||||
navigateFallback: 'activities.html',
|
navigateFallback: 'events.html',
|
||||||
navigateFallbackAllowlist: [
|
navigateFallbackAllowlist: [
|
||||||
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||||
],
|
],
|
||||||
|
|
@ -76,18 +84,17 @@ export default defineConfig(({ mode }) => ({
|
||||||
'icon-maskable-512.png',
|
'icon-maskable-512.png',
|
||||||
],
|
],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Sortir — Activités & Événements',
|
name: APP_NAME,
|
||||||
short_name: 'Sortir',
|
short_name: APP_NAME,
|
||||||
description: 'Découvrez les activités et événements près de chez vous',
|
description: 'Discover events near you',
|
||||||
theme_color: '#1f2937',
|
theme_color: '#1f2937',
|
||||||
background_color: '#ffffff',
|
background_color: '#ffffff',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
orientation: 'portrait-primary',
|
orientation: 'portrait-primary',
|
||||||
start_url: process.env.VITE_BASE_PATH || '/',
|
start_url: process.env.VITE_BASE_PATH || '/',
|
||||||
scope: process.env.VITE_BASE_PATH || '/',
|
scope: process.env.VITE_BASE_PATH || '/',
|
||||||
id: 'sortir-activities',
|
id: 'aiolabs-events',
|
||||||
categories: ['social', 'entertainment', 'lifestyle'],
|
categories: ['social', 'entertainment', 'lifestyle'],
|
||||||
lang: 'fr',
|
|
||||||
icons: [
|
icons: [
|
||||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
|
@ -104,7 +111,7 @@ export default defineConfig(({ mode }) => ({
|
||||||
mode === 'analyze' &&
|
mode === 'analyze' &&
|
||||||
visualizer({
|
visualizer({
|
||||||
open: true,
|
open: true,
|
||||||
filename: 'dist-activities/stats.html',
|
filename: 'dist-events/stats.html',
|
||||||
gzipSize: true,
|
gzipSize: true,
|
||||||
brotliSize: true,
|
brotliSize: true,
|
||||||
}),
|
}),
|
||||||
|
|
@ -115,9 +122,9 @@ export default defineConfig(({ mode }) => ({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist-activities',
|
outDir: 'dist-events',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: 'activities.html',
|
input: 'events.html',
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||||
Loading…
Add table
Add a link
Reference in a new issue