Compare commits

..

No commits in common. "1f20d5f00ce6964535102f9b64843d6b1c2a7304" and "2bc0b9d57bb196ed97d15579e04278ab12f58411" have entirely different histories.

73 changed files with 3439 additions and 3923 deletions

View file

@ -11,13 +11,6 @@ VITE_API_KEY=your-api-key-here
VITE_LNBITS_DEBUG=false VITE_LNBITS_DEBUG=false
VITE_WEBSOCKET_ENABLED=true VITE_WEBSOCKET_ENABLED=true
# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint).
# Logged by the LNbits server at startup:
# `Nostr transport: starting with pubkey <hex>... on N relay(s)`
# Required for the activities ticket scanner; legacy HTTP path still
# works without it.
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY=
# Lightning Address Domain (optional) # Lightning Address Domain (optional)
# Override the domain used for Lightning Addresses # Override the domain used for Lightning Addresses
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL # If not set, domain will be extracted from VITE_LNBITS_BASE_URL

View file

@ -714,90 +714,6 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
VITE_WEBSOCKET_ENABLED=true VITE_WEBSOCKET_ENABLED=true
``` ```
## Payment Rails Pattern
Shared primitives for modules that mix Lightning + fiat (and, future,
cash / internal-wallet) payment rails. Activities is the first
consumer; restaurant + marketplace will adopt the same primitives as
their backends gain fiat support.
### Vocabulary (canonical — used in code AND UI labels)
| Term | Meaning | Field |
|---|---|---|
| **Price currency** | unit the price is quoted in | `currency` |
| **Payment method** | the rail used to pay (Lightning / Fiat / Cash / Internal) | `payment_method` |
| **Fiat currency** | currency the fiat provider settles in | `fiat_currency` |
| **Also accept fiat** | the user-facing label for the `allow_fiat` toggle | `allow_fiat` |
The bare word `Currency` is **banned** in payment-context UI labels —
it always carries a `Price` or `Fiat` qualifier. The literal string
`Pay in fiat` is also banned on buyer-side buttons — each fiat rail
shows as a button labeled with its provider name (`Stripe`, `PayPal`,
`Square`, `SEPA`). Only the degenerate "providers unknown" fallback
shows a generic `Card`.
### Fiat-provider architecture (LNbits today)
Fiat providers are configured **globally** by the LNbits admin
(`lnbits/settings.py`). Each provider has an optional `allowed_users`
whitelist; the per-session filtered list is exposed as
`User.fiat_providers: string[]` on `GET /api/v1/auth` (which the
webapp already reads as `currentUser.fiat_providers`). Both organizer
and buyer on the same instance see the same list.
Per-user provider configuration is a deferred backend feature. Until
then, `useFiatProviders` reads the same `currentUser.fiat_providers`
for both sides.
### Shared primitives (live in base module)
```
src/modules/base/
├── composables/
│ ├── useFiatProviders.ts // providers + hasAnyProvider + refresh + providerMeta()
│ └── usePriceConversion.ts // convert() + useLivePreview(); 60s cache; null on failure
└── components/payments/
├── PaymentMethodSelector.vue // buyer-side rail picker
├── FiatToggleField.vue // organizer-side toggle + conditional fiat-currency dropdown
└── PriceConversionPreview.vue // muted "≈ X.XX USD" line
```
All three components consume services via DI — never import them
directly across module boundaries.
### `PaymentMethodSelector` data shape
```ts
type PaymentRail = 'lightning' | 'fiat' | 'cash' | 'internal' | (string & {})
type PaymentMethod = {
id: string // unique v-for key, e.g. 'fiat:stripe'
rail: PaymentRail // sent as payment_method
provider?: string // sent as fiat_provider when present
label: string // 'Lightning' | 'Stripe' | 'SEPA' | 'Cash' | …
icon: Component // lucide icon
available: boolean // false ⇒ rendered disabled with tooltip
unavailableReason?: string // tooltip when disabled
badge?: string // optional secondary hint, e.g. '≈ 1000 sats'
}
```
Module usage:
- **Activities** passes `[lightning, ...one entry per organizer provider]`.
- **Restaurant** (future) passes the subset of
`[lightning, cash, internal, ...fiat providers]` enabled by the
restaurant's `accepts_*` flags.
### Adding a new fiat provider
1. Backend exposes the provider id in `User.fiat_providers`.
2. Add the id to `KNOWN_PROVIDERS` in `useFiatProviders.ts` with its
display label and icon hint (`'card' | 'bank' | 'wallet'`).
3. Unknown ids fall back to a `Capitalized` label with a `'card'`
icon hint — no code change required just for the buttons to
render, only for nice branding.
## Mobile Browser File Input & Form Refresh Issues ## Mobile Browser File Input & Form Refresh Issues
### **Problem Overview** ### **Problem Overview**

View file

@ -59,7 +59,7 @@
"light-bolt11-decoder": "^3.2.0", "light-bolt11-decoder": "^3.2.0",
"lucide-vue-next": "^0.474.0", "lucide-vue-next": "^0.474.0",
"ngeohash": "^0.6.3", "ngeohash": "^0.6.3",
"nostr-tools": "^2.23.3", "nostr-tools": "2.15.0",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",

86
pnpm-lock.yaml generated
View file

@ -57,8 +57,8 @@ importers:
specifier: ^0.6.3 specifier: ^0.6.3
version: 0.6.3 version: 0.6.3
nostr-tools: nostr-tools:
specifier: ^2.23.3 specifier: 2.15.0
version: 2.23.5(typescript@5.6.3) version: 2.15.0(typescript@5.6.3)
pinia: pinia:
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.1(typescript@5.6.3)(vue@3.5.34(typescript@5.6.3)) version: 2.3.1(typescript@5.6.3)(vue@3.5.34(typescript@5.6.3))
@ -1247,17 +1247,22 @@ packages:
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
'@noble/ciphers@2.1.1': '@noble/ciphers@0.5.3':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
engines: {node: '>= 20.19.0'}
'@noble/curves@2.0.1': '@noble/curves@1.1.0':
resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.0.1': '@noble/curves@1.2.0':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@1.3.1':
resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==}
engines: {node: '>= 16'}
'@noble/hashes@1.3.2':
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
engines: {node: '>= 16'}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@ -1473,14 +1478,11 @@ packages:
'@scure/base@1.1.1': '@scure/base@1.1.1':
resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==}
'@scure/base@2.0.0': '@scure/bip32@1.3.1':
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==}
'@scure/bip32@2.0.1': '@scure/bip39@1.2.1':
resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==}
'@scure/bip39@2.0.1':
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
'@sindresorhus/is@4.6.0': '@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
@ -3533,8 +3535,8 @@ packages:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'} engines: {node: '>=10'}
nostr-tools@2.23.5: nostr-tools@2.15.0:
resolution: {integrity: sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==} resolution: {integrity: sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==}
peerDependencies: peerDependencies:
typescript: '>=5.0.0' typescript: '>=5.0.0'
peerDependenciesMeta: peerDependenciesMeta:
@ -6202,13 +6204,19 @@ snapshots:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
'@noble/ciphers@2.1.1': {} '@noble/ciphers@0.5.3': {}
'@noble/curves@2.0.1': '@noble/curves@1.1.0':
dependencies: dependencies:
'@noble/hashes': 2.0.1 '@noble/hashes': 1.3.1
'@noble/hashes@2.0.1': {} '@noble/curves@1.2.0':
dependencies:
'@noble/hashes': 1.3.2
'@noble/hashes@1.3.1': {}
'@noble/hashes@1.3.2': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
@ -6354,18 +6362,16 @@ snapshots:
'@scure/base@1.1.1': {} '@scure/base@1.1.1': {}
'@scure/base@2.0.0': {} '@scure/bip32@1.3.1':
'@scure/bip32@2.0.1':
dependencies: dependencies:
'@noble/curves': 2.0.1 '@noble/curves': 1.1.0
'@noble/hashes': 2.0.1 '@noble/hashes': 1.3.1
'@scure/base': 2.0.0 '@scure/base': 1.1.1
'@scure/bip39@2.0.1': '@scure/bip39@1.2.1':
dependencies: dependencies:
'@noble/hashes': 2.0.1 '@noble/hashes': 1.3.1
'@scure/base': 2.0.0 '@scure/base': 1.1.1
'@sindresorhus/is@4.6.0': {} '@sindresorhus/is@4.6.0': {}
@ -8533,14 +8539,14 @@ snapshots:
normalize-url@6.1.0: {} normalize-url@6.1.0: {}
nostr-tools@2.23.5(typescript@5.6.3): nostr-tools@2.15.0(typescript@5.6.3):
dependencies: dependencies:
'@noble/ciphers': 2.1.1 '@noble/ciphers': 0.5.3
'@noble/curves': 2.0.1 '@noble/curves': 1.2.0
'@noble/hashes': 2.0.1 '@noble/hashes': 1.3.1
'@scure/base': 2.0.0 '@scure/base': 1.1.1
'@scure/bip32': 2.0.1 '@scure/bip32': 1.3.1
'@scure/bip39': 2.0.1 '@scure/bip39': 1.2.1
nostr-wasm: 0.1.0 nostr-wasm: 0.1.0
optionalDependencies: optionalDependencies:
typescript: 5.6.3 typescript: 5.6.3

View file

@ -98,11 +98,8 @@ async function loadData() {
totalIncomeSats.value = balanceData.total_income_sats || 0 totalIncomeSats.value = balanceData.total_income_sats || 0
totalIncomeFiat.value = balanceData.total_income_fiat || {} totalIncomeFiat.value = balanceData.total_income_fiat || {}
// Filter for pending transactions (flag = '!'), excluding voided ones // Filter for pending transactions (flag = '!')
// (libra convention: voided keeps '!' flag and carries a 'voided' tag). pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
pendingTransactions.value = txData.entries.filter(
tx => tx.flag === '!' && !tx.tags?.includes('voided')
)
} catch (error) { } catch (error) {
console.error('[BalancePage] Error loading data:', error) console.error('[BalancePage] Error loading data:', error)
toast.error('Failed to load balance data') toast.error('Failed to load balance data')

View file

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next' 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'
@ -16,7 +15,6 @@ import type { CreateEventRequest } from '@/modules/activities/types/ticket'
import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue' import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const activitiesStore = useActivitiesStore() const activitiesStore = useActivitiesStore()
@ -27,9 +25,9 @@ const { isAdmin, autoApprove } = useApprovalState()
const { loadOwnEvents } = useActivities() const { loadOwnEvents } = useActivities()
// 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 (auth-gated): activity creation is a deliberate
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of // act, surfacing it as a tab keeps it one tap away when authed and out of the
// opening the dialog. Per-app placement deliberation tracked at #53. // way when not. 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('activities.nav.feed'), icon: Search, path: '/activities' },
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' }, { name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
@ -37,15 +35,6 @@ const tabs = computed<BottomTab[]>(() => [
name: t('activities.createNew'), name: t('activities.createNew'),
icon: Plus, icon: Plus,
onClick: () => { onClick: () => {
if (!isAuthenticated.value) {
toast.info('Log in to create an activity', {
action: {
label: 'Log in',
onClick: () => router.push('/login'),
},
})
return
}
// 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 activitiesStore.editingEvent = null
@ -54,27 +43,7 @@ const tabs = computed<BottomTab[]>(() => [
disabled: !isAuthenticated.value, disabled: !isAuthenticated.value,
}, },
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' }, { name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
{ { name: t('activities.nav.favorites'), icon: Heart, path: '/activities/favorites' },
name: t('activities.nav.favorites'),
icon: Heart,
// path kept so the tab stays active-highlighted while the user is
// on /activities/favorites; onClick wins for the actual tap so we
// can gate on auth (mirrors the Create tab pattern above).
path: '/activities/favorites',
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('activities.favorites.loginPrompt'), {
action: {
label: t('activities.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
router.push('/activities/favorites')
},
disabled: !isAuthenticated.value,
},
]) ])
// Feed tab is active for the bare /activities route AND all sub-paths that // Feed tab is active for the bare /activities route AND all sub-paths that

View file

@ -1,195 +1,179 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import './themes/countrysidecastle.css';
@import './themes/darkmatter.css';
@import './themes/emeraldforest.css';
@import './themes/lightgreen.css';
@import './themes/neobrut.css';
@import './themes/starrynight.css';
@plugin 'tailwindcss-animate'; @plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-serif: var(--font-serif);
--font-mono: var(--font-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--shadow-2xs: var(--shadow-2xs); --color-background: oklch(var(--background));
--shadow-xs: var(--shadow-xs); --color-foreground: oklch(var(--foreground));
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--duration-fast: 150ms; --color-card: oklch(var(--card));
--duration-normal: 200ms; --color-card-foreground: oklch(var(--card-foreground));
--duration-slow: 300ms;
--ease-in: cubic-bezier(0.4, 0, 1, 1); --color-popover: oklch(var(--popover));
--ease-out: cubic-bezier(0, 0, 0.2, 1); --color-popover-foreground: oklch(var(--popover-foreground));
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--color-primary: oklch(var(--primary));
--color-primary-foreground: oklch(var(--primary-foreground));
--color-secondary: oklch(var(--secondary));
--color-secondary-foreground: oklch(var(--secondary-foreground));
--color-muted: oklch(var(--muted));
--color-muted-foreground: oklch(var(--muted-foreground));
--color-accent: oklch(var(--accent));
--color-accent-foreground: oklch(var(--accent-foreground));
--color-destructive: oklch(var(--destructive));
--color-destructive-foreground: oklch(var(--destructive-foreground));
--color-border: oklch(var(--border));
--color-input: oklch(var(--input));
--color-ring: oklch(var(--ring));
--color-chart-1: oklch(var(--chart-1));
--color-chart-2: oklch(var(--chart-2));
--color-chart-3: oklch(var(--chart-3));
--color-chart-4: oklch(var(--chart-4));
--color-chart-5: oklch(var(--chart-5));
--animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down { @keyframes accordion-down {
from { height: 0; } from {
to { height: var(--reka-accordion-content-height); } height: 0;
}
to {
height: var(--reka-accordion-content-height);
}
} }
@keyframes accordion-up { @keyframes accordion-up {
from { height: var(--reka-accordion-content-height); } from {
to { height: 0; } height: var(--reka-accordion-content-height);
}
to {
height: 0;
} }
} }
/* Default palette: Catppuccin (Latte for light, Mocha for dark). /* Add standard shadcn animation durations */
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */ --duration-fast: 150ms;
:root { --duration-normal: 200ms;
--background: oklch(0.9578 0.0058 264.5321); --duration-slow: 300ms;
--foreground: oklch(0.4355 0.0430 279.3250);
--card: oklch(1.0000 0 0); /* Add standard shadcn easings */
--card-foreground: oklch(0.4355 0.0430 279.3250); --ease-in: cubic-bezier(0.4, 0, 1, 1);
--popover: oklch(0.8575 0.0145 268.4756); --ease-out: cubic-bezier(0, 0, 0.2, 1);
--popover-foreground: oklch(0.4355 0.0430 279.3250); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--primary: oklch(0.5547 0.2503 297.0156);
--primary-foreground: oklch(1.0000 0 0); /* Add standard shadcn animations */
--secondary: oklch(0.8575 0.0145 268.4756); --animate-in: animate-in var(--duration-normal) var(--ease-out);
--secondary-foreground: oklch(0.4355 0.0430 279.3250); --animate-out: animate-out var(--duration-normal) var(--ease-in);
--muted: oklch(0.9060 0.0117 264.5071);
--muted-foreground: oklch(0.5471 0.0343 279.0837); --animate-fade-in: fade-in var(--duration-normal) var(--ease-out);
--accent: oklch(0.6820 0.1448 235.3822); --animate-fade-out: fade-out var(--duration-normal) var(--ease-in);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0.5505 0.2155 19.8095); --animate-slide-in-from-top: slide-in-from-top var(--duration-normal) var(--ease-out);
--destructive-foreground: oklch(1.0000 0 0); --animate-slide-out-to-top: slide-out-to-top var(--duration-normal) var(--ease-in);
--border: oklch(0.8083 0.0174 271.1982);
--input: oklch(0.8575 0.0145 268.4756); --animate-slide-in-from-bottom: slide-in-from-bottom var(--duration-normal) var(--ease-out);
--ring: oklch(0.5547 0.2503 297.0156); --animate-slide-out-to-bottom: slide-out-to-bottom var(--duration-normal) var(--ease-in);
--chart-1: oklch(0.5547 0.2503 297.0156);
--chart-2: oklch(0.6820 0.1448 235.3822); --animate-slide-in-from-left: slide-in-from-left var(--duration-normal) var(--ease-out);
--chart-3: oklch(0.6250 0.1772 140.4448); --animate-slide-out-to-left: slide-out-to-left var(--duration-normal) var(--ease-in);
--chart-4: oklch(0.6920 0.2041 42.4293);
--chart-5: oklch(0.7141 0.1045 33.0967); --animate-slide-in-from-right: slide-in-from-right var(--duration-normal) var(--ease-out);
--sidebar: oklch(0.9335 0.0087 264.5206); --animate-slide-out-to-right: slide-out-to-right var(--duration-normal) var(--ease-in);
--sidebar-foreground: oklch(0.4355 0.0430 279.3250);
--sidebar-primary: oklch(0.5547 0.2503 297.0156);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.6820 0.1448 235.3822);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0.8083 0.0174 271.1982);
--sidebar-ring: oklch(0.5547 0.2503 297.0156);
--font-sans: Montserrat, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Fira Code, monospace;
--radius: 0.35rem;
--shadow-2xs: 0px 4px 6px 0px hsl(240 30% 25% / 0.06);
--shadow-xs: 0px 4px 6px 0px hsl(240 30% 25% / 0.06);
--shadow-sm: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 1px 2px -1px hsl(240 30% 25% / 0.12);
--shadow: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 1px 2px -1px hsl(240 30% 25% / 0.12);
--shadow-md: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 2px 4px -1px hsl(240 30% 25% / 0.12);
--shadow-lg: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 4px 6px -1px hsl(240 30% 25% / 0.12);
--shadow-xl: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 8px 10px -1px hsl(240 30% 25% / 0.12);
--shadow-2xl: 0px 4px 6px 0px hsl(240 30% 25% / 0.30);
} }
.dark { /*
--background: oklch(0.2155 0.0254 284.0647); The default border color has changed to `currentColor` in Tailwind CSS v4,
--foreground: oklch(0.8787 0.0426 272.2767); so we've added these compatibility styles to make sure everything still
--card: oklch(0.2429 0.0304 283.9110); looks the same as it did with Tailwind CSS v3.
--card-foreground: oklch(0.8787 0.0426 272.2767);
--popover: oklch(0.4037 0.0320 280.1520);
--popover-foreground: oklch(0.8787 0.0426 272.2767);
--primary: oklch(0.7871 0.1187 304.7693);
--primary-foreground: oklch(0.2429 0.0304 283.9110);
--secondary: oklch(0.4765 0.0340 278.6430);
--secondary-foreground: oklch(0.8787 0.0426 272.2767);
--muted: oklch(0.2973 0.0294 276.2144);
--muted-foreground: oklch(0.7510 0.0396 273.9320);
--accent: oklch(0.8467 0.0833 210.2545);
--accent-foreground: oklch(0.2429 0.0304 283.9110);
--destructive: oklch(0.7556 0.1297 2.7642);
--destructive-foreground: oklch(0.2429 0.0304 283.9110);
--border: oklch(0.3240 0.0319 281.9784);
--input: oklch(0.3240 0.0319 281.9784);
--ring: oklch(0.7871 0.1187 304.7693);
--chart-1: oklch(0.7871 0.1187 304.7693);
--chart-2: oklch(0.8467 0.0833 210.2545);
--chart-3: oklch(0.8577 0.1092 142.7153);
--chart-4: oklch(0.8237 0.1015 52.6294);
--chart-5: oklch(0.9226 0.0238 30.4919);
--sidebar: oklch(0.1828 0.0204 284.2039);
--sidebar-foreground: oklch(0.8787 0.0426 272.2767);
--sidebar-primary: oklch(0.7871 0.1187 304.7693);
--sidebar-primary-foreground: oklch(0.2429 0.0304 283.9110);
--sidebar-accent: oklch(0.8467 0.0833 210.2545);
--sidebar-accent-foreground: oklch(0.2429 0.0304 283.9110);
--sidebar-border: oklch(0.4037 0.0320 280.1520);
--sidebar-ring: oklch(0.7871 0.1187 304.7693);
}
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base { @layer base {
*, *,
::after, ::after,
::before, ::before,
::backdrop, ::backdrop,
::file-selector-button { ::file-selector-button {
border-color: var(--color-border, currentColor); border-color: var(--color-gray-200, currentColor);
}
} }
@layer base {
:root {
/* Catppuccin Latte - Enhanced */
--background: 0.98 0.005 235; /* base - slightly brighter */
--foreground: 0.25 0.02 235; /* text - darker for contrast */
--card: 1.0 0.005 235; /* pure white for cards */
--card-foreground: 0.25 0.02 235;
--popover: 1.0 0.005 235;
--popover-foreground: 0.25 0.02 235;
--primary: 0.60 0.18 250; /* lavender - more saturated */
--primary-foreground: 1.0 0.005 235;
--secondary: 0.92 0.04 235; /* surface1 - more distinct */
--secondary-foreground: 0.25 0.02 235;
--muted: 0.95 0.03 235; /* surface0 - slightly more color */
--muted-foreground: 0.45 0.03 235; /* subtext0 - better contrast */
--accent: 0.70 0.18 290; /* mauve - more saturated */
--accent-foreground: 0.25 0.02 235;
--destructive: 0.70 0.28 0; /* red - more vibrant */
--destructive-foreground: 1.0 0.005 235;
--border: 0.90 0.04 235; /* surface1 - more visible */
--input: 0.90 0.04 235;
--ring: 0.60 0.18 250; /* matching primary */
--chart-1: 0.70 0.28 0; /* red - more vibrant */
--chart-2: 0.80 0.25 30; /* peach - more saturated */
--chart-3: 0.85 0.20 90; /* yellow - more saturated */
--chart-4: 0.75 0.20 150; /* green - more saturated */
--chart-5: 0.65 0.20 180; /* blue - more saturated */
--radius: 0.5rem;
}
.dark {
/* Catppuccin Mocha - Enhanced */
--background: 0.25 0.03 256; /* base #1e1e2e */
--foreground: 0.98 0.02 256; /* text #cdd6f4 - slightly brighter */
--card: 0.27 0.03 256; /* slightly lighter than background */
--card-foreground: 0.98 0.02 256;
--popover: 0.27 0.03 256;
--popover-foreground: 0.98 0.02 256;
--primary: 0.80 0.15 270; /* lavender #b4befe - more saturated */
--primary-foreground: 0.20 0.02 256;
--secondary: 0.32 0.04 256; /* surface1 #313244 - more distinct */
--secondary-foreground: 0.98 0.02 256;
--muted: 0.29 0.03 256; /* surface0 #2a2b3c */
--muted-foreground: 0.85 0.03 256; /* subtext0 - brighter */
--accent: 0.75 0.18 300; /* mauve #cba6f7 - more saturated */
--accent-foreground: 0.20 0.02 256;
--destructive: 0.75 0.28 10; /* red #f38ba8 - more vibrant */
--destructive-foreground: 0.98 0.02 256;
--border: 0.32 0.04 256; /* slightly more visible borders */
--input: 0.32 0.04 256;
--ring: 0.80 0.15 270; /* matching primary */
--chart-1: 0.75 0.28 10; /* red #f38ba8 */
--chart-2: 0.85 0.22 40; /* peach #fab387 */
--chart-3: 0.88 0.18 95; /* yellow #f9e2af */
--chart-4: 0.80 0.18 155; /* green #a6e3a1 */
--chart-5: 0.70 0.18 190; /* blue #89b4fa */
}
}
@layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }

64
src/assets/index.css.gruv Normal file
View file

@ -0,0 +1,64 @@
/* ... add other utility classes as needed ... */
/* :root { */
/* /* gruvbox light theme */ */
/* --color-background: hsl(32 92% 87%); /* bg0 */ */
/* --color-foreground: hsl(40 13% 23%); /* fg */ */
/**/
/* --color-card: hsl(39 59% 88%); /* bg1 */ */
/* --color-card-foreground: hsl(40 13% 23%); /* fg */ */
/**/
/* --color-popover: hsl(39 59% 88%); /* bg1 */ */
/* --color-popover-foreground: hsl(40 13% 23%); /* fg */ */
/**/
/* --color-primary: hsl(0 100% 31%); /* red */ */
/* --color-primary-foreground: hsl(40 92% 88%); /* bg */ */
/**/
/* --color-secondary: hsl(39 46% 81%); /* bg2 */ */
/* --color-secondary-foreground: hsl(40 13% 23%); /* fg */ */
/**/
/* --color-muted: hsl(37 29% 73%); /* bg3 */ */
/* --color-muted-foreground: hsl(40 4% 36%); /* gray */ */
/**/
/* --color-accent: hsl(40 71% 49%); /* yellow */ */
/* --color-accent-foreground: hsl(0 0% 16%); */
/**/
/* --color-destructive: hsl(0 76% 46%); /* bright_red */ */
/* --color-destructive-foreground: hsl(40 92% 88%); /* bg */ */
/**/
/* --color-border: hsl(33 14% 59%); /* fg4 */ */
/* --color-input: hsl(33 14% 59%); /* fg4 */ */
/* --color-ring: hsl(0 100% 31%); /* red */ */
/**/
/* --radius: 0.5rem; */
/* } */
/* .dark { */
/* /* gruvbox dark theme */ */
/* --color-background: hsl(0 0% 16%); /* bg0 */ */
/* --color-foreground: hsl(40 92% 88%); /* fg */ */
/**/
/* --color-card: hsl(0 7% 23%); /* bg1 */ */
/* --color-card-foreground: hsl(40 92% 88%); /* fg */ */
/**/
/* --color-popover: hsl(0 7% 23%); /* bg1 */ */
/* --color-popover-foreground: hsl(40 92% 88%); /* fg */ */
/**/
/* --color-primary: hsl(6 93% 59%); /* red */ */
/* --color-primary-foreground: hsl(0 0% 16%); /* bg */ */
/**/
/* --color-secondary: hsl(0 5% 29%); /* bg2 */ */
/* --color-secondary-foreground: hsl(40 92% 88%); /* fg */ */
/**/
/* --color-muted: hsl(20 6% 36%); /* bg3 */ */
/* --color-muted-foreground: hsl(33 14% 59%); /* gray */ */
/**/
/* --color-accent: hsl(42 95% 58%); /* yellow */ */
/* --color-accent-foreground: hsl(0 0% 16%); */
/**/
/* --color-destructive: hsl(6 93% 59%); /* bright_red */ */
/* --color-destructive-foreground: hsl(40 92% 88%); /* bg */ */
/**/
/* --color-border: hsl(24 10% 51%); /* fg4 */ */
/* --color-input: hsl(24 10% 51%); /* fg4 */ */
/* --color-ring: hsl(6 93% 59%); /* red */ */
/* } */

View file

@ -1,89 +0,0 @@
:root[data-theme='countrysidecastle'] {
--background: oklch(0.9730 0.0058 84.5664);
--foreground: oklch(0.3247 0.0226 57.3596);
--card: oklch(0.9459 0.0117 84.5794);
--card-foreground: oklch(0.2724 0.0177 57.4319);
--popover: oklch(0.9595 0.0088 84.5733);
--popover-foreground: oklch(0.2175 0.0125 57.5482);
--primary: oklch(0.5698 0.0614 230.9798);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.8197 0.0295 76.4350);
--secondary-foreground: oklch(0.3750 0.0273 57.3103);
--muted: oklch(0.8903 0.0080 91.4872);
--muted-foreground: oklch(0.5129 0.0202 57.8140);
--accent: oklch(0.5425 0.0625 154.6280);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0.5107 0.1226 22.3963);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8540 0.0188 76.5358);
--input: oklch(0.9130 0.0111 76.5933);
--ring: oklch(0.5698 0.0614 230.9798);
--chart-1: oklch(0.5698 0.0614 230.9798);
--chart-2: oklch(0.5425 0.0625 154.6280);
--chart-3: oklch(0.6550 0.0929 74.2249);
--chart-4: oklch(0.5570 0.0685 47.4365);
--chart-5: oklch(0.7343 0.0644 91.8078);
--sidebar: oklch(0.9266 0.0069 76.6174);
--sidebar-foreground: oklch(0.4237 0.0317 57.2745);
--sidebar-primary: oklch(0.5698 0.0614 230.9798);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.8912 0.0161 156.8939);
--sidebar-accent-foreground: oklch(0.3497 0.0513 153.7441);
--sidebar-border: oklch(0.8649 0.0085 76.6058);
--sidebar-ring: oklch(0.5698 0.0614 230.9798);
--font-sans: Garamond, 'Eb Garamond', serif;
--font-serif: Palatino, 'Palatino Linotype', serif;
--font-mono: monospace;
--radius: 0.3rem;
--shadow-2xs: 0px 4px 12px 2px hsl(25 20% 10% / 0.05);
--shadow-xs: 0px 4px 12px 2px hsl(25 20% 10% / 0.05);
--shadow-sm: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 1px 2px 1px hsl(25 20% 10% / 0.10);
--shadow: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 1px 2px 1px hsl(25 20% 10% / 0.10);
--shadow-md: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 2px 4px 1px hsl(25 20% 10% / 0.10);
--shadow-lg: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 4px 6px 1px hsl(25 20% 10% / 0.10);
--shadow-xl: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 8px 10px 1px hsl(25 20% 10% / 0.10);
--shadow-2xl: 0px 4px 12px 2px hsl(25 20% 10% / 0.25);
}
:root[data-theme='countrysidecastle'].dark {
--background: oklch(0.2299 0.0198 256.8306);
--foreground: oklch(0.8894 0.0105 76.5953);
--card: oklch(0.2605 0.0240 256.8358);
--card-foreground: oklch(0.9266 0.0069 76.6174);
--popover: oklch(0.2090 0.0169 256.8258);
--popover-foreground: oklch(0.9635 0.0034 76.6361);
--primary: oklch(0.7041 0.0385 211.1736);
--primary-foreground: oklch(0.2079 0.0203 256.8404);
--secondary: oklch(0.4265 0.0167 76.4097);
--secondary-foreground: oklch(0.8894 0.0105 76.5953);
--muted: oklch(0.3137 0.0183 256.8010);
--muted-foreground: oklch(0.7169 0.0173 256.7349);
--accent: oklch(0.5342 0.0451 158.8384);
--accent-foreground: oklch(0.9635 0.0034 76.6361);
--destructive: oklch(0.4708 0.1367 24.0033);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.3591 0.0295 256.8271);
--input: oklch(0.2920 0.0224 256.8218);
--ring: oklch(0.7041 0.0385 211.1736);
--chart-1: oklch(0.7041 0.0385 211.1736);
--chart-2: oklch(0.5810 0.0497 158.8087);
--chart-3: oklch(0.6763 0.0649 75.6254);
--chart-4: oklch(0.6009 0.0747 47.4204);
--chart-5: oklch(0.5781 0.0524 256.8345);
--sidebar: oklch(0.1974 0.0185 256.8372);
--sidebar-foreground: oklch(0.8518 0.0141 76.5687);
--sidebar-primary: oklch(0.7041 0.0385 211.1736);
--sidebar-primary-foreground: oklch(0.2079 0.0203 256.8404);
--sidebar-accent: oklch(0.2920 0.0224 256.8218);
--sidebar-accent-foreground: oklch(0.8544 0.0190 211.0454);
--sidebar-border: oklch(0.3095 0.0306 256.8416);
--sidebar-ring: oklch(0.7041 0.0385 211.1736);
--shadow-2xs: 0px 10px 20px 5px hsl(215 40% 5% / 0.25);
--shadow-xs: 0px 10px 20px 5px hsl(215 40% 5% / 0.25);
--shadow-sm: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 1px 2px 4px hsl(215 40% 5% / 0.50);
--shadow: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 1px 2px 4px hsl(215 40% 5% / 0.50);
--shadow-md: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 2px 4px 4px hsl(215 40% 5% / 0.50);
--shadow-lg: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 4px 6px 4px hsl(215 40% 5% / 0.50);
--shadow-xl: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 8px 10px 4px hsl(215 40% 5% / 0.50);
--shadow-2xl: 0px 10px 20px 5px hsl(215 40% 5% / 1.25);
}

View file

@ -1,81 +0,0 @@
:root[data-theme='darkmatter'] {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.2101 0.0318 264.6645);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.2101 0.0318 264.6645);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2101 0.0318 264.6645);
--primary: oklch(0.6716 0.1368 48.5130);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.5360 0.0398 196.0280);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9670 0.0029 264.5419);
--muted-foreground: oklch(0.5510 0.0234 264.3637);
--accent: oklch(0.9491 0 0);
--accent-foreground: oklch(0.2101 0.0318 264.6645);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(0.9851 0 0);
--border: oklch(0.9276 0.0058 264.5313);
--input: oklch(0.9276 0.0058 264.5313);
--ring: oklch(0.6716 0.1368 48.5130);
--chart-1: oklch(0.5940 0.0443 196.0233);
--chart-2: oklch(0.7214 0.1337 49.9802);
--chart-3: oklch(0.8721 0.0864 68.5474);
--chart-4: oklch(0.6268 0 0);
--chart-5: oklch(0.6830 0 0);
--sidebar: oklch(0.9670 0.0029 264.5419);
--sidebar-foreground: oklch(0.2101 0.0318 264.6645);
--sidebar-primary: oklch(0.6716 0.1368 48.5130);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(1.0000 0 0);
--sidebar-accent-foreground: oklch(0.2101 0.0318 264.6645);
--sidebar-border: oklch(0.9276 0.0058 264.5313);
--sidebar-ring: oklch(0.6716 0.1368 48.5130);
--font-sans: Geist Mono, ui-monospace, monospace;
--font-serif: serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.75rem;
--shadow-2xs: 0px 1px 4px 0px hsl(0 0% 0% / 0.03);
--shadow-xs: 0px 1px 4px 0px hsl(0 0% 0% / 0.03);
--shadow-sm: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow-md: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 2px 4px -1px hsl(0 0% 0% / 0.05);
--shadow-lg: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 4px 6px -1px hsl(0 0% 0% / 0.05);
--shadow-xl: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 8px 10px -1px hsl(0 0% 0% / 0.05);
--shadow-2xl: 0px 1px 4px 0px hsl(0 0% 0% / 0.13);
}
:root[data-theme='darkmatter'].dark {
--background: oklch(0.1797 0.0043 308.1928);
--foreground: oklch(0.8109 0 0);
--card: oklch(0.1822 0 0);
--card-foreground: oklch(0.8109 0 0);
--popover: oklch(0.1797 0.0043 308.1928);
--popover-foreground: oklch(0.8109 0 0);
--primary: oklch(0.7214 0.1337 49.9802);
--primary-foreground: oklch(0.1797 0.0043 308.1928);
--secondary: oklch(0.5940 0.0443 196.0233);
--secondary-foreground: oklch(0.1797 0.0043 308.1928);
--muted: oklch(0.2520 0 0);
--muted-foreground: oklch(0.6268 0 0);
--accent: oklch(0.3211 0 0);
--accent-foreground: oklch(0.8109 0 0);
--destructive: oklch(0.5940 0.0443 196.0233);
--destructive-foreground: oklch(0.1797 0.0043 308.1928);
--border: oklch(0.2520 0 0);
--input: oklch(0.2520 0 0);
--ring: oklch(0.7214 0.1337 49.9802);
--chart-1: oklch(0.5940 0.0443 196.0233);
--chart-2: oklch(0.7214 0.1337 49.9802);
--chart-3: oklch(0.8721 0.0864 68.5474);
--chart-4: oklch(0.6268 0 0);
--chart-5: oklch(0.6830 0 0);
--sidebar: oklch(0.1822 0 0);
--sidebar-foreground: oklch(0.8109 0 0);
--sidebar-primary: oklch(0.7214 0.1337 49.9802);
--sidebar-primary-foreground: oklch(0.1797 0.0043 308.1928);
--sidebar-accent: oklch(0.3211 0 0);
--sidebar-accent-foreground: oklch(0.8109 0 0);
--sidebar-border: oklch(0.2520 0 0);
--sidebar-ring: oklch(0.7214 0.1337 49.9802);
}

View file

@ -1,89 +0,0 @@
:root[data-theme='emeraldforest'] {
--background: oklch(0.9793 0.0077 145.5161);
--foreground: oklch(0.2464 0.0358 168.9829);
--card: oklch(0.9649 0.0108 145.4913);
--card-foreground: oklch(0.2464 0.0358 168.9829);
--popover: oklch(0.9793 0.0077 145.5161);
--popover-foreground: oklch(0.2464 0.0358 168.9829);
--primary: oklch(0.5767 0.1258 156.2896);
--primary-foreground: oklch(0.9860 0.0025 165.0756);
--secondary: oklch(0.8935 0.0214 156.7700);
--secondary-foreground: oklch(0.3633 0.0586 159.6692);
--muted: oklch(0.9274 0.0118 150.6461);
--muted-foreground: oklch(0.4883 0.0348 172.3051);
--accent: oklch(0.9398 0.0317 164.2277);
--accent-foreground: oklch(0.4557 0.0902 161.0214);
--destructive: oklch(0.5714 0.2121 27.2502);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8935 0.0214 156.7700);
--input: oklch(0.9293 0.0141 156.9525);
--ring: oklch(0.5767 0.1258 156.2896);
--chart-1: oklch(0.6610 0.1560 154.8128);
--chart-2: oklch(0.6335 0.1421 147.0021);
--chart-3: oklch(0.5044 0.0769 179.9212);
--chart-4: oklch(0.7869 0.1601 151.8584);
--chart-5: oklch(0.6954 0.1084 168.3427);
--sidebar: oklch(0.9588 0.0148 147.9012);
--sidebar-foreground: oklch(0.3142 0.0494 168.2500);
--sidebar-primary: oklch(0.5767 0.1258 156.2896);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9168 0.0213 156.7859);
--sidebar-accent-foreground: oklch(0.4542 0.0965 156.7126);
--sidebar-border: oklch(0.9150 0.0170 156.8819);
--sidebar-ring: oklch(0.5767 0.1258 156.2896);
--font-sans: Inter, sans-serif;
--font-serif: Georgia, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.5rem;
--shadow-2xs: 0px 2px 10px 0px hsl(160 50% 10% / 0.03);
--shadow-xs: 0px 2px 10px 0px hsl(160 50% 10% / 0.03);
--shadow-sm: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 1px 2px -1px hsl(160 50% 10% / 0.05);
--shadow: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 1px 2px -1px hsl(160 50% 10% / 0.05);
--shadow-md: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 2px 4px -1px hsl(160 50% 10% / 0.05);
--shadow-lg: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 4px 6px -1px hsl(160 50% 10% / 0.05);
--shadow-xl: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 8px 10px -1px hsl(160 50% 10% / 0.05);
--shadow-2xl: 0px 2px 10px 0px hsl(160 50% 10% / 0.13);
}
:root[data-theme='emeraldforest'].dark {
--background: oklch(0.1554 0.0140 178.0345);
--foreground: oklch(0.9650 0.0064 164.9697);
--card: oklch(0.1965 0.0190 176.6910);
--card-foreground: oklch(0.9650 0.0064 164.9697);
--popover: oklch(0.1703 0.0163 177.0235);
--popover-foreground: oklch(0.9650 0.0064 164.9697);
--primary: oklch(0.7355 0.1793 154.0472);
--primary-foreground: oklch(0.1703 0.0163 177.0235);
--secondary: oklch(0.2896 0.0276 171.3798);
--secondary-foreground: oklch(0.9297 0.0128 164.7776);
--muted: oklch(0.2503 0.0187 172.1743);
--muted-foreground: oklch(0.7753 0.0200 164.4487);
--accent: oklch(0.3031 0.0438 164.4442);
--accent-foreground: oklch(0.9059 0.0980 161.8651);
--destructive: oklch(0.5181 0.1747 25.7610);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2852 0.0226 172.0143);
--input: oklch(0.3190 0.0263 171.8953);
--ring: oklch(0.7355 0.1793 154.0472);
--chart-1: oklch(0.7355 0.1793 154.0472);
--chart-2: oklch(0.7403 0.1521 151.7821);
--chart-3: oklch(0.6461 0.1081 178.6914);
--chart-4: oklch(0.5457 0.0949 162.7657);
--chart-5: oklch(0.7786 0.0938 157.7379);
--sidebar: oklch(0.1848 0.0187 176.5131);
--sidebar-foreground: oklch(0.9297 0.0128 164.7776);
--sidebar-primary: oklch(0.7355 0.1793 154.0472);
--sidebar-primary-foreground: oklch(0.1703 0.0163 177.0235);
--sidebar-accent: oklch(0.2503 0.0187 172.1743);
--sidebar-accent-foreground: oklch(0.8800 0.1137 161.0658);
--sidebar-border: oklch(0.2737 0.0213 172.0621);
--sidebar-ring: oklch(0.7355 0.1793 154.0472);
--shadow-2xs: 0px 4px 15px 0px hsl(0 0% 0% / 0.20);
--shadow-xs: 0px 4px 15px 0px hsl(0 0% 0% / 0.20);
--shadow-sm: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40);
--shadow: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40);
--shadow-md: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 2px 4px -1px hsl(0 0% 0% / 0.40);
--shadow-lg: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 4px 6px -1px hsl(0 0% 0% / 0.40);
--shadow-xl: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 8px 10px -1px hsl(0 0% 0% / 0.40);
--shadow-2xl: 0px 4px 15px 0px hsl(0 0% 0% / 1.00);
}

View file

@ -1,89 +0,0 @@
:root[data-theme='lightgreen'] {
--background: oklch(0.9892 0.0054 117.9205);
--foreground: oklch(0.2077 0.0398 265.7549);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.2077 0.0398 265.7549);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2077 0.0398 265.7549);
--primary: oklch(0.8871 0.2122 128.5041);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.3717 0.0392 257.2870);
--secondary-foreground: oklch(0.9842 0.0034 247.8575);
--muted: oklch(0.9683 0.0069 247.8956);
--muted-foreground: oklch(0.5544 0.0407 257.4166);
--accent: oklch(0.9819 0.0181 155.8263);
--accent-foreground: oklch(0.4479 0.1083 151.3277);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.9288 0.0126 255.5078);
--input: oklch(0.9288 0.0126 255.5078);
--ring: oklch(0.8871 0.2122 128.5041);
--chart-1: oklch(0.8871 0.2122 128.5041);
--chart-2: oklch(0.3717 0.0392 257.2870);
--chart-3: oklch(0.7227 0.1920 149.5793);
--chart-4: oklch(0.5544 0.0407 257.4166);
--chart-5: oklch(0.7107 0.0351 256.7878);
--sidebar: oklch(1.0000 0 0);
--sidebar-foreground: oklch(0.2077 0.0398 265.7549);
--sidebar-primary: oklch(0.8871 0.2122 128.5041);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.9842 0.0034 247.8575);
--sidebar-accent-foreground: oklch(0.2077 0.0398 265.7549);
--sidebar-border: oklch(0.9683 0.0069 247.8956);
--sidebar-ring: oklch(0.8871 0.2122 128.5041);
--font-sans: Inter, system-ui, sans-serif;
--font-serif: Georgia, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 1rem;
--shadow-2xs: 0px 8px 20px 0px hsl(0 0% 0% / 0.03);
--shadow-xs: 0px 8px 20px 0px hsl(0 0% 0% / 0.03);
--shadow-sm: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow-md: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 2px 4px -1px hsl(0 0% 0% / 0.05);
--shadow-lg: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 4px 6px -1px hsl(0 0% 0% / 0.05);
--shadow-xl: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 8px 10px -1px hsl(0 0% 0% / 0.05);
--shadow-2xl: 0px 8px 20px 0px hsl(0 0% 0% / 0.13);
}
:root[data-theme='lightgreen'].dark {
--background: oklch(0.1288 0.0406 264.6952);
--foreground: oklch(0.9842 0.0034 247.8575);
--card: oklch(0.2077 0.0398 265.7549);
--card-foreground: oklch(0.9842 0.0034 247.8575);
--popover: oklch(0.2077 0.0398 265.7549);
--popover-foreground: oklch(0.9842 0.0034 247.8575);
--primary: oklch(0.8871 0.2122 128.5041);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2795 0.0368 260.0310);
--secondary-foreground: oklch(0.9842 0.0034 247.8575);
--muted: oklch(0.2795 0.0368 260.0310);
--muted-foreground: oklch(0.7107 0.0351 256.7878);
--accent: oklch(0.3925 0.0896 152.5353);
--accent-foreground: oklch(0.8871 0.2122 128.5041);
--destructive: oklch(0.4437 0.1613 26.8994);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2795 0.0368 260.0310);
--input: oklch(0.2795 0.0368 260.0310);
--ring: oklch(0.8871 0.2122 128.5041);
--chart-1: oklch(0.8871 0.2122 128.5041);
--chart-2: oklch(0.6231 0.1880 259.8145);
--chart-3: oklch(0.7227 0.1920 149.5793);
--chart-4: oklch(0.6268 0.2325 303.9004);
--chart-5: oklch(0.7686 0.1647 70.0804);
--sidebar: oklch(0.1288 0.0406 264.6952);
--sidebar-foreground: oklch(0.9842 0.0034 247.8575);
--sidebar-primary: oklch(0.8871 0.2122 128.5041);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.2795 0.0368 260.0310);
--sidebar-accent-foreground: oklch(0.9842 0.0034 247.8575);
--sidebar-border: oklch(0.2795 0.0368 260.0310);
--sidebar-ring: oklch(0.8871 0.2122 128.5041);
--shadow-2xs: 0px 10px 25px 0px hsl(0 0% 0% / 0.20);
--shadow-xs: 0px 10px 25px 0px hsl(0 0% 0% / 0.20);
--shadow-sm: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40);
--shadow: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40);
--shadow-md: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 2px 4px -1px hsl(0 0% 0% / 0.40);
--shadow-lg: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 4px 6px -1px hsl(0 0% 0% / 0.40);
--shadow-xl: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 8px 10px -1px hsl(0 0% 0% / 0.40);
--shadow-2xl: 0px 10px 25px 0px hsl(0 0% 0% / 1.00);
}

View file

@ -1,81 +0,0 @@
:root[data-theme='neobrut'] {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
}
:root[data-theme='neobrut'].dark {
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.3211 0 0);
--card-foreground: oklch(1.0000 0 0);
--popover: oklch(0.3211 0 0);
--popover-foreground: oklch(1.0000 0 0);
--primary: oklch(0.7044 0.1872 23.1858);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.9691 0.2005 109.6228);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.2178 0 0);
--muted-foreground: oklch(0.8452 0 0);
--accent: oklch(0.6755 0.1765 252.2592);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(1.0000 0 0);
--destructive-foreground: oklch(0 0 0);
--border: oklch(1.0000 0 0);
--input: oklch(1.0000 0 0);
--ring: oklch(0.7044 0.1872 23.1858);
--chart-1: oklch(0.7044 0.1872 23.1858);
--chart-2: oklch(0.9691 0.2005 109.6228);
--chart-3: oklch(0.6755 0.1765 252.2592);
--chart-4: oklch(0.7395 0.2268 142.8504);
--chart-5: oklch(0.6131 0.2458 328.0714);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1.0000 0 0);
--sidebar-primary: oklch(0.7044 0.1872 23.1858);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.6755 0.1765 252.2592);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(1.0000 0 0);
--sidebar-ring: oklch(0.7044 0.1872 23.1858);
}

View file

@ -1,81 +0,0 @@
:root[data-theme='starrynight'] {
--background: oklch(0.9755 0.0045 258.3245);
--foreground: oklch(0.2558 0.0433 268.0662);
--card: oklch(0.9341 0.0132 251.5628);
--card-foreground: oklch(0.2558 0.0433 268.0662);
--popover: oklch(0.9856 0.0278 98.0540);
--popover-foreground: oklch(0.2558 0.0433 268.0662);
--primary: oklch(0.4815 0.1178 263.3758);
--primary-foreground: oklch(0.9856 0.0278 98.0540);
--secondary: oklch(0.8567 0.1164 81.0092);
--secondary-foreground: oklch(0.2558 0.0433 268.0662);
--muted: oklch(0.9202 0.0080 106.5563);
--muted-foreground: oklch(0.4815 0.1178 263.3758);
--accent: oklch(0.6896 0.0714 234.0387);
--accent-foreground: oklch(0.9856 0.0278 98.0540);
--destructive: oklch(0.2611 0.0376 322.5267);
--destructive-foreground: oklch(0.9856 0.0278 98.0540);
--border: oklch(0.7791 0.0156 251.1926);
--input: oklch(0.6896 0.0714 234.0387);
--ring: oklch(0.8567 0.1164 81.0092);
--chart-1: oklch(0.4815 0.1178 263.3758);
--chart-2: oklch(0.8567 0.1164 81.0092);
--chart-3: oklch(0.6896 0.0714 234.0387);
--chart-4: oklch(0.7791 0.0156 251.1926);
--chart-5: oklch(0.2611 0.0376 322.5267);
--sidebar: oklch(0.9341 0.0132 251.5628);
--sidebar-foreground: oklch(0.2558 0.0433 268.0662);
--sidebar-primary: oklch(0.4815 0.1178 263.3758);
--sidebar-primary-foreground: oklch(0.9856 0.0278 98.0540);
--sidebar-accent: oklch(0.8567 0.1164 81.0092);
--sidebar-accent-foreground: oklch(0.2558 0.0433 268.0662);
--sidebar-border: oklch(0.7791 0.0156 251.1926);
--sidebar-ring: oklch(0.8567 0.1164 81.0092);
--font-sans: Libre Baskerville, serif;
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
:root[data-theme='starrynight'].dark {
--background: oklch(0.2204 0.0198 275.8439);
--foreground: oklch(0.9366 0.0129 266.6974);
--card: oklch(0.2703 0.0407 281.3036);
--card-foreground: oklch(0.9366 0.0129 266.6974);
--popover: oklch(0.2703 0.0407 281.3036);
--popover-foreground: oklch(0.9097 0.1440 95.1120);
--primary: oklch(0.4815 0.1178 263.3758);
--primary-foreground: oklch(0.9097 0.1440 95.1120);
--secondary: oklch(0.9097 0.1440 95.1120);
--secondary-foreground: oklch(0.2703 0.0407 281.3036);
--muted: oklch(0.2424 0.0324 281.0890);
--muted-foreground: oklch(0.6243 0.0412 262.0375);
--accent: oklch(0.8469 0.0524 264.7751);
--accent-foreground: oklch(0.2204 0.0198 275.8439);
--destructive: oklch(0.5280 0.1200 357.1130);
--destructive-foreground: oklch(0.9097 0.1440 95.1120);
--border: oklch(0.3072 0.0287 281.7681);
--input: oklch(0.4815 0.1178 263.3758);
--ring: oklch(0.9097 0.1440 95.1120);
--chart-1: oklch(0.4815 0.1178 263.3758);
--chart-2: oklch(0.9097 0.1440 95.1120);
--chart-3: oklch(0.6896 0.0714 234.0387);
--chart-4: oklch(0.6243 0.0412 262.0375);
--chart-5: oklch(0.5280 0.1200 357.1130);
--sidebar: oklch(0.2703 0.0407 281.3036);
--sidebar-foreground: oklch(0.9366 0.0129 266.6974);
--sidebar-primary: oklch(0.4815 0.1178 263.3758);
--sidebar-primary-foreground: oklch(0.9097 0.1440 95.1120);
--sidebar-accent: oklch(0.9097 0.1440 95.1120);
--sidebar-accent-foreground: oklch(0.2703 0.0407 281.3036);
--sidebar-border: oklch(0.3072 0.0287 281.7681);
--sidebar-ring: oklch(0.9097 0.1440 95.1120);
}

View file

@ -2,9 +2,9 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { Sun, Moon, Monitor, Globe, Coins, Palette } from 'lucide-vue-next' import { Sun, Moon, Monitor, Globe, Coins } from 'lucide-vue-next'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { useTheme, PALETTES, type Palette as PaletteName } from '@/components/theme-provider' import { useTheme } from '@/components/theme-provider'
import { useLocale } from '@/composables/useLocale' import { useLocale } from '@/composables/useLocale'
import { import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
@ -14,14 +14,14 @@ import {
interface Props { interface Props {
/** 'row' = three icon-stacked buttons side-by-side (used by Hub bottom nav). /** 'row' = three icon-stacked buttons side-by-side (used by Hub bottom nav).
* 'list' = full-width list rows (used inside the profile sheet). */ * 'list' = three full-width list rows (used inside the profile sheet). */
layout?: 'row' | 'list' layout?: 'row' | 'list'
} }
const props = withDefaults(defineProps<Props>(), { layout: 'row' }) const props = withDefaults(defineProps<Props>(), { layout: 'row' })
const { t } = useI18n() const { t } = useI18n()
const { theme, setTheme, currentTheme, palette, setPalette } = useTheme() const { theme, setTheme, currentTheme } = useTheme()
const { currentLocale, locales, setLocale } = useLocale() const { currentLocale, locales, setLocale } = useLocale()
const ThemeIcon = computed(() => (currentTheme.value === 'dark' ? Moon : Sun)) const ThemeIcon = computed(() => (currentTheme.value === 'dark' ? Moon : Sun))
@ -29,10 +29,6 @@ const currentLocaleLabel = computed(
() => locales.value.find(l => l.code === currentLocale.value)?.name ?? currentLocale.value () => locales.value.find(l => l.code === currentLocale.value)?.name ?? currentLocale.value
) )
const paletteLabel = (p: PaletteName) =>
t(`common.nav.palette_${p}`)
const currentPaletteLabel = computed(() => paletteLabel(palette.value))
// Currency picker is intentionally still a placeholder until #45 lands // Currency picker is intentionally still a placeholder until #45 lands
// the row UX is what we're building here, not the underlying preference. // the row UX is what we're building here, not the underlying preference.
function notImplemented() { function notImplemented() {
@ -118,31 +114,6 @@ function notImplemented() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<!-- Color scheme (palette) -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left">
<div class="flex items-center gap-3">
<Palette class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('common.nav.colorScheme') }}</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<span>{{ currentPaletteLabel }}</span>
<ChevronRight class="w-4 h-4" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuLabel>{{ t('common.nav.colorScheme') }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup :model-value="palette" @update:model-value="(v) => v != null && setPalette(v as PaletteName)">
<DropdownMenuRadioItem v-for="p in PALETTES" :key="p" :value="p">
{{ paletteLabel(p) }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<!-- Language --> <!-- Language -->
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>

View file

@ -2,31 +2,9 @@ import { computed, onMounted, ref, watch } from 'vue'
type Theme = 'dark' | 'light' | 'system' type Theme = 'dark' | 'light' | 'system'
export type Palette =
| 'catppuccin'
| 'countrysidecastle'
| 'darkmatter'
| 'emeraldforest'
| 'lightgreen'
| 'neobrut'
| 'starrynight'
export const PALETTES: Palette[] = [
'catppuccin',
'countrysidecastle',
'darkmatter',
'emeraldforest',
'lightgreen',
'neobrut',
'starrynight',
]
const DEFAULT_PALETTE: Palette = 'catppuccin'
const useTheme = () => { const useTheme = () => {
const theme = ref<Theme>('dark') const theme = ref<Theme>('dark')
const systemTheme = ref<'dark' | 'light'>('light') const systemTheme = ref<'dark' | 'light'>('light')
const palette = ref<Palette>(DEFAULT_PALETTE)
const updateSystemTheme = () => { const updateSystemTheme = () => {
systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
@ -44,56 +22,31 @@ const useTheme = () => {
} }
} }
const applyPalette = () => {
if (palette.value === DEFAULT_PALETTE) {
document.documentElement.removeAttribute('data-theme')
} else {
document.documentElement.setAttribute('data-theme', palette.value)
}
}
onMounted(() => { onMounted(() => {
const stored = localStorage.getItem('ui-theme') const stored = localStorage.getItem('ui-theme')
if (stored && (stored === 'dark' || stored === 'light' || stored === 'system')) { if (stored && (stored === 'dark' || stored === 'light' || stored === 'system')) {
theme.value = stored as Theme theme.value = stored as Theme
} }
const storedPalette = localStorage.getItem('ui-palette')
if (storedPalette && (PALETTES as string[]).includes(storedPalette)) {
palette.value = storedPalette as Palette
}
updateSystemTheme() updateSystemTheme()
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateSystemTheme) window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateSystemTheme)
applyTheme() applyTheme()
applyPalette()
}) })
watch(currentTheme, () => { watch(currentTheme, () => {
applyTheme() applyTheme()
}) })
watch(palette, () => {
applyPalette()
})
const setTheme = (newTheme: Theme) => { const setTheme = (newTheme: Theme) => {
theme.value = newTheme theme.value = newTheme
localStorage.setItem('ui-theme', newTheme) localStorage.setItem('ui-theme', newTheme)
} }
const setPalette = (newPalette: Palette) => {
palette.value = newPalette
localStorage.setItem('ui-palette', newPalette)
}
return { return {
theme, theme,
setTheme, setTheme,
systemTheme, systemTheme,
currentTheme, currentTheme,
palette,
setPalette,
} }
} }

View file

@ -43,17 +43,16 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<!-- Flash toggle (if available) flashlight, not lightning-bolt: <!-- Flash toggle (if available) -->
this controls the camera torch, not anything Lightning-related,
and the bolt icon was confusing in a Lightning-payments app. -->
<Button <Button
v-if="flashAvailable" v-if="flashAvailable"
@click="toggleFlash" @click="toggleFlash"
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="Toggle flashlight"
> >
<Flashlight class="w-4 h-4" /> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</Button> </Button>
<!-- Close scanner --> <!-- Close scanner -->
@ -74,7 +73,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Loader2, Flashlight } from 'lucide-vue-next' import { Loader2 } from 'lucide-vue-next'
import { useQRScanner } from '@/composables/useQRScanner' import { useQRScanner } from '@/composables/useQRScanner'
interface Emits { interface Emits {

View file

@ -139,13 +139,15 @@ export const SERVICE_TOKENS = {
// Tasks services // Tasks services
TASK_SERVICE: Symbol('taskService'), TASK_SERVICE: Symbol('taskService'),
/** @deprecated Use TASK_SERVICE instead */
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
// Links services // Links services
SUBMISSION_SERVICE: Symbol('submissionService'), SUBMISSION_SERVICE: Symbol('submissionService'),
LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'), LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'),
// Nostr transport (kind-21000 RPC over relays — LNbits backend) // Nostr metadata services
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'), NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
// Activities services (Nostr-native events + ticketing module) // Activities services (Nostr-native events + ticketing module)
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'), ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),

View file

@ -29,14 +29,6 @@ const messages: LocaleMessages = {
themeLight: 'Light', themeLight: 'Light',
themeDark: 'Dark', themeDark: 'Dark',
themeSystem: 'System', themeSystem: 'System',
colorScheme: 'Color scheme',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Countryside Castle',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Emerald Forest',
palette_lightgreen: 'Light Green',
palette_neobrut: 'Neo Brutalist',
palette_starrynight: 'Starry Night',
language: 'Language', language: 'Language',
currency: 'Currency', currency: 'Currency',
currencyComingSoon: 'Currency picker — coming soon', currencyComingSoon: 'Currency picker — coming soon',
@ -65,10 +57,6 @@ const messages: LocaleMessages = {
tomorrow: 'Tomorrow', tomorrow: 'Tomorrow',
thisWeek: 'This Week', thisWeek: 'This Week',
thisMonth: 'This Month', thisMonth: 'This Month',
myTickets: 'My tickets',
hosting: 'Hosting',
pastEvents: 'Past events',
past: 'Past',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -108,15 +96,7 @@ const messages: LocaleMessages = {
when: 'When', when: 'When',
tickets: 'Tickets', tickets: 'Tickets',
ticketsAvailable: '{count} tickets available', ticketsAvailable: '{count} tickets available',
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
unlimitedTickets: 'Unlimited tickets',
buyTicket: 'Buy ticket',
buyAnotherTicket: 'Buy another ticket',
viewMyTickets: 'View in My Tickets',
soldOut: 'Sold Out', soldOut: 'Sold Out',
pastEvent: 'This event has already happened',
loginToBuyTickets: 'Log in to buy tickets',
logIn: 'Log in',
free: 'Free', free: 'Free',
}, },
tickets: { tickets: {

View file

@ -29,14 +29,6 @@ const messages: LocaleMessages = {
themeLight: 'Claro', themeLight: 'Claro',
themeDark: 'Oscuro', themeDark: 'Oscuro',
themeSystem: 'Sistema', themeSystem: 'Sistema',
colorScheme: 'Paleta',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Castillo Campestre',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Bosque Esmeralda',
palette_lightgreen: 'Verde Claro',
palette_neobrut: 'Neo Brutalista',
palette_starrynight: 'Noche Estrellada',
language: 'Idioma', language: 'Idioma',
currency: 'Moneda', currency: 'Moneda',
currencyComingSoon: 'Selector de moneda — próximamente', currencyComingSoon: 'Selector de moneda — próximamente',
@ -65,10 +57,6 @@ const messages: LocaleMessages = {
tomorrow: 'Mañana', tomorrow: 'Mañana',
thisWeek: 'Esta semana', thisWeek: 'Esta semana',
thisMonth: 'Este mes', thisMonth: 'Este mes',
myTickets: 'Mis boletos',
hosting: 'Organizo',
pastEvents: 'Eventos pasados',
past: 'Pasado',
}, },
categories: { categories: {
concert: 'Concierto', concert: 'Concierto',
@ -108,15 +96,7 @@ const messages: LocaleMessages = {
when: 'Cuándo', when: 'Cuándo',
tickets: 'Boletos', tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles', ticketsAvailable: '{count} boletos disponibles',
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
unlimitedTickets: 'Boletos ilimitados',
buyTicket: 'Comprar boleto',
buyAnotherTicket: 'Comprar otro boleto',
viewMyTickets: 'Ver en Mis boletos',
soldOut: 'Agotado', soldOut: 'Agotado',
pastEvent: 'Este evento ya pasó',
loginToBuyTickets: 'Inicia sesión para comprar boletos',
logIn: 'Iniciar sesión',
free: 'Gratis', free: 'Gratis',
}, },
tickets: { tickets: {

View file

@ -29,14 +29,6 @@ const messages: LocaleMessages = {
themeLight: 'Clair', themeLight: 'Clair',
themeDark: 'Sombre', themeDark: 'Sombre',
themeSystem: 'Système', themeSystem: 'Système',
colorScheme: 'Palette',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Château Champêtre',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Forêt d\'Émeraude',
palette_lightgreen: 'Vert Clair',
palette_neobrut: 'Néo-Brutaliste',
palette_starrynight: 'Nuit Étoilée',
language: 'Langue', language: 'Langue',
currency: 'Devise', currency: 'Devise',
currencyComingSoon: 'Sélecteur de devise — bientôt disponible', currencyComingSoon: 'Sélecteur de devise — bientôt disponible',
@ -65,10 +57,6 @@ const messages: LocaleMessages = {
tomorrow: 'Demain', tomorrow: 'Demain',
thisWeek: 'Cette semaine', thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci', thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
hosting: 'J\'organise',
pastEvents: 'Événements passés',
past: 'Passé',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -108,15 +96,7 @@ const messages: LocaleMessages = {
when: 'Quand', when: 'Quand',
tickets: 'Billets', tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles', ticketsAvailable: '{count} billets disponibles',
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
unlimitedTickets: 'Billets illimités',
buyTicket: 'Acheter un billet',
buyAnotherTicket: 'Acheter un autre billet',
viewMyTickets: 'Voir dans Mes billets',
soldOut: 'Épuisé', soldOut: 'Épuisé',
pastEvent: 'Cet événement est déjà passé',
loginToBuyTickets: 'Connectez-vous pour acheter des billets',
logIn: 'Connexion',
free: 'Gratuit', free: 'Gratuit',
}, },
tickets: { tickets: {

View file

@ -28,14 +28,6 @@ export interface LocaleMessages {
themeLight: string themeLight: string
themeDark: string themeDark: string
themeSystem: string themeSystem: string
colorScheme: string
palette_catppuccin: string
palette_countrysidecastle: string
palette_darkmatter: string
palette_emeraldforest: string
palette_lightgreen: string
palette_neobrut: string
palette_starrynight: string
language: string language: string
currency: string currency: string
currencyComingSoon: string currencyComingSoon: string
@ -66,10 +58,6 @@ export interface LocaleMessages {
tomorrow: string tomorrow: string
thisWeek: string thisWeek: string
thisMonth: string thisMonth: string
myTickets: string
hosting: string
pastEvents: string
past: string
} }
categories: Record<string, string> categories: Record<string, string>
detail: { detail: {
@ -83,15 +71,7 @@ export interface LocaleMessages {
when: string when: string
tickets: string tickets: string
ticketsAvailable: string ticketsAvailable: string
ticketsOwned: string
unlimitedTickets: string
buyTicket: string
buyAnotherTicket: string
viewMyTickets: string
soldOut: string soldOut: string
pastEvent: string
loginToBuyTickets: string
logIn: string
free: string free: string
} }
tickets: { tickets: {

View file

@ -40,12 +40,7 @@ interface User {
username?: string username?: string
email?: string email?: string
pubkey?: string pubkey?: string
// The `prvkey` field was removed from this interface as the final step of prvkey?: string // Nostr private key for user
// phase-1 per aiolabs/lnbits#9 / design-questions Q1.2 Option (b). LNbits
// signs server-side via the NostrSigner abstraction (PR #26) and exposes
// `signer_type` instead of raw key material on /api/v1/auth. Bucket-B
// sign-sites (kind 1 / 4 / 5 / 7 / 31925 / 10003 / 1111 etc.) migrate to
// POST /api/v1/auth/sign-event (PR #29) in phase 2.
external_id?: string external_id?: string
extensions: string[] extensions: string[]
wallets: Wallet[] wallets: Wallet[]
@ -116,29 +111,12 @@ export class LnbitsAPI extends BaseService {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
// Try to surface FastAPI's `{"detail": "..."}` shape; fall back to raw
// body for non-JSON errors. Without this, every backend error renders
// as a generic "API request failed: <status>" and you can't distinguish
// "wrong endpoint" from "expired token" from "validation failure".
let detail: string = errorText
try {
const parsed = JSON.parse(errorText)
if (parsed && typeof parsed.detail === 'string') {
detail = parsed.detail
} else if (parsed && Array.isArray(parsed.detail)) {
// pydantic ValidationError: take the first msg
detail = parsed.detail[0]?.msg ?? errorText
}
} catch {
// body wasn't JSON; keep the raw text in `detail`
}
console.error('LNBits API Error:', { console.error('LNBits API Error:', {
endpoint,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
detail, errorText
}) })
throw new Error(`LNbits ${endpoint} ${response.status}: ${detail || response.statusText}`) throw new Error(`API request failed: ${response.status} ${response.statusText}`)
} }
const data = await response.json() const data = await response.json()
@ -179,21 +157,19 @@ export class LnbitsAPI extends BaseService {
// First get basic user info from /auth // First get basic user info from /auth
const basicUser = await this.request<User>('/auth') const basicUser = await this.request<User>('/auth')
// /auth/nostr/me used to return the user's prvkey for client-side signing; // Then get Nostr keys from /auth/nostr/me (this was working in main branch)
// post-aiolabs/lnbits#9 phase-1 the server signs and the endpoint returns
// only the pubkey. We keep the call to merge the pubkey (which the basic
// /auth response also includes on the post-cascade server; this is the
// belt-and-suspenders fallback for older lnbits revisions until we ship a
// signer_type-aware client).
try { try {
const nostrUser = await this.request<User>('/auth/nostr/me') const nostrUser = await this.request<User>('/auth/nostr/me')
// Merge the data - basic user info + Nostr keys
return { return {
...basicUser, ...basicUser,
pubkey: nostrUser.pubkey, pubkey: nostrUser.pubkey,
prvkey: nostrUser.prvkey
} }
} catch (error) { } catch (error) {
console.warn('Failed to fetch Nostr pubkey from /auth/nostr/me, returning basic user info:', error) console.warn('Failed to fetch Nostr keys, returning basic user info:', error)
// Return basic user info without Nostr keys if the endpoint fails
return basicUser return basicUser
} }
} }
@ -209,23 +185,12 @@ export class LnbitsAPI extends BaseService {
} }
async updateProfile(data: Partial<User>): Promise<User> { async updateProfile(data: Partial<User>): Promise<User> {
// aiolabs/lnbits PR #26 (gap-fill 869f67c3) wired return this.request<User>('/auth/update', {
// _publish_nostr_metadata_event into PATCH /api/v1/auth method: 'PUT',
// (auth_api.py:546). The legacy PUT /auth/update route does not
// exist on the post-cascade server.
return this.request<User>('/auth', {
method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
} }
async getConversion(params: { from: string; to: string; amount: number }): Promise<Record<string, number>> {
return this.request<Record<string, number>>('/conversion', {
method: 'POST',
body: JSON.stringify({ from_: params.from, to: params.to, amount: params.amount }),
})
}
isAuthenticated(): boolean { isAuthenticated(): boolean {
return !!this.accessToken return !!this.accessToken
} }

View file

@ -4,12 +4,6 @@ export const LNBITS_CONFIG = {
// This should point to your LNBits instance // This should point to your LNBits instance
API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`, API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`,
// LNbits Nostr-transport server pubkey. The webapp encrypts its
// signed kind-21000 RPC events to this pubkey and listens for
// signed responses from it. Logged by the LNbits server at startup
// (`Nostr transport: starting with pubkey <hex>...`).
NOSTR_TRANSPORT_PUBKEY: import.meta.env.VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY || '',
// Whether to enable debug logging // Whether to enable debug logging
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true', DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',

View file

@ -1,108 +0,0 @@
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { getApiUrl, getAuthToken } from '@/lib/config/lnbits'
/**
* Uniform bucket-B signing helper. Bucket-B kinds (1, 3, 4, 5, 7, 1111,
* 1621, 1622, 10003, 30023, 31922, 31923, 31925) sign server-side via
* the `NostrSigner` ABC LocalSigner does it in-process, RemoteBunker
* routes via NIP-46 to nsecbunkerd. The webapp posts an unsigned event
* template and receives the signed event back.
*
* Wire shape: POST /api/v1/auth/sign-event
* - Cookie auth (cookie_access_token from login) OR Bearer auth
* (Authorization: Bearer <token>). The webapp uses Bearer.
* - CSRF: GET /auth/csrf-token issues an XSRF-TOKEN cookie + body
* token; we echo the token in X-CSRF-Token. We lazy-fetch once
* per page load and refresh on 403.
* - Body: {kind, created_at, tags, content}
* - Returns: fully-signed event {kind, created_at, tags, content,
* pubkey, id, sig}.
*
* Refs:
* - `~/dev/coordination/webapp-design-questions.md` Q3.3 (uniform
* helper); aiolabs/lnbits PR #29 (the endpoint); commit
* `9a300c1` (the prvkey-removal that this unblocks).
*/
let cachedCsrfToken: string | null = null
async function fetchCsrfToken(): Promise<string> {
const response = await fetch(getApiUrl('/auth/csrf-token'), {
method: 'GET',
credentials: 'include',
headers: getAuthHeaders(),
})
if (!response.ok) {
throw new Error(
`Failed to obtain CSRF token: ${response.status} ${response.statusText}`,
)
}
const data = await response.json()
if (typeof data?.csrf_token !== 'string') {
throw new Error('CSRF token response missing csrf_token field')
}
return data.csrf_token
}
async function getCsrfToken(forceRefresh = false): Promise<string> {
if (!forceRefresh && cachedCsrfToken) return cachedCsrfToken
cachedCsrfToken = await fetchCsrfToken()
return cachedCsrfToken
}
function getAuthHeaders(): Record<string, string> {
const token = getAuthToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function signOnce(
template: EventTemplate,
csrfToken: string,
): Promise<Response> {
return fetch(getApiUrl('/auth/sign-event'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
...getAuthHeaders(),
},
body: JSON.stringify({
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
}),
})
}
export async function signEventViaLnbits(
template: EventTemplate,
): Promise<NostrEvent> {
let csrfToken = await getCsrfToken()
let response = await signOnce(template, csrfToken)
// On CSRF rejection, refresh the token once and retry. The cached
// token outlives the cookie when the browser is restarted across
// an expired XSRF-TOKEN cookie; one retry covers that race.
if (response.status === 403) {
const body = await response.clone().text()
if (body.includes('CSRF')) {
csrfToken = await getCsrfToken(true)
response = await signOnce(template, csrfToken)
}
}
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`
try {
const parsed = await response.json()
if (typeof parsed?.detail === 'string') detail = parsed.detail
} catch {
// body wasn't JSON
}
throw new Error(`signEventViaLnbits: ${detail}`)
}
return (await response.json()) as NostrEvent
}

View file

@ -4,10 +4,9 @@ import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next' import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next'
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 type { Activity } from '../types/activity' import type { Activity } from '../types/activity'
const props = defineProps<{ const props = defineProps<{
@ -20,9 +19,6 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale()
const { paidCount } = useOwnedTickets()
const ownedCount = computed(() => paidCount(props.activity.id))
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
const a = props.activity const a = props.activity
@ -58,13 +54,6 @@ const placeholderBg = computed(() => {
const hue = hash % 360 const hue = hash % 360
return `hsl(${hue}, 40%, 85%)` return `hsl(${hue}, 40%, 85%)`
}) })
const isPast = computed(() => {
const a = props.activity
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
</script> </script>
<template> <template>
@ -128,22 +117,6 @@ const isPast = computed(() => {
> >
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge> </Badge>
<!-- Past badge shown when the activity has already ended.
Only relevant on the feed when the "Past events" filter
chip is toggled on (otherwise these cards aren't rendered);
on the detail page the card view isn't used. Suppressed
when a pending/rejected status badge is taking the same
slot that case is the creator's own past draft, which is
vanishingly rare and the status hint is more actionable. -->
<Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
<History class="w-3 h-3" />
{{ t('activities.filters.past', 'Past') }}
</Badge>
</div> </div>
<CardContent class="p-4 flex-1 flex flex-col gap-2"> <CardContent class="p-4 flex-1 flex flex-col gap-2">
@ -182,38 +155,19 @@ const isPast = computed(() => {
<span class="truncate">{{ activity.location }}</span> <span class="truncate">{{ activity.location }}</span>
</div> </div>
<!-- Tickets available. `available === undefined` means <!-- Tickets available -->
unlimited capacity (no `tickets_available` tag was
published); show the price-only line in that case. -->
<div <div
v-if="activity.ticketInfo" v-if="activity.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="activity.ticketInfo.available > 0">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }} {{ t('activities.detail.ticketsAvailable', { count: activity.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('activities.detail.soldOut') }}
</span> </span>
</div> </div>
<!-- Owned tickets shown when the current user holds at
least one paid ticket for this activity. Sits next to
the availability line so the buyer can see at a glance
whether they've already bought in. -->
<div
v-if="ownedCount > 0"
class="flex items-center gap-1.5 text-sm text-primary"
>
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span>
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
</span>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button'
import { CalendarPlus } from 'lucide-vue-next' import { CalendarPlus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import type { CreateEventRequest } from '../types/ticket' import type { CalendarTimeEvent } from '../types/nip52'
import type { ActivityCategory } from '../types/category' import type { ActivityCategory } from '../types/category'
import CategorySelector from './CategorySelector.vue' import CategorySelector from './CategorySelector.vue'
import LocationPicker from './LocationPicker.vue' import LocationPicker from './LocationPicker.vue'
@ -67,64 +67,56 @@ const form = useForm({
const isFormValid = computed(() => form.meta.value.valid) const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => { const onSubmit = form.handleSubmit(async (values) => {
const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API) const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!ticketApi) { if (!nostrService) {
toast.error('Activities service not available') toast.error('Activities service not available')
return return
} }
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey const signingKey = currentUser.value?.prvkey
if (!invoiceKey) { if (!signingKey) {
toast.error('No wallet available. Please log in first.') toast.error('Signing key not available. Please log in again.')
return return
} }
isPublishing.value = true isPublishing.value = true
try { try {
// Compose ISO 8601 datetime strings the events extension parses. // Build unix timestamps
const startIso = `${values.startDate}T${values.startTime}` const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000)
const endIso = let endTimestamp: number | undefined
values.endDate && values.endTime if (values.endDate && values.endTime) {
? `${values.endDate}T${values.endTime}` endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000)
: 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) // Generate a unique d-tag
const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
// Approval workflow caveat: non-admin users on instances with const eventData: Partial<CalendarTimeEvent> = {
// `auto_approve=false` (the default) land in the proposal queue; dTag,
// their event isn't published to relays until an admin approves. title: values.title,
// Admins-and-auto-approve-on instances publish immediately. summary: values.summary || undefined,
toast.success('Activity created!') content: values.description,
image: values.image || undefined,
start: startTimestamp,
end: endTimestamp,
startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: location.value || undefined,
hashtags: selectedCategories.value,
}
const result = await nostrService.publishCalendarEvent(eventData, signingKey)
if (result.success > 0) {
toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`)
emit('created') emit('created')
handleClose() handleClose()
} else {
toast.error('Failed to publish to any relay')
}
} catch (err) { } catch (err) {
console.error('Failed to create activity:', err) console.error('Failed to publish activity:', err)
toast.error(err instanceof Error ? err.message : 'Failed to create activity') toast.error(err instanceof Error ? err.message : 'Failed to publish activity')
} finally { } finally {
isPublishing.value = false isPublishing.value = false
} }

View file

@ -24,9 +24,6 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Bell, ChevronDown } from 'lucide-vue-next'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
Select, Select,
@ -35,13 +32,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Calendar, Loader2, MapPin, AlertCircle, Zap } from 'lucide-vue-next' import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next'
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'
import ImageUpload from '@/modules/base/components/ImageUpload.vue' import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue' import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue'
import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
@ -91,28 +87,6 @@ function foldDateTime(date: string, time: string): string {
return time ? `${date}T${time}` : date return time ? `${date}T${time}` : date
} }
// Stamp the form's wall-clock datetime with the user's local UTC offset
// before sending it to the LNbits events backend. Without this, the
// backend's `_to_unix` (nostr_publisher.py) treats a naive ISO string
// as UTC, so e.g. "08:00" entered in CEST gets stored as 08:00 UTC and
// the NIP-52 `start` tag is off by the user's offset on the relay
// the detail page then renders it +offset (08:00 10:00 in CEST).
// Preserving the user's intended wall-clock means stamping it here.
// Date-only values (no "T") pass through unchanged.
function withLocalTzOffset(value: string): string {
if (!value || !value.includes('T')) return value
// The form's "YYYY-MM-DDTHH:MM" is parsed by JS Date as local time;
// getTimezoneOffset() returns minutes west of UTC, so negate it.
const offMin = -new Date(value).getTimezoneOffset()
const sign = offMin >= 0 ? '+' : '-'
const abs = Math.abs(offMin)
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
const mm = String(abs % 60).padStart(2, '0')
// Include `:00` seconds for compatibility with older Python
// `datetime.fromisoformat` (pre-3.11 won't accept "HH:MM+HH:MM").
return `${value}:00${sign}${hh}:${mm}`
}
const formSchema = toTypedSchema( const formSchema = toTypedSchema(
z z
.object({ .object({
@ -124,19 +98,13 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''), event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
currency: z.string().default("sat"), currency: z.string().default("sat"),
allow_fiat: z.boolean().default(false),
fiat_currency: z.string().default("USD"),
amount_tickets: z.number().min(0).max(100000).default(0), amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0), price_per_ticket: z.number().min(0).default(0),
email_notifications: z.boolean().default(false),
nostr_notifications: z.boolean().default(false),
notification_subject: z.string().max(200).default(''),
notification_body: z.string().max(2000).default(''),
}) })
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time // End must not precede start. Compare on the folded date+time
// string so equal-date / later-time is enforced too. // string so equal-date / later-time is enforced too.
if (v.event_end_date) { if (!v.event_end_date) return
const start = foldDateTime(v.event_start_date, v.event_start_time) const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time) const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) { if (start && end && end < start) {
@ -146,19 +114,6 @@ const formSchema = toTypedSchema(
message: 'End must be on or after start', message: 'End must be on or after start',
}) })
} }
}
// When the price is in sats and the organizer also accepts fiat,
// they MUST choose a settle currency. Other price denominations
// mirror themselves into fiat_currency automatically. The events
// extension uses 'sat' and 'sats' interchangeably accept both.
const isSat = v.currency === 'sat' || v.currency === 'sats'
if (v.allow_fiat && isSat && !v.fiat_currency) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['fiat_currency'],
message: 'Pick a fiat currency for buyers paying by card',
})
}
}) })
) )
@ -173,14 +128,8 @@ const form = useForm({
event_end_time: '', event_end_time: '',
location: '', location: '',
currency: 'sat', currency: 'sat',
allow_fiat: false,
fiat_currency: 'USD',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 0, price_per_ticket: 0,
email_notifications: false,
nostr_notifications: false,
notification_subject: '',
notification_body: '',
} }
}) })
@ -189,11 +138,8 @@ interface BannerImage extends UploadedImage {
} }
const bannerImages = ref<BannerImage[]>([]) const bannerImages = ref<BannerImage[]>([])
// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM[:SS][±HH:MM]]" // Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM]" back
// back into separate date + time pieces for the form inputs. The // into separate date + time pieces for the form inputs.
// time slice trims to "HH:MM" so any seconds + offset suffix added by
// withLocalTzOffset on submit drops cleanly the user sees the same
// wall-clock they originally entered when re-editing.
function splitDateTime(value: string | null | undefined): { date: string; time: string } { function splitDateTime(value: string | null | undefined): { date: string; time: string } {
if (!value) return { date: '', time: '' } if (!value) return { date: '', time: '' }
const [date, time = ''] = value.split('T') const [date, time = ''] = value.split('T')
@ -203,7 +149,6 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
// When `true`, suppress the auto-mirror watcher so we don't clobber an // When `true`, suppress the auto-mirror watcher so we don't clobber an
// edit-mode population with start-date side effects mid-setValues. // edit-mode population with start-date side effects mid-setValues.
const isPopulating = ref(false) const isPopulating = ref(false)
const notificationsOpen = ref(false)
// Auto-mirror end date to start: when the user picks a start date, // Auto-mirror end date to start: when the user picks a start date,
// surface that same date in the end-date picker so a one-day event // surface that same date in the end-date picker so a one-day event
@ -243,14 +188,8 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time, event_end_time: end.time,
location: event.location ?? '', location: event.location ?? '',
currency: event.currency ?? 'sat', currency: event.currency ?? 'sat',
allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0, amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0, price_per_ticket: event.price_per_ticket ?? 0,
email_notifications: event.extra?.email_notifications ?? false,
nostr_notifications: event.extra?.nostr_notifications ?? false,
notification_subject: event.extra?.notification_subject ?? '',
notification_body: event.extra?.notification_body ?? '',
}) })
selectedCategories.value = [...(event.categories ?? [])] selectedCategories.value = [...(event.categories ?? [])]
if (event.banner) { if (event.banner) {
@ -328,8 +267,9 @@ const onSubmit = form.handleSubmit(async (formValues) => {
try { try {
const eventData: CreateEventRequest = { const eventData: CreateEventRequest = {
name: formValues.name, name: formValues.name,
event_start_date: withLocalTzOffset( event_start_date: foldDateTime(
foldDateTime(formValues.event_start_date, formValues.event_start_time) formValues.event_start_date,
formValues.event_start_time
), ),
} }
if (!isEditMode.value) { if (!isEditMode.value) {
@ -341,8 +281,9 @@ const onSubmit = form.handleSubmit(async (formValues) => {
// Optional fields only include if provided // Optional fields only include if provided
if (formValues.info) eventData.info = formValues.info if (formValues.info) eventData.info = formValues.info
if (formValues.event_end_date) { if (formValues.event_end_date) {
eventData.event_end_date = withLocalTzOffset( eventData.event_end_date = foldDateTime(
foldDateTime(formValues.event_end_date, formValues.event_end_time) formValues.event_end_date,
formValues.event_end_time
) )
} }
if (formValues.location) eventData.location = formValues.location if (formValues.location) eventData.location = formValues.location
@ -354,29 +295,10 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null eventData.banner = null
} }
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends so a truefalse flip propagates on edit;
// fiat_currency only sends when fiat is on (no point persisting a
// rail-currency the backend won't use).
eventData.allow_fiat = formValues.allow_fiat
if (formValues.allow_fiat && formValues.fiat_currency) {
eventData.fiat_currency = formValues.fiat_currency
}
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
// Notification config goes inside the `extra` envelope. On edit
// overlay onto the existing event.extra so unrelated fields the
// LNbits admin UI sets (promo_codes, conditional, min_tickets)
// survive the round-trip.
eventData.extra = {
...(props.event?.extra ?? {}),
email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body,
}
if (isEditMode.value) { if (isEditMode.value) {
if (!props.onUpdateEvent || !props.event?.id) { if (!props.onUpdateEvent || !props.event?.id) {
toastService.error('Update handler missing') toastService.error('Update handler missing')
@ -602,15 +524,7 @@ const handleOpenChange = (open: boolean) => {
/> />
</div> </div>
<!-- Pricing --> <!-- Tickets (optional, visible) -->
<div class="space-y-3">
<div>
<p class="text-sm font-medium">Pricing</p>
<p class="text-xs text-muted-foreground">
Set what buyers see. Lightning charges happen in sats;
fiat amounts convert at checkout using current rates.
</p>
</div>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets"> <FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem> <FormItem>
@ -636,7 +550,7 @@ const handleOpenChange = (open: boolean) => {
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem> <FormItem>
<FormLabel>Price currency</FormLabel> <FormLabel>Currency</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField"> <Select v-bind="componentField">
<SelectTrigger> <SelectTrigger>
@ -653,91 +567,6 @@ const handleOpenChange = (open: boolean) => {
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
</div>
<!-- Payment methods -->
<div class="space-y-3">
<div>
<p class="text-sm font-medium">Payment methods</p>
<p class="text-xs text-muted-foreground">
Lightning is always available. Enable fiat to also accept
card and bank payments through your configured provider.
</p>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Zap class="w-4 h-4" />
<span>Lightning always on</span>
</div>
<FiatToggleField
allow-fiat-field="allow_fiat"
fiat-currency-field="fiat_currency"
:denomination="form.values.currency ?? 'sat'"
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
:disabled="isLoading"
/>
</div>
<!-- Ticket buyer notifications (collapsible). The backend
sends email + NIP-04 Nostr DM confirmations on
payment when these are on. notification_subject /
body let the organizer customize the message; empty
strings fall back to the extension's defaults. -->
<Collapsible v-model:open="notificationsOpen">
<CollapsibleTrigger as-child>
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
<span class="flex items-center gap-1.5">
<Bell class="w-4 h-4" />
Buyer notifications
</span>
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': notificationsOpen }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="space-y-3 pt-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField v-slot="{ value, handleChange }" name="email_notifications">
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3">
<FormLabel class="text-sm">Email confirmation</FormLabel>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="nostr_notifications">
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3">
<FormLabel class="text-sm">Nostr DM confirmation</FormLabel>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="notification_subject">
<FormItem>
<FormLabel class="text-sm">Subject</FormLabel>
<FormControl>
<Input placeholder="Your ticket for {event_name}" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">Leave blank to use the default.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="notification_body">
<FormItem>
<FormLabel class="text-sm">Body</FormLabel>
<FormControl>
<Textarea placeholder="See you there!" rows="3" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">
Leave blank to use the default. The ticket link is appended automatically.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</CollapsibleContent>
</Collapsible>
<!-- Actions --> <!-- Actions -->
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">

View file

@ -1,20 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue' import { onUnmounted } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useTicketPurchase } from '../composables/useTicketPurchase' import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting' import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry,
} from '@/modules/base/components/payments/PaymentMethodSelector.vue'
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
interface Props { interface Props {
event: { event: {
@ -22,9 +14,6 @@ interface Props {
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
/** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean
fiat_currency?: string
} }
isOpen: boolean isOpen: boolean
} }
@ -41,7 +30,6 @@ const {
isLoading, isLoading,
error, error,
paymentHash, paymentHash,
paymentRequest,
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
@ -49,198 +37,27 @@ const {
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
purchaseTicketForEvent, purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet, handleOpenLightningWallet,
resetPaymentState, resetPaymentState,
cleanup, cleanup,
purchasedTicketIds, ticketQRCode,
purchasedTicketId,
showTicketQR showTicketQR
} = useTicketPurchase() } = useTicketPurchase()
const MAX_QUANTITY = 10
const quantity = ref(1)
const copiedInvoice = ref(false)
function decreaseQuantity() {
if (quantity.value > 1) quantity.value -= 1
}
function increaseQuantity() {
if (quantity.value < MAX_QUANTITY) quantity.value += 1
}
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
async function copyInvoice() {
if (!paymentRequest.value) return
try {
await navigator.clipboard.writeText(paymentRequest.value)
copiedInvoice.value = true
setTimeout(() => (copiedInvoice.value = false), 1500)
} catch {
// Older browsers / insecure contexts; the Open-in-wallet button
// still works as a fallback.
}
}
const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion()
const selectedMethodId = ref<string>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false)
const fiatError = ref<string | null>(null)
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats',
)
// Lightning-button badge: when the price is denominated in fiat, show
// the live sat equivalent so the buyer knows roughly what their wallet
// will be charged. Best-effort silent if the conversion fails.
const lightningSats = ref<number | null>(null)
watch(
() => [props.event.currency, props.event.price_per_ticket, props.isOpen] as const,
async ([cur, amt, open]) => {
if (!open || !amt || cur === 'sat' || cur === 'sats') {
lightningSats.value = null
return
}
lightningSats.value = await convert(amt as number, cur as string, 'sat')
},
{ immediate: true },
)
function iconFor(hint: 'card' | 'bank' | 'wallet') {
if (hint === 'bank') return Landmark
if (hint === 'wallet') return Wallet
return CreditCard
}
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const lightning: PaymentMethodEntry = {
id: 'lightning',
rail: 'lightning',
label: 'Lightning',
icon: Zap,
available: true,
badge:
!isPriceInSats.value && lightningSats.value
? `${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined,
}
if (!props.event.allow_fiat) return [lightning]
if (providers.value.length > 0) {
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
const meta = providerMeta(id)
return {
id: `fiat:${id}`,
rail: 'fiat',
provider: id,
label: meta.label,
icon: iconFor(meta.icon),
available: true,
}
})
return [lightning, ...fiatRails]
}
// Degenerate fallback allow_fiat is on but the buyer's session
// can't enumerate the organizer's providers. Show a generic Card
// button and let the backend pick a default at request time.
return [
lightning,
{
id: 'fiat',
rail: 'fiat',
label: 'Card',
icon: CreditCard,
available: true,
},
]
})
const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
)
async function handlePurchase() { async function handlePurchase() {
if (!canPurchase.value) return if (!canPurchase.value) return
fiatError.value = null
const method = selectedMethod.value
if (!method) return
// Lightning path: the composable just creates the invoice + starts
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in
// external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here.
if (method.rail === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value }) await purchaseTicketForEvent(props.event.id)
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
} }
return
}
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
// API directly with the chosen provider, then redirect the buyer to
// the provider's checkout URL. Payment confirmation happens via
// webhook on the backend and shows up next time the buyer reloads
// MyTickets.
try {
isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const currentUser = (lnbitsAPI?.currentUser?.value) || null
const userId = currentUser?.id
if (!userId) {
fiatError.value = 'Missing user id'
return
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken,
{
paymentMethod: 'fiat',
fiatProvider: method.provider,
quantity: quantity.value,
},
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
return
}
fiatRedirectUrl.value = invoice.fiatPaymentRequest
fiatProviderLabel.value = invoice.fiatProvider
? providerMeta(invoice.fiatProvider).label
: method.label
} catch (err) {
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
} finally {
isFiatPending.value = false
}
}
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
} }
function handleClose() { function handleClose() {
emit('update:isOpen', false) emit('update:isOpen', false)
resetPaymentState() resetPaymentState()
selectedMethodId.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null
quantity.value = 1
copiedInvoice.value = false
} }
onUnmounted(() => { onUnmounted(() => {
@ -250,20 +67,14 @@ onUnmounted(() => {
<template> <template>
<Dialog :open="isOpen" @update:open="handleClose"> <Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto"> <DialogContent class="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<CreditCard class="w-5 h-5" /> <CreditCard class="w-5 h-5" />
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }} Purchase Ticket
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }} Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
</span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -338,233 +149,93 @@ onUnmounted(() => {
<CreditCard class="w-4 h-4 text-muted-foreground" /> <CreditCard class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Payment Details:</span> <span class="text-sm font-medium">Payment Details:</span>
</div> </div>
<!-- Quantity selector backend caps at 10. One invoice for
the whole purchase, one ticket row representing N seats. -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7"
:disabled="quantity <= 1"
@click="decreaseQuantity"
>
<Minus class="h-3.5 w-3.5" />
</Button>
<span class="w-6 text-center text-sm font-medium">{{ quantity }}</span>
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7"
:disabled="quantity >= MAX_QUANTITY"
@click="increaseQuantity"
>
<Plus class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="space-y-1"> <div class="space-y-1">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground">Event:</span> <span class="text-sm text-muted-foreground">Event:</span>
<span class="text-sm font-medium">{{ event.name }}</span> <span class="text-sm font-medium">{{ event.name }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">Price:</span>
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} <span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
</span>
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
</div> </div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="totalPrice"
from="sat"
:to="event.fiat_currency"
prefix="Equivalent ~"
suffix=" if paid in fiat"
/>
</div> </div>
</div> </div>
<!-- Payment method selector (only shown when fiat is enabled <div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
on the event). Buttons surface one per configured fiat {{ error }}
provider so "Stripe" / "PayPal" / "Square" stand alongside
Lightning rather than collapsing into a single "Fiat"
catch-all. Hidden entirely for Lightning-only events to
keep the dialog uncluttered. -->
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails.
Live rates shown are estimates; the exact sat amount locks
in when you start checkout.
</p>
<PaymentMethodSelector
:methods="paymentMethods"
:model-value="selectedMethodId"
@update:model-value="selectedMethodId = $event"
/>
</div>
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{{ error || fiatError }}
</div>
<!-- Fiat checkout panel shown after a successful fiat
POST when we have a provider URL to redirect to. -->
<div v-if="fiatRedirectUrl" class="space-y-3">
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
<p class="text-xs text-muted-foreground">
Opens the provider's checkout in a new tab. Your ticket
appears in My Tickets once the payment settles.
</p>
</div>
<Button @click="openFiatCheckout" class="w-full">
<ExternalLink class="w-4 h-4 mr-2" />
Open {{ fiatProviderLabel }} checkout
</Button>
</div> </div>
<Button <Button
v-else
@click="handlePurchase" @click="handlePurchase"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)" :disabled="isLoading || !canPurchase"
class="w-full" class="w-full"
> >
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" /> <span v-if="isLoading" class="animate-spin mr-2"></span>
<template v-else-if="selectedMethod?.rail === 'fiat'"> <span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
<CreditCard class="w-4 h-4 mr-2" /> <Zap class="w-4 h-4" />
Continue to {{ selectedMethod.label }} checkout Pay with Wallet
</template> </span>
<template v-else> <span v-else>Generate Payment Request</span>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</template>
</Button> </Button>
</div> </div>
<!-- Lightning invoice restaurant-style. Shows QR + amount, <!-- Payment QR Code and Status -->
with both pay paths visible at once: tap-to-pay from the <div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4">
LNbits wallet, scan with an external wallet, or hand off <div class="text-center space-y-2">
via lightning: URI on mobile. Polling fires whichever <h3 class="text-lg font-semibold">Payment Required</h3>
path the buyer takes. --> <p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
<div v-else-if="paymentHash && !showTicketQR" class="py-4 space-y-4"> Processing payment with your wallet...
<div class="text-center space-y-1"> </p>
<h3 class="text-lg font-semibold">Pay the invoice</h3> <p v-else class="text-sm text-muted-foreground">
<p class="text-sm text-muted-foreground"> Scan the QR code with your Lightning wallet to complete the payment
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
</p> </p>
</div> </div>
<!-- QR + amount + copy/open buttons (restaurant <div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4">
OrderInvoiceCard pattern). The QR keeps a white background <img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
regardless of theme so phone cameras parse it reliably. -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<img
v-if="qrCode"
:src="qrCode"
alt="Lightning payment QR code"
class="block h-56 w-56 sm:h-64 sm:w-64"
/>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-sm font-semibold text-primary">
{{ formatEventPrice(totalPrice, event.currency) }}
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
({{ quantity }} tickets)
</span>
</span>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
class="flex-1 font-mono text-xs"
@click="copyInvoice"
>
<Check v-if="copiedInvoice" class="mr-2 h-3.5 w-3.5" />
<Copy v-else class="mr-2 h-3.5 w-3.5" />
{{ copiedInvoice ? 'Copied' : 'Copy' }}
</Button>
<Button
variant="default"
size="sm"
class="flex-1 text-xs"
@click="handleOpenLightningWallet"
>
<Zap class="mr-2 h-3.5 w-3.5" />
Open in wallet
</Button>
</div>
</div> </div>
<!-- LNbits-wallet pay button only shown when the buyer is <div class="space-y-3 w-full">
logged in with a funded wallet. Same screen as the QR so <Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
the user can pick either path without having to back out <Wallet class="w-4 h-4 mr-2" />
of the dialog. --> Open in Lightning Wallet
<Button
v-if="hasWalletWithBalance"
size="lg"
class="w-full"
:disabled="isPayingWithWallet"
@click="payCurrentInvoiceWithWallet"
>
<Loader2 v-if="isPayingWithWallet" class="mr-2 h-4 w-4 animate-spin" />
<Wallet v-else class="mr-2 h-4 w-4" />
{{ isPayingWithWallet ? 'Paying…' : 'Pay from my LNbits wallet' }}
</Button> </Button>
<p
v-else-if="userWallets.length > 0"
class="text-center text-xs text-muted-foreground"
>
Your LNbits wallet is empty pay with an external wallet
using the QR or "Open in wallet" above.
</p>
<div v-if="isPaymentPending" class="text-center space-y-1"> <div v-if="isPaymentPending" class="text-center space-y-2">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div> <div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
Waiting for payment {{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
</span> </span>
</div> </div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Confirmation lands automatically no need to refresh. Payment will be confirmed automatically once received
</p> </p>
</div> </div>
</div> </div>
</div>
<!-- Success state. QRs live in My Tickets no need to <!-- Ticket QR Code (After Successful Purchase) -->
pre-render them here; this view's job is to confirm the <div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
purchase landed and route the buyer to where they actually <div class="text-center space-y-2">
interact with their tickets. --> <h3 class="text-lg font-semibold text-green-600">Ticket Purchased Successfully!</h3>
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4"> <p class="text-sm text-muted-foreground">
Your ticket has been purchased and is now available in your tickets area.
</p>
</div>
<div class="bg-muted/50 rounded-lg p-4 w-full">
<div class="text-center space-y-3">
<div class="flex justify-center"> <div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" /> <Ticket class="w-12 h-12 text-green-600" />
</div> </div>
<div class="text-center space-y-2"> <div>
<h3 class="text-lg font-semibold text-green-600"> <p class="text-sm font-medium">Ticket ID</p>
{{ purchasedTicketIds.length > 1 <div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
? `${purchasedTicketIds.length} tickets purchased!` <p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p>
: 'Ticket purchased!' }} </div>
</h3> </div>
<p class="text-sm text-muted-foreground"> </div>
<span v-if="purchasedTicketIds.length > 1">
Each attendee gets their own scannable QR in My Tickets
hand them out independently for the door scan.
</span>
<span v-else>
Your ticket is now in My Tickets.
</span>
</p>
</div> </div>
<div class="space-y-3 w-full"> <div class="space-y-3 w-full">

View file

@ -8,7 +8,6 @@ import type { TicketedEvent } from '../types/ticket'
import { ticketedEventToActivity } from '../types/activity' import { ticketedEventToActivity } from '../types/activity'
import { useActivitiesStore } from '../stores/activities' import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters' import { useActivityFilters } from './useActivityFilters'
import { useOwnedTickets } from './useOwnedTickets'
/** /**
* Main composable for activities discovery. * Main composable for activities discovery.
@ -18,7 +17,6 @@ export function useActivities() {
const store = useActivitiesStore() const store = useActivitiesStore()
const filters = useActivityFilters() const filters = useActivityFilters()
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const { ownedActivityIds } = useOwnedTickets()
const isSubscribed = ref(false) const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null) const subscriptionError = ref<string | null>(null)
@ -72,10 +70,7 @@ export function useActivities() {
const all = store.activities.sort( const all = store.activities.sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime() (a, b) => a.startDate.getTime() - b.startDate.getTime()
) )
const filtered = filters.applyFilters(all) return filters.applyFilters(all)
if (!filters.onlyOwnedTickets.value) return filtered
const owned = ownedActivityIds.value
return filtered.filter(a => owned.has(a.id))
}) })
/** /**

View file

@ -32,24 +32,18 @@ export function useActivityDetail(activityId: string) {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
// Scope both the subscription and the one-shot query to this // Subscribe and wait for this specific event
// activity's d-tag. Without this scope, the query asks every
// 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
// even when the activity is reachable.
const detailFilters = { dTags: [activityId] }
unsubscribe = nostrService.subscribeToCalendarEvents( unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => { (incoming) => {
store.upsertActivity(incoming) store.upsertActivity(incoming)
if (incoming.id === activityId) { if (incoming.id === activityId) {
isLoading.value = false isLoading.value = false
} }
}, }
detailFilters
) )
const results = await nostrService.queryCalendarEvents(detailFilters) // Also do a one-shot query
const results = await nostrService.queryCalendarEvents()
store.upsertActivities(results) store.upsertActivities(results)
// If we still don't have it after query, stop loading // If we still don't have it after query, stop loading

View file

@ -15,28 +15,6 @@ export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal) const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([]) const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined) const selectedDate = ref<Date | undefined>(undefined)
/**
* When true, the feed is narrowed to activities the current user
* holds at least one paid ticket for. Crossed with the
* `ownedActivityIds` set from useOwnedTickets in useActivities
* (this composable stays free of ticket fetching).
*/
const onlyOwnedTickets = ref(false)
/**
* When true, the feed is narrowed to activities the current user
* is hosting (organizer pubkey matches the signed-in user, or the
* row is a local LNbits draft of theirs). Reads `activity.isMine`
* which `useActivities.tagOwnership()` populates.
*/
const onlyHosting = ref(false)
/**
* When false (default), activities that have already ended are
* hidden from the feed. Toggling on includes them so the user can
* browse past events. The date-picker overrides this picking a
* specific past date shows that day's activities regardless,
* mirroring how it overrides the temporal pills.
*/
const showPast = ref(false)
const filters = computed<ActivityFilters>(() => ({ const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,
@ -49,9 +27,7 @@ export function useActivityFilters() {
function applyFilters(activities: Activity[]): Activity[] { function applyFilters(activities: Activity[]): Activity[] {
let result = activities let result = activities
// Specific date filter (from DatePickerStrip) takes priority over // Specific date filter (from DatePickerStrip) takes priority over temporal
// temporal. Picking a date also bypasses the past/upcoming split
// so the user can browse activities 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)
@ -62,16 +38,6 @@ export function useActivityFilters() {
} else { } else {
// Temporal filter // Temporal filter
result = applyTemporalFilter(result, temporal.value) result = applyTemporalFilter(result, temporal.value)
// Past/upcoming split — the chip narrows to one side of "now",
// mirroring the "My tickets" / "Hosting" mental model. Default
// (showPast=false) is upcoming-only; toggling on flips to
// past-only. Composes with temporal pills: "This Week" +
// showPast=true shows only the days already passed this week.
const now = new Date()
result = result.filter(a => {
const activityEnd = a.endDate ?? a.startDate
return showPast.value ? activityEnd < now : activityEnd >= now
})
} }
// Category filter // Category filter
@ -81,13 +47,6 @@ export function useActivityFilters() {
) )
} }
// Hosting filter — activities the signed-in user organizes.
// Read off `activity.isMine` which `useActivities.tagOwnership()`
// populates from organizer-pubkey match + LNbits drafts.
if (onlyHosting.value) {
result = result.filter(a => a.isMine === true)
}
return result return result
} }
@ -122,30 +81,12 @@ export function useActivityFilters() {
temporal.value = DEFAULT_FILTERS.temporal temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = [] selectedCategories.value = []
selectedDate.value = undefined selectedDate.value = undefined
onlyOwnedTickets.value = false
onlyHosting.value = false
showPast.value = false
}
function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value
}
function toggleHosting() {
onlyHosting.value = !onlyHosting.value
}
function togglePast() {
showPast.value = !showPast.value
} }
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
temporal.value !== 'all' || temporal.value !== 'all' ||
selectedCategories.value.length > 0 || selectedCategories.value.length > 0 ||
selectedDate.value !== undefined || selectedDate.value !== undefined
onlyOwnedTickets.value ||
onlyHosting.value ||
showPast.value
) )
return { return {
@ -153,9 +94,6 @@ export function useActivityFilters() {
temporal, temporal,
selectedCategories, selectedCategories,
selectedDate, selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
filters, filters,
hasActiveFilters, hasActiveFilters,
@ -165,9 +103,6 @@ export function useActivityFilters() {
selectDate, selectDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters, resetFilters,
} }
} }

View file

@ -1,8 +1,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools' import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools'
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 { signEventViaLnbits } from '@/lib/nostr/signing'
/** /**
* NIP-51 Bookmarks (kind 10003) for saving favorite activities. * NIP-51 Bookmarks (kind 10003) for saving favorite activities.
@ -90,7 +89,7 @@ export function useBookmarks() {
* Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list. * Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list.
*/ */
async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) { async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return if (!isAuthenticated.value || !currentUser.value?.prvkey) return
const coord = `${activityKind}:${pubkey}:${dTag}` const coord = `${activityKind}:${pubkey}:${dTag}`
const newCoords = new Set(state.value.bookmarkedCoords) const newCoords = new Set(state.value.bookmarkedCoords)
@ -111,13 +110,8 @@ export function useBookmarks() {
tags, tags,
} }
let signedEvent: NostrEvent const signingKey = hexToUint8Array(currentUser.value.prvkey)
try { const signedEvent = finalizeEvent(template, signingKey)
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useBookmarks] signEventViaLnbits failed:', err)
return
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB) const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return if (!relayHub) return
@ -153,3 +147,10 @@ export function useBookmarks() {
} }
} }
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}

View file

@ -1,127 +0,0 @@
import { computed, ref, watch } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { ActivityTicket } from '../types/ticket'
/**
* Module-level singleton: owned-ticket lookup keyed by activity id
* (== LNbits event id == NIP-52 d-tag, all the same string by
* extension contract). Lives at module scope so every <ActivityCard>
* + the detail page + the feed filter share ONE underlying fetch
* instead of each instance hitting the API.
*
* Auto-loads on first use after auth is ready, and re-loads when
* the current user changes (login/logout). Consumers that mutate the
* user's ticket set (e.g. a successful purchase) call `refresh()`
* directly so every surface reading this composable updates
* atomically.
*/
const tickets = ref<ActivityTicket[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
let hasAutoLoaded = false
let lastLoadedUserId: string | null = null
async function fetchTickets(): Promise<void> {
const { isAuthenticated, currentUser } = useAuth()
if (!isAuthenticated.value || !currentUser.value) {
tickets.value = []
lastLoadedUserId = null
return
}
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
isLoading.value = true
error.value = null
try {
tickets.value = await ticketApi.fetchUserTickets(currentUser.value.id, accessToken)
lastLoadedUserId = currentUser.value.id
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
tickets.value = []
} finally {
isLoading.value = false
}
}
const ticketsByActivity = computed<Map<string, ActivityTicket[]>>(() => {
const m = new Map<string, ActivityTicket[]>()
for (const ticket of tickets.value) {
const existing = m.get(ticket.activityId)
if (existing) {
existing.push(ticket)
} else {
m.set(ticket.activityId, [ticket])
}
}
return m
})
const ownedActivityIds = computed<Set<string>>(() => {
const s = new Set<string>()
for (const ticket of tickets.value) {
if (ticket.paid) s.add(ticket.activityId)
}
return s
})
function getTickets(activityId: string): ActivityTicket[] {
return ticketsByActivity.value.get(activityId) ?? []
}
/** Number of paid ticket rows for an activity. With the
* multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2),
* this matches the number of attendees / scannable QRs. */
function paidCount(activityId: string): number {
return getTickets(activityId).filter(t => t.paid).length
}
export function useOwnedTickets() {
const { isAuthenticated, currentUser } = useAuth()
// First call kicks off the initial load + sets up the auth-change
// watcher. Subsequent calls attach to the shared state.
if (!hasAutoLoaded) {
hasAutoLoaded = true
fetchTickets()
// Re-fetch when the current user changes (login / logout /
// account switch). Compares against the last-fetched user id
// so we don't re-fetch when other auth fields update (e.g.
// metadata refresh) without the user id changing.
watch(
() => currentUser.value?.id ?? null,
(id) => {
if (id !== lastLoadedUserId) fetchTickets()
},
)
} else if (
!isLoading.value &&
isAuthenticated.value &&
currentUser.value &&
lastLoadedUserId !== currentUser.value.id
) {
// A previous load failed (lastLoadedUserId stayed null) or the
// user changed identity while the singleton was idle. Retry —
// the buyer landing on a fresh detail page after a transient
// backend hiccup shouldn't be stuck with empty tickets.
fetchTickets()
}
return {
tickets,
ticketsByActivity,
ownedActivityIds,
getTickets,
paidCount,
refresh: fetchTickets,
isLoading,
error,
isAuthenticated,
}
}

View file

@ -1,8 +1,7 @@
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools' import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools'
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 { signEventViaLnbits } from '@/lib/nostr/signing'
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52' import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
/** /**
@ -151,7 +150,7 @@ export function useRSVP() {
activityDTag: string, activityDTag: string,
status: RSVPStatus status: RSVPStatus
): Promise<RSVPStatus | null> { ): Promise<RSVPStatus | null> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null if (!isAuthenticated.value || !currentUser.value?.prvkey) return null
const coord = `${activityKind}:${activityPubkey}:${activityDTag}` const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
@ -185,13 +184,8 @@ export function useRSVP() {
], ],
} }
let signedEvent: NostrEvent const signingKey = hexToUint8Array(currentUser.value.prvkey)
try { const signedEvent = finalizeEvent(template, signingKey)
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useRSVP] signEventViaLnbits failed:', err)
return null
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB) const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return null if (!relayHub) return null
@ -246,3 +240,10 @@ export function useRSVP() {
} }
} }
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}

View file

@ -20,16 +20,9 @@ export function useTicketPurchase() {
const qrCode = ref<string | null>(null) const qrCode = ref<string | null>(null)
const isPaymentPending = ref(false) const isPaymentPending = ref(false)
// Ticket QR code state. After payment lands, `purchasedTicketIds` // Ticket QR code state
// is populated with every row id created on the invoice (one for
// a single-ticket purchase, N for multi). `ticketQRCodes` is a
// parallel map id → QR data URL so the UI can render one QR per
// attendee. `purchasedTicketId` stays for back-compat with the
// single-id success path.
const ticketQRCode = ref<string | null>(null) const ticketQRCode = ref<string | null>(null)
const ticketQRCodes = ref<Record<string, string>>({})
const purchasedTicketId = ref<string | null>(null) const purchasedTicketId = ref<string | null>(null)
const purchasedTicketIds = ref<string[]>([])
const showTicketQR = ref(false) const showTicketQR = ref(false)
// Computed properties // Computed properties
@ -82,15 +75,7 @@ export function useTicketPurchase() {
} }
} }
/** The event id this composable is currently driving kept so async function purchaseTicketForEvent(eventId: string) {
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
* have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null)
async function purchaseTicketForEvent(
eventId: string,
options: { quantity?: number } = {},
) {
if (!canPurchase.value || !currentUser.value) { if (!canPurchase.value || !currentUser.value) {
throw new Error('User must be authenticated to purchase tickets') throw new Error('User must be authenticated to purchase tickets')
} }
@ -101,11 +86,8 @@ export function useTicketPurchase() {
paymentRequest.value = null paymentRequest.value = null
qrCode.value = null qrCode.value = null
ticketQRCode.value = null ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false showTicketQR.value = false
currentEventId.value = eventId
// Get the invoice via TicketApiService // Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
@ -114,36 +96,26 @@ export function useTicketPurchase() {
const invoice = await ticketApi.requestTicket( const invoice = await ticketApi.requestTicket(
eventId, eventId,
currentUser.value!.id, currentUser.value!.id,
accessToken, accessToken
{ quantity: options.quantity },
) )
// Backend now returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows
// how to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog
// (it has the user-visible payment-method selector). Reject the
// fiat response here so callers get a clear error instead of a
// silent broken QR.
if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' +
'to follow the provider link.',
)
}
const bolt11: string = invoice.paymentRequest
paymentHash.value = invoice.paymentHash paymentHash.value = invoice.paymentHash
paymentRequest.value = bolt11 paymentRequest.value = invoice.paymentRequest
// Generate QR code for payment // Generate QR code for payment
await generateQRCode(bolt11) await generateQRCode(invoice.paymentRequest)
// Restaurant-style: don't auto-pay. Surface the QR + amount and // Try to pay with wallet if available
// let the buyer pick "Pay with my LNbits wallet" vs "Open in if (hasWalletWithBalance.value) {
// external wallet" on the same screen. The composable just try {
// starts polling so when payment lands (from any path) the UI await payWithWallet(invoice.paymentRequest)
// advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash) await startPaymentStatusCheck(eventId, invoice.paymentHash)
} catch (walletError) {
console.log('Wallet payment failed, falling back to manual payment:', walletError)
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
} else {
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
return invoice return invoice
}, { }, {
@ -151,19 +123,6 @@ export function useTicketPurchase() {
}) })
} }
/**
* Trigger LNbits-wallet payment of the invoice this composable is
* currently displaying. Called when the buyer clicks the "Pay from
* my LNbits wallet" button on the invoice screen.
*/
async function payCurrentInvoiceWithWallet(): Promise<void> {
if (!paymentRequest.value) return
await payWithWallet(paymentRequest.value)
// Polling is already running from purchaseTicketForEvent — when
// the payment lands, it advances to showTicketQR. No need to
// restart it here.
}
async function startPaymentStatusCheck(eventId: string, hash: string) { async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true isPaymentPending.value = true
let checkInterval: number | null = null let checkInterval: number | null = null
@ -178,34 +137,13 @@ export function useTicketPurchase() {
clearInterval(checkInterval) clearInterval(checkInterval)
} }
// Multi-ticket purchases come back with `ticketIds` (N rows if (result.ticketId) {
// sharing one invoice). Single-ticket purchases include purchasedTicketId.value = result.ticketId
// `ticketId` only. Render one QR per row so each attendee await generateTicketQRCode(result.ticketId)
// has their own scannable code at the door.
const ids = result.ticketIds && result.ticketIds.length > 0
? result.ticketIds
: result.ticketId
? [result.ticketId]
: []
if (ids.length > 0) {
purchasedTicketIds.value = ids
purchasedTicketId.value = ids[0]
const qrMap: Record<string, string> = {}
for (const id of ids) {
const dataUrl = await generateTicketQRCode(id)
if (dataUrl) qrMap[id] = dataUrl
}
ticketQRCodes.value = qrMap
ticketQRCode.value = qrMap[ids[0]] ?? null
showTicketQR.value = true showTicketQR.value = true
} }
toast.success( toast.success('Ticket purchased successfully!')
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
} }
} catch (err) { } catch (err) {
console.error('Error checking payment status:', err) console.error('Error checking payment status:', err)
@ -227,9 +165,7 @@ export function useTicketPurchase() {
qrCode.value = null qrCode.value = null
isPaymentPending.value = false isPaymentPending.value = false
ticketQRCode.value = null ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false showTicketQR.value = false
} }
@ -257,9 +193,7 @@ export function useTicketPurchase() {
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
ticketQRCode, ticketQRCode,
ticketQRCodes,
purchasedTicketId, purchasedTicketId,
purchasedTicketIds,
showTicketQR, showTicketQR,
// Computed // Computed
@ -270,7 +204,6 @@ export function useTicketPurchase() {
// Actions // Actions
purchaseTicketForEvent, purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet, handleOpenLightningWallet,
resetPaymentState, resetPaymentState,
cleanup, cleanup,

View file

@ -1,214 +0,0 @@
import { ref, onMounted, type Ref } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { TicketApiService } from '../services/TicketApiService'
export type ScanStatus = 'ok' | 'duplicate-session' | 'error'
export interface ScanResult {
status: ScanStatus
ticketId: string
/** Backend response payload on OK. */
ticket?: Record<string, unknown>
/** Error string from the backend or local validation. */
message?: string
}
export interface ScanRecord {
ticketId: string
/** Holder display name from the backend, if any. */
name?: string | null
registeredAt: string
}
/** A paid ticket as returned by `events_list_event_tickets`. */
export interface EventTicket {
id: string
name?: string | null
registered: boolean
registeredAt: string | null
}
/** Counts + roster snapshot for the event, sourced from the backend. */
export interface EventStats {
sold: number
registered: number
remaining: number
tickets: EventTicket[]
}
/**
* Stateful scanner driver. Owns the camera lifecycle (delegated to
* useQRScanner upstream), the QR decode, the register-ticket call,
* an authoritative event roster fetch, and a session-local dedup
* cache.
*
* Counts + the displayed scanned list come from the backend so the
* UI agrees with reality even when a second organizer is scanning
* on another device. The localStorage cache is kept as a silent
* dedup so the 5-fps decode loop doesn't re-fire the request on a
* QR the camera held in frame for multiple ticks.
*
* Auth: the organizer's wallet admin_key. The events extension's
* `GET /tickets/event/{id}/stats` + `PUT /tickets/register/{id}`
* endpoints both check the event's wallet is in the caller's
* wallet set, so admin_key alone is sufficient. We deliberately
* route via HTTP rather than the kind-21000 nostr-transport RPC
* because post-#9 the webapp no longer holds a raw user prvkey.
*/
export function useTicketScanner(activityId: Ref<string>) {
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
const { currentUser } = useAuth()
const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null)
/**
* Set to `true` immediately after a decode resolves (success or
* failure) and stays true until the operator dismisses or taps
* "Scan next". While paused, further `onDecode` calls are dropped
* the camera keeps streaming for instant resume but the result
* banner sticks so the door can confirm the outcome before
* moving on. Without this, the 5-fps decode loop instantly
* fires "already scanned this session" on the very ticket that
* just succeeded.
*/
const isPaused = ref(false)
/** Server-authoritative counts + per-ticket registered status. */
const eventStats = ref<EventStats | null>(null)
const statsLoading = ref(false)
const statsError = ref<string | null>(null)
/** Session-local dedup. Hidden from UI; only guards repeat decodes. */
const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`,
[],
)
function parseTicketId(qrText: string): string {
return qrText.startsWith('ticket://')
? qrText.slice('ticket://'.length)
: qrText
}
async function refreshStats(): Promise<void> {
if (!activityId.value) return
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) {
statsError.value = 'No wallet admin key available'
return
}
statsLoading.value = true
statsError.value = null
try {
const data = await ticketApi.getEventStats(activityId.value, adminKey)
eventStats.value = {
sold: data.sold,
registered: data.registered,
remaining: data.remaining,
tickets: data.tickets.map(t => ({
id: t.id,
name: t.name ?? null,
registered: t.registered,
registeredAt: t.registered_at,
})),
}
} catch (e) {
statsError.value = e instanceof Error ? e.message : String(e)
} finally {
statsLoading.value = false
}
}
async function onDecode(qrText: string): Promise<void> {
if (isProcessing.value || isPaused.value) return
const ticketId = parseTicketId(qrText).trim()
if (!ticketId) return
// Session-local de-dup. Distinct from the backend's "already
// registered" — this guards against the QR being held in front
// of the camera for multiple decode frames.
if (scanned.value.some(r => r.ticketId === ticketId)) {
lastScan.value = { status: 'duplicate-session', ticketId }
isPaused.value = true
return
}
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) {
lastScan.value = {
status: 'error',
ticketId,
message: 'No wallet admin key available',
}
isPaused.value = true
return
}
isProcessing.value = true
try {
const ticket = await ticketApi.registerTicket(ticketId, adminKey)
scanned.value = [
{
ticketId,
name: ticket.name ?? null,
registeredAt: new Date().toISOString(),
},
...scanned.value,
]
lastScan.value = {
status: 'ok',
ticketId,
ticket: ticket as unknown as Record<string, unknown>,
}
} catch (e) {
// Backend errors surface via TicketApiService.request as the
// HTTP `detail` string: "Ticket not paid for", "Ticket
// already registered", "Ticket does not exist.", "You do not
// own this event.", etc.
lastScan.value = {
status: 'error',
ticketId,
message: e instanceof Error ? e.message : String(e),
}
} finally {
isProcessing.value = false
// Pause the decode loop regardless of outcome. The operator
// taps "Scan next" to resume; this guarantees they see the
// banner and can correct (let the attendee in, deny entry,
// etc.) before the next QR comes into frame.
isPaused.value = true
// Refresh the roster so the counts strip + scanned tab reflect
// the new state. Fire-and-forget — UI doesn't block on it.
void refreshStats()
}
}
function resume() {
lastScan.value = null
isPaused.value = false
}
function clearScanned() {
scanned.value = []
lastScan.value = null
isPaused.value = false
}
onMounted(() => {
void refreshStats()
})
return {
isProcessing,
isPaused,
lastScan,
scanned,
eventStats,
statsLoading,
statsError,
refreshStats,
onDecode,
resume,
clearScanned,
}
}

View file

@ -66,7 +66,6 @@ export function useUserTickets() {
return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered) return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered)
}) })
const groupedTickets = computed(() => { const groupedTickets = computed(() => {
const groups = new Map<string, GroupedTickets>() const groups = new Map<string, GroupedTickets>()

View file

@ -78,15 +78,6 @@ export const activitiesModule = createModulePlugin({
requiresAuth: true, requiresAuth: true,
}, },
}, },
{
path: '/scan/:activityId',
name: 'scan-tickets',
component: () => import('./views/ScanTicketsPage.vue'),
meta: {
title: 'Scan Tickets',
requiresAuth: true,
},
},
{ {
path: '/events', path: '/events',
name: 'events', name: 'events',

View file

@ -1,10 +1,12 @@
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import type { Event as NostrEvent } from 'nostr-tools' import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools'
import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub' import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
import { import {
NIP52_KINDS, NIP52_KINDS,
parseCalendarTimeEvent, parseCalendarTimeEvent,
parseCalendarDateEvent, parseCalendarDateEvent,
buildCalendarTimeEventTags,
type CalendarTimeEvent,
} from '../types/nip52' } from '../types/nip52'
import { import {
calendarTimeEventToActivity, calendarTimeEventToActivity,
@ -23,20 +25,10 @@ export interface CalendarEventFilters {
hashtags?: string[] hashtags?: string[]
/** Filter by geohash prefix (NIP-52 'g' tag) */ /** Filter by geohash prefix (NIP-52 'g' tag) */
geohash?: string geohash?: string
/** Filter by NIP-52 'd' tag — scopes the query to specific parameterized-replaceable events */
dTags?: string[]
} }
/** /**
* Service for subscribing to NIP-52 Calendar Events via RelayHub. * Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub.
*
* Publishing kind-31922 calendar events lives server-side in the
* `aiolabs/events` LNbits extension (signer-abstraction branch, commit
* 66076d6) `POST /events/api/v1/events` constructs and signs the
* event via NostrSigner and broadcasts it to the operator's configured
* relays. The webapp constructs only the request payload; see
* CreateActivityDialog 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 ActivitiesNostrService extends BaseService {
@ -113,6 +105,32 @@ export class ActivitiesNostrService extends BaseService {
return activities return activities
} }
/**
* Publish a NIP-52 time-based calendar event.
* Requires an authenticated user with a signing key.
*/
async publishCalendarEvent(
eventData: Partial<CalendarTimeEvent>,
signingKeyHex: string
): Promise<{ success: number; total: number }> {
if (!this.relayHub) {
throw new Error('RelayHub not available')
}
const tags = buildCalendarTimeEventTags(eventData)
const template: EventTemplate = {
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
created_at: Math.floor(Date.now() / 1000),
content: eventData.content ?? '',
tags,
}
const privkeyBytes = hexToUint8Array(signingKeyHex)
const signedEvent = finalizeEvent(template, privkeyBytes)
return await this.relayHub.publishEvent(signedEvent)
}
/** /**
* Parse a raw Nostr event into an Activity view model. * Parse a raw Nostr event into an Activity view model.
*/ */
@ -150,7 +168,6 @@ export class ActivitiesNostrService extends BaseService {
if (filters?.authors?.length) filter.authors = filters.authors if (filters?.authors?.length) filter.authors = filters.authors
if (filters?.hashtags?.length) filter['#t'] = filters.hashtags if (filters?.hashtags?.length) filter['#t'] = filters.hashtags
if (filters?.geohash) filter['#g'] = [filters.geohash] if (filters?.geohash) filter['#g'] = [filters.geohash]
if (filters?.dTags?.length) filter['#d'] = filters.dTags
return [filter] return [filter]
} }
@ -162,3 +179,11 @@ export class ActivitiesNostrService extends BaseService {
this.activeUnsubscribes = [] this.activeUnsubscribes = []
} }
} }
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}

View file

@ -1,8 +1,5 @@
import type { import type {
ActivityTicket, ActivityTicket,
ActivityTicketExtra,
CreateTicketRequest,
PaymentMethod,
TicketPurchaseInvoice, TicketPurchaseInvoice,
TicketPaymentStatus, TicketPaymentStatus,
TicketedEvent, TicketedEvent,
@ -52,41 +49,14 @@ export class TicketApiService {
} }
/** /**
* Request a ticket purchase. Returns either a Lightning invoice * Request a ticket purchase (creates a Lightning invoice).
* (`paymentRequest` = bolt11) or a fiat invoice (`fiatPaymentRequest` * Uses POST /tickets/{event_id} with user_id in body (upstream API).
* = follow-the-URL string from the configured fiat provider). The
* `isFiat` flag is the discriminator.
*
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
* the fiat path (requires the event to have `allow_fiat=true`).
* `fiatProvider` is optional backend picks the user's configured
* default when omitted.
*
* Additional ticket metadata (promo code, refund address, nostr
* identifier for DM delivery) can be supplied via `options`.
*/ */
async requestTicket( async requestTicket(
eventId: string, eventId: string,
userId: string, userId: string,
accessToken: string, accessToken: string
options: {
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
} = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId }
if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}`, `/events/api/v1/tickets/${eventId}`,
{ {
@ -95,16 +65,13 @@ export class TicketApiService {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, 'Authorization': `Bearer ${accessToken}`,
}, },
body: JSON.stringify(body), body: JSON.stringify({ user_id: userId }),
} }
) )
return { return {
paymentHash: data.payment_hash, paymentHash: data.payment_hash,
paymentRequest: data.payment_request ?? undefined, paymentRequest: data.payment_request,
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat),
} }
} }
@ -123,7 +90,6 @@ export class TicketApiService {
return { return {
paid: data.paid === true, paid: data.paid === true,
ticketId: data.ticket_id, ticketId: data.ticket_id,
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} }
} }
@ -155,7 +121,6 @@ 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,
})) }))
} }
@ -179,7 +144,6 @@ 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,
})) }))
} }
@ -219,93 +183,6 @@ export class TicketApiService {
}) })
} }
/**
* Resend the ticket confirmation email for a paid ticket. Requires
* the event's wallet admin key (organizer-only). Returns the updated
* Ticket with the `email_notification_sent` flag refreshed.
*
* Endpoint added upstream in v1.6.1 (PR #51).
*/
async resendTicketEmail(
ticketId: string,
adminKey: string,
): Promise<ActivityTicket> {
const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`,
{
method: 'POST',
headers: { 'X-API-KEY': adminKey },
}
)
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}
}
/**
* Door-scanner roster + counts for one event. Organizer-only
* requires the event-owning wallet's admin_key. Returns the same
* shape the `events_list_event_tickets` nostr-transport RPC does;
* we route via HTTP because post-#9 the webapp no longer holds a
* raw user prvkey to sign kind-21000 envelopes with.
*/
async getEventStats(
eventId: string,
adminKey: string,
): Promise<{
event_id: string
sold: number
registered: number
remaining: number
tickets: Array<{
id: string
name?: string | null
registered: boolean
registered_at: string | null
}>
}> {
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
method: 'GET',
headers: { 'X-API-KEY': adminKey },
})
}
/**
* Mark a paid ticket as registered at the door. Organizer-only
* requires the event-owning wallet's admin_key. Backend rejects
* unpaid / already-registered / not-owned cases with HTTP errors
* whose `detail` becomes the thrown Error message.
*/
async registerTicket(ticketId: string, adminKey: string): Promise<ActivityTicket> {
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
method: 'PUT',
headers: { 'X-API-KEY': adminKey },
})
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}
}
/** /**
* Probe whether the current user has LNbits admin privileges. The * Probe whether the current user has LNbits admin privileges. The
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin", * `/all` endpoint is `check_admin`-gated, so a 200 means "admin",

View file

@ -1,6 +1,6 @@
import ngeohash from 'ngeohash' import ngeohash from 'ngeohash'
import type { ActivityCategory } from './category' import type { ActivityCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52' import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
import type { TicketedEvent } from './ticket' import type { TicketedEvent } from './ticket'
/** /**
@ -74,26 +74,8 @@ export interface OrganizerInfo {
export interface ActivityTicketInfo { export interface ActivityTicketInfo {
price: number price: number
currency: string currency: string
/** Remaining capacity. Undefined means unlimited. */ available: number
available?: number total: number
/** Running paid count. */
sold: number
/** Whether the organizer enabled fiat checkout. */
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
}
function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo | undefined {
if (!ticket) return undefined
return {
price: ticket.price,
currency: ticket.currency,
available: ticket.available,
sold: ticket.sold,
allowFiat: ticket.allowFiat,
fiatCurrency: ticket.fiatCurrency,
}
} }
/** /**
@ -122,7 +104,6 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
geohash: event.geohash, geohash: event.geohash,
category, category,
tags: event.hashtags, tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false, isPrivate: false,
createdAt: new Date(event.createdAt * 1000), createdAt: new Date(event.createdAt * 1000),
} }
@ -159,7 +140,6 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
geohash: event.geohash, geohash: event.geohash,
category, category,
tags: event.hashtags, tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false, isPrivate: false,
createdAt: new Date(event.createdAt * 1000), createdAt: new Date(event.createdAt * 1000),
} }

View file

@ -17,27 +17,6 @@ export const NIP52_KINDS = {
export type Nip52Kind = typeof NIP52_KINDS[keyof typeof NIP52_KINDS] export type Nip52Kind = typeof NIP52_KINDS[keyof typeof NIP52_KINDS]
/**
* AIO custom tags carried on ticketed calendar events. The aiolabs/events
* extension adds these so connected clients can render the buy CTA + the
* "X tickets remaining" badge without an extra REST hop. Absent when the
* event was published by a non-AIO client.
*/
export interface TicketTags {
/** Remaining capacity. Undefined means unlimited. */
available?: number
/** Running paid-count. */
sold: number
/** Price per ticket in the event's `currency`. */
price: number
/** Currency string (e.g. 'sat', 'sats', 'USD'). */
currency: string
/** Whether the organizer enabled fiat checkout. */
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
}
/** /**
* Parsed NIP-52 date-based calendar event (kind 31922) * Parsed NIP-52 date-based calendar event (kind 31922)
*/ */
@ -57,7 +36,6 @@ export interface CalendarDateEvent {
references: string[] references: string[]
id: string id: string
createdAt: number createdAt: number
ticket?: TicketTags
} }
/** /**
@ -81,7 +59,6 @@ export interface CalendarTimeEvent {
references: string[] references: string[]
id: string id: string
createdAt: number createdAt: number
ticket?: TicketTags
} }
export interface Participant { export interface Participant {
@ -119,35 +96,6 @@ function getTagValues(tags: string[][], tagName: string): string[] {
return tags.filter(t => t[0] === tagName).map(t => t[1]) return tags.filter(t => t[0] === tagName).map(t => t[1])
} }
/**
* Parse the AIO ticket_* tags off a NIP-52 calendar event. Returns
* undefined when the event carries no ticket info (e.g. an event
* published by a non-AIO client or a non-ticketed AIO event though
* the latter doesn't currently exist since every aiolabs/events row
* has a price + currency).
*
* `tickets_currency` is the discriminator: when absent, the event has
* no inventory metadata and the buy UI stays hidden.
*/
function parseTicketTags(tags: string[][]): TicketTags | undefined {
const currency = getTagValue(tags, 'tickets_currency')
if (!currency) return undefined
const availableStr = getTagValue(tags, 'tickets_available')
const soldStr = getTagValue(tags, 'tickets_sold')
const priceStr = getTagValue(tags, 'tickets_price')
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
return {
available: availableStr != null ? Number(availableStr) : undefined,
sold: soldStr != null ? Number(soldStr) : 0,
price: priceStr != null ? Number(priceStr) : 0,
currency,
allowFiat: allowFiatStr === 'true',
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
}
}
/** /**
* Parse a NIP-52 start/end tag value to a unix timestamp in seconds. * Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
* Handles: unix seconds, unix milliseconds, and ISO date strings. * Handles: unix seconds, unix milliseconds, and ISO date strings.
@ -218,7 +166,6 @@ export function parseCalendarTimeEvent(event: NostrEvent): CalendarTimeEvent | n
references: getTagValues(event.tags, 'r'), references: getTagValues(event.tags, 'r'),
id: event.id, id: event.id,
createdAt: event.created_at, createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
} }
} }
@ -266,7 +213,6 @@ export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | n
references: getTagValues(event.tags, 'r'), references: getTagValues(event.tags, 'r'),
id: event.id, id: event.id,
createdAt: event.created_at, createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
} }
} }

View file

@ -1,44 +1,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
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
* below) are the webapp-internal view models after adapter conversion.
*/ */
export interface PromoCode {
code: string
discount_percent: number
active: boolean
}
/**
* EventExtra mirrors the EventExtra Pydantic model in
* `events/models.py`. Carries promo codes, conditional-event config,
* and the per-event notification toggles + custom subject/body added
* in upstream v1.4.0 (PR #50) and v1.6.0.
*/
export interface EventExtra {
promo_codes: PromoCode[]
conditional: boolean
min_tickets: number
email_notifications: boolean
nostr_notifications: boolean
notification_subject: string
notification_body: string
}
export interface ActivityTicketExtra {
applied_promo_code?: string | null
sats_paid?: number | null
refund_address?: string | null
nostr_identifier?: string | null
ticket_base_url?: string | null
email_notification_sent: boolean
nostr_notification_sent: boolean
refunded: boolean
}
export interface ActivityTicket { export interface ActivityTicket {
id: string id: string
wallet: string wallet: string
@ -58,51 +21,24 @@ export interface ActivityTicket {
time: string time: string
/** Registration/scan timestamp */ /** Registration/scan timestamp */
regTimestamp: string regTimestamp: string
/** Optional metadata promo code applied, sats paid, notification
* delivery flags, refund state. May be absent on older tickets. */
extra?: ActivityTicketExtra
} }
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled' export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
export type PaymentMethod = 'lightning' | 'fiat'
export interface TicketPurchaseRequest { export interface TicketPurchaseRequest {
activityId: string activityId: string
userId: string userId: string
accessToken: string accessToken: string
/** Lightning (default) or fiat. Only meaningful if the event has
* `allow_fiat=true` on the backend; otherwise the backend coerces
* to lightning. */
paymentMethod?: PaymentMethod
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
* user's default if omitted. */
fiatProvider?: string
} }
/**
* Server response from `POST /tickets/{event_id}`. Either Lightning
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* the buyer follows to complete payment with `fiatProvider`).
* `isFiat` is the discriminator.
*/
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string
paymentRequest?: string paymentRequest: string
fiatPaymentRequest?: string
fiatProvider?: string
isFiat: boolean
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {
paid: boolean paid: boolean
/** First ticket id created on this invoice. Back-compat with
* single-ticket purchases equals the payment_hash. */
ticketId?: string ticketId?: string
/** Every row created on this invoice one for single-ticket
* purchases, N for multi-ticket. Each row is independently
* scannable at the door. */
ticketIds?: string[]
} }
/** /**
@ -122,10 +58,6 @@ export interface TicketedEvent {
event_start_date: string event_start_date: string
event_end_date: string | null event_end_date: string | null
currency: string currency: string
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */
allow_fiat: boolean
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
fiat_currency: string
amount_tickets: number amount_tickets: number
price_per_ticket: number price_per_ticket: number
time: string time: string
@ -133,7 +65,6 @@ export interface TicketedEvent {
banner: string | null banner: string | null
location: string | null location: string | null
categories: string[] categories: string[]
extra: EventExtra
status: string status: string
} }
@ -145,36 +76,9 @@ export interface CreateEventRequest {
event_start_date: string event_start_date: string
event_end_date?: string event_end_date?: string
currency?: string currency?: string
allow_fiat?: boolean
fiat_currency?: string
amount_tickets?: number amount_tickets?: number
price_per_ticket?: number price_per_ticket?: number
banner?: string | null banner?: string | null
location?: string | null location?: string | null
categories?: string[] categories?: string[]
/** Optional notification toggles + custom subject/body, promo
* codes, conditional-event config. Backend defaults to a fresh
* EventExtra if omitted. */
extra?: Partial<EventExtra>
}
/**
* Body for `POST /tickets/{event_id}`. Either `user_id` OR the
* `name`+`email` pair is required (backend root_validator enforces
* mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM
* delivery when the event has nostr_notifications enabled. The
* `payment_method` + `fiat_provider` pair selects between Lightning
* and fiat checkout.
*/
export interface CreateTicketRequest {
name?: string
email?: string
user_id?: string
promo_code?: string
refund_address?: string
nostr_identifier?: string
payment_method?: PaymentMethod
fiat_provider?: string
/** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number
} }

View file

@ -8,9 +8,8 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next' import { SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities' import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue' import ActivitySearchOverlay from '../components/ActivitySearchOverlay.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'
@ -29,22 +28,14 @@ const {
selectedCategories, selectedCategories,
hasActiveFilters, hasActiveFilters,
selectedDate, selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
selectDate, selectDate,
setTemporal, setTemporal,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters, resetFilters,
subscribe, subscribe,
} = useActivities() } = useActivities()
const { isAuthenticated } = useAuth()
const filtersOpen = ref(false) const filtersOpen = ref(false)
onMounted(() => { onMounted(() => {
@ -83,43 +74,6 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" /> <TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div> </div>
<!-- Role + past-events filter chips. The role chips ("My tickets",
"Hosting") narrow the feed to activities the signed-in user
has skin in and are hidden when logged out. The "Past events"
chip is always visible since past-browsing doesn't require an
account. -->
<div class="mb-4 flex flex-wrap gap-2">
<template v-if="isAuthenticated">
<Button
:variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleOwnedTickets"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }}
</Button>
</template>
<Button
:variant="showPast ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="togglePast"
>
<History class="w-3.5 h-3.5" />
{{ t('activities.filters.pastEvents', 'Past events') }}
</Button>
</div>
<!-- Category filters (collapsible) --> <!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6"> <Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child> <CollapsibleTrigger as-child>

View file

@ -8,18 +8,15 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History, Calendar, MapPin, ArrowLeft, Pencil,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail' import { useActivityDetail } from '../composables/useActivityDetail'
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 { 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 { useActivitiesStore } from '../stores/activities'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { toastService } from '@/core/services/ToastService'
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 { TicketedEvent } from '../types/ticket' import type { TicketedEvent } from '../types/ticket'
@ -64,10 +61,6 @@ function openEditDialog() {
activitiesStore.showCreateDialog = true activitiesStore.showCreateDialog = true
} }
function openScannerPage() {
router.push({ name: 'scan-tickets', params: { activityId } })
}
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
if (!activity.value) return '' if (!activity.value) return ''
const a = activity.value const a = activity.value
@ -101,71 +94,6 @@ const categoryLabel = computed(() => {
function goBack() { function goBack() {
router.push({ name: 'activities' }) router.push({ name: 'activities' })
} }
// --- Ticket purchase + owned-tickets surface ----------------------
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
const ownedPaidCount = computed(() => paidCount(activityId))
const purchaseEvent = computed(() => {
const a = activity.value
if (!a || !a.ticketInfo) return null
return {
id: a.id,
name: a.title,
price_per_ticket: a.ticketInfo.price,
currency: a.ticketInfo.currency,
allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency,
}
})
// available === undefined unlimited capacity, button always shown
// available === 0 sold out, button hidden
// available > 0 button shown
const canBuyTicket = computed(() => {
const info = activity.value?.ticketInfo
if (!info) return false
return info.available === undefined || info.available > 0
})
// Past events can't be bought into. The notice below replaces the
// buy CTA so the flow is unambiguous date alone is easy to miss
// on a long detail page.
const isPast = computed(() => {
const a = activity.value
if (!a) return false
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
const showPurchaseDialog = ref(false)
function openPurchaseDialog() {
if (!isAuthenticated.value) {
toastService.info(t('activities.detail.loginToBuyTickets'), {
action: {
label: t('activities.detail.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
showPurchaseDialog.value = true
}
// Re-fetch the user's tickets when the purchase dialog closes (the
// buyer may have just paid). The inventory side updates automatically
// via the relay republish from the events extension.
watch(showPurchaseDialog, (open) => {
if (!open) refreshOwnedTickets()
})
function goToMyTickets() {
router.push('/my-tickets')
}
</script> </script>
<template> <template>
@ -177,17 +105,6 @@ function goToMyTickets() {
Back Back
</Button> </Button>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openScannerPage"
aria-label="Scan tickets"
>
<ScanLine class="w-4 h-4" />
Scan
</Button>
<Button <Button
v-if="ownedLnbitsEvent" v-if="ownedLnbitsEvent"
variant="ghost" variant="ghost"
@ -302,79 +219,6 @@ function goToMyTickets() {
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT" :kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/> />
<!-- Tickets gated on the activity carrying ticketInfo (set
by the calendarActivity converter from the AIO custom
tickets_* tags on the published event). Sections render
bottom-up: availability count, then existing owned
tickets (when count > 0) above a Purchase CTA (when
capacity remains). -->
<div v-if="activity.ticketInfo" class="space-y-3">
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Ticket class="w-4 h-4 shrink-0" />
<span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }}
</span>
</div>
<div
v-if="ownedPaidCount > 0"
class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-center justify-between gap-3"
>
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</div>
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('activities.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
</div>
<div
v-if="isPast"
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" />
{{ t('activities.detail.pastEvent', 'This event has already happened') }}
</div>
<div v-else-if="canBuyTicket">
<Button
class="w-full gap-1.5"
size="lg"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ ownedPaidCount > 0
? t('activities.detail.buyAnotherTicket', 'Buy another ticket')
: t('activities.detail.buyTicket', 'Buy ticket') }}
<span class="ml-2 opacity-80 font-normal">
{{ activity.ticketInfo.price === 0
? t('activities.detail.free')
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
</span>
</Button>
</div>
<p
v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center"
>
{{ t('activities.detail.soldOut') }}
</p>
</div>
<PurchaseTicketDialog
v-if="purchaseEvent"
:is-open="showPurchaseDialog"
:event="purchaseEvent"
@update:is-open="showPurchaseDialog = $event"
/>
<!-- 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">

View file

@ -27,8 +27,6 @@ const selectedEvent = ref<{
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
allow_fiat?: boolean
fiat_currency?: string
} | null>(null) } | null>(null)
const showEventDialog = ref(false) const showEventDialog = ref(false)
@ -58,8 +56,6 @@ function handlePurchaseClick(event: {
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
allow_fiat?: boolean
fiat_currency?: string
}) { }) {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedEvent.value = event selectedEvent.value = event

View file

@ -1,292 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft,
CheckCircle2,
AlertCircle,
Clock,
Ticket,
ScanLine,
RefreshCw,
} from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner'
import { useActivityDetail } from '../composables/useActivityDetail'
const route = useRoute()
const router = useRouter()
const activityId = ref(route.params.activityId as string)
const { activity } = useActivityDetail(activityId.value)
const {
isProcessing,
isPaused,
lastScan,
eventStats,
statsLoading,
statsError,
refreshStats,
onDecode,
resume,
} = useTicketScanner(activityId)
const scannerOpen = ref(true)
const activeTab = ref<'scanner' | 'list'>('scanner')
const lastScanVariant = computed(() => {
switch (lastScan.value?.status) {
case 'ok':
return 'success'
case 'duplicate-session':
return 'warning'
case 'error':
return 'destructive'
default:
return null
}
})
// Backend-authoritative roster. Falls back to the activity nostr
// event's `tickets_sold` tag if the RPC hasn't completed yet.
const soldCount = computed(
() => eventStats.value?.sold ?? activity.value?.ticketInfo?.sold,
)
const registeredCount = computed(() => eventStats.value?.registered ?? 0)
const remainingCount = computed(() => {
if (soldCount.value == null) return undefined
return Math.max(0, soldCount.value - registeredCount.value)
})
// Registered tickets only what the "Scanned" tab shows.
const registeredTickets = computed(
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
)
function handleResult(qrText: string) {
// Don't pause the scanner useQRScanner's `maxScansPerSecond: 5`
// already throttles, and useTicketScanner.onDecode dedups the same
// ticket id at the session-list level.
void onDecode(qrText)
}
function goBack() {
if (window.history.length > 1) router.back()
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
}
function fmtTime(iso: string) {
try {
return format(new Date(iso), 'HH:mm:ss')
} catch {
return iso
}
}
</script>
<template>
<div class="container mx-auto py-6 px-4 max-w-2xl">
<!-- Top bar -->
<div class="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<Button
variant="ghost"
size="sm"
class="gap-1.5"
:disabled="statsLoading"
@click="refreshStats"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': statsLoading }" />
Refresh
</Button>
</div>
<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">
{{ activity.title }}
</p>
<!-- Counts strip backend-authoritative. Source: the
`events_list_event_tickets` RPC, refreshed after every scan.
Stays consistent across organizer devices unlike a
per-device localStorage count. -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
<p class="text-2xl font-bold text-foreground">{{ registeredCount }}</p>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Scanned</p>
</div>
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
<p class="text-2xl font-bold text-foreground">
{{ soldCount ?? '—' }}
</p>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Sold</p>
</div>
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
<p class="text-2xl font-bold text-foreground">
{{ remainingCount ?? '—' }}
</p>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Remaining</p>
</div>
</div>
<!-- Surface stats fetch failures (e.g. backend missing the /stats
endpoint, or wallet ownership rejected). Without this the
counts strip silently freezes on the last good value while
scans keep landing on the backend. -->
<div
v-if="statsError"
class="mb-4 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-xs text-destructive"
role="alert"
>
<AlertCircle class="w-4 h-4 mt-0.5 shrink-0" />
<div class="min-w-0">
<p class="font-medium">Counts may be out of date</p>
<p class="text-destructive/80 mt-0.5 break-words">{{ statsError }}</p>
</div>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="scanner" class="gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</TabsTrigger>
<TabsTrigger value="list" class="gap-1.5">
<Ticket class="w-4 h-4" />
Scanned ({{ registeredCount }})
</TabsTrigger>
</TabsList>
<TabsContent value="scanner" class="mt-0">
<!-- Scanner -->
<div v-if="scannerOpen" class="bg-card rounded-lg border border-border overflow-hidden">
<QRScanner @result="handleResult" @close="scannerOpen = false" />
</div>
<div v-else class="flex justify-center my-6">
<Button @click="scannerOpen = true" class="gap-1.5">
<Ticket class="w-4 h-4" />
Resume scanning
</Button>
</div>
<!-- Sending indicator (popup handles success/error post-fact). -->
<p
v-if="isProcessing && !isPaused"
class="text-xs text-center text-muted-foreground mt-3"
>
Sending registration over Nostr
</p>
</TabsContent>
<TabsContent value="list" class="mt-0">
<div class="space-y-3">
<h2 class="text-sm font-medium text-foreground">
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
</h2>
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
<ul class="space-y-1.5 pr-3">
<li
v-for="record in registeredTickets"
:key="record.id"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<Badge
v-if="record.registeredAt"
variant="secondary"
class="text-[10px] font-mono px-1.5"
>
{{ fmtTime(record.registeredAt) }}
</Badge>
<span v-if="record.name" class="font-medium text-foreground">
{{ record.name }}
</span>
</div>
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
{{ record.id }}
</p>
</div>
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
</li>
</ul>
</ScrollArea>
<p v-else class="text-sm text-muted-foreground text-center py-12">
No tickets scanned yet.
</p>
</div>
</TabsContent>
</Tabs>
<!-- Full-screen result overlay. Tap anywhere to dismiss and
resume the decode loop. Replaces the inline banner so the
door operator can't miss the outcome a busy entry meant
the small banner was easy to skim past. -->
<div
v-if="lastScan && isPaused"
class="fixed inset-0 z-50 flex items-center justify-center p-6 cursor-pointer"
:class="{
'bg-emerald-500/95': lastScanVariant === 'success',
'bg-amber-500/95': lastScanVariant === 'warning',
'bg-destructive/95': lastScanVariant === 'destructive',
}"
role="alertdialog"
aria-modal="true"
@click="resume"
>
<div class="text-center max-w-md">
<CheckCircle2
v-if="lastScanVariant === 'success'"
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<Clock
v-else-if="lastScanVariant === 'warning'"
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<AlertCircle
v-else
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<p class="text-3xl sm:text-4xl font-bold text-white">
<template v-if="lastScan.status === 'ok'">
Registered
</template>
<template v-else-if="lastScan.status === 'duplicate-session'">
Already scanned
</template>
<template v-else>
Scan failed
</template>
</p>
<p
v-if="lastScan.status === 'ok' && lastScan.ticket?.name"
class="text-xl text-white/90 mt-2 font-medium"
>
{{ lastScan.ticket.name }}
</p>
<p
v-else-if="lastScan.status === 'error'"
class="text-base text-white/90 mt-2"
>
{{ lastScan.message }}
</p>
<p class="text-xs font-mono text-white/70 break-all mt-4 px-4">
{{ lastScan.ticketId }}
</p>
<p class="text-xs uppercase tracking-widest text-white/80 mt-8">
Tap anywhere to scan next
</p>
</div>
</div>
</div>
</template>

View file

@ -2,7 +2,9 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { eventBus } from '@/core/event-bus' import { eventBus } from '@/core/event-bus'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits' import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits' import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits'
export class AuthService extends BaseService { export class AuthService extends BaseService {
@ -112,6 +114,9 @@ export class AuthService extends BaseService {
eventBus.emit('auth:login', { user: userData }, 'auth-service') eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on login
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'login') const err = this.handleError(error, 'login')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service') eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -133,6 +138,9 @@ export class AuthService extends BaseService {
eventBus.emit('auth:login', { user: userData }, 'auth-service') eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on registration
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'register') const err = this.handleError(error, 'register')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service') eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -180,14 +188,18 @@ export class AuthService extends BaseService {
this.isLoading.value = true this.isLoading.value = true
const updatedUser = await this.lnbitsAPI.updateProfile(data) const updatedUser = await this.lnbitsAPI.updateProfile(data)
// Preserve pubkey from existing user since /auth/update doesn't return it. // Preserve prvkey and pubkey from existing user since /auth/update doesn't return them
// Kind-0 metadata is published server-side by lnbits's PATCH /auth handler
// (aiolabs/lnbits commit 869f67c3); no webapp-side broadcast path remains.
this.user.value = { this.user.value = {
...updatedUser, ...updatedUser,
pubkey: this.user.value?.pubkey || updatedUser.pubkey, pubkey: this.user.value?.pubkey || updatedUser.pubkey,
prvkey: this.user.value?.prvkey || updatedUser.prvkey
} }
// Auto-broadcast Nostr metadata when profile is updated
// Note: ProfileSettings component will also manually broadcast,
// but this ensures metadata stays in sync even if updated elsewhere
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'updateProfile') const err = this.handleError(error, 'updateProfile')
throw err throw err
@ -196,6 +208,26 @@ export class AuthService extends BaseService {
} }
} }
/**
* Broadcast user metadata to Nostr relays (NIP-01 kind 0)
* Called automatically on login, registration, and profile updates
*/
private async broadcastNostrMetadata(): Promise<void> {
try {
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
if (metadataService && this.user.value?.pubkey) {
// Broadcast in background - don't block login/update
metadataService.publishMetadata().catch(error => {
console.warn('Failed to broadcast Nostr metadata:', error)
// Don't throw - this is a non-critical background operation
})
}
} catch (error) {
// If service isn't available yet, silently skip
console.debug('Nostr metadata service not yet available')
}
}
/** /**
* Cleanup when service is disposed * Cleanup when service is disposed
*/ */

View file

@ -122,17 +122,32 @@
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<Button <Button
type="submit" type="submit"
:disabled="isUpdating || !isFormValid" :disabled="isUpdating || !isFormValid"
class="w-full" class="flex-1"
> >
<span v-if="isUpdating">Updating...</span> <span v-if="isUpdating">Updating...</span>
<span v-else>Update Profile</span> <span v-else>Update Profile</span>
</Button> </Button>
<Button
type="button"
variant="outline"
:disabled="isBroadcasting"
@click="broadcastMetadata"
class="flex-1"
>
<Radio class="mr-2 h-4 w-4" :class="{ 'animate-pulse': isBroadcasting }" />
<span v-if="isBroadcasting">Broadcasting...</span>
<span v-else>Broadcast to Nostr</span>
</Button>
</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Your profile is broadcast to Nostr automatically when you save changes. Your profile is automatically broadcast to Nostr when you update it or log in.
Use the "Broadcast to Nostr" button to manually re-broadcast your profile.
</p> </p>
</form> </form>
@ -174,7 +189,7 @@ import * as z from 'zod'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { User, Zap, Hash } from 'lucide-vue-next' import { User, Zap, Hash, Radio } from 'lucide-vue-next'
import { import {
FormControl, FormControl,
FormDescription, FormDescription,
@ -200,16 +215,19 @@ import { useAuth } from '@/composables/useAuthService'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService' import type { ImageUploadService } from '../services/ImageUploadService'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
// Services // Services
const { user, updateProfile, logout } = useAuth() const { user, updateProfile, logout } = useAuth()
const router = useRouter() const router = useRouter()
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
const toast = useToast() const toast = useToast()
// Local state // Local state
const isUpdating = ref(false) const isUpdating = ref(false)
const isBroadcasting = ref(false)
const updateError = ref<string | null>(null) const updateError = ref<string | null>(null)
const updateSuccess = ref(false) const updateSuccess = ref(false)
const uploadedPicture = ref<any[]>([]) const uploadedPicture = ref<any[]>([])
@ -305,12 +323,18 @@ const updateUserProfile = async (formData: any) => {
} }
} }
// Update profile via AuthService (which updates LNbits). // Update profile via AuthService (which updates LNbits)
// Kind-0 metadata publishing happens server-side as part of the
// PATCH /api/v1/auth handler (aiolabs/lnbits 869f67c3).
await updateProfile(updateData) await updateProfile(updateData)
toast.success('Profile updated!') // Broadcast to Nostr automatically
try {
await metadataService.publishMetadata()
toast.success('Profile updated and broadcast to Nostr!')
} catch (nostrError) {
console.error('Failed to broadcast to Nostr:', nostrError)
toast.warning('Profile updated, but failed to broadcast to Nostr')
}
updateSuccess.value = true updateSuccess.value = true
// Clear success message after 3 seconds // Clear success message after 3 seconds
@ -328,6 +352,22 @@ const updateUserProfile = async (formData: any) => {
} }
} }
// Manually broadcast metadata to Nostr
const broadcastMetadata = async () => {
isBroadcasting.value = true
try {
const result = await metadataService.publishMetadata()
toast.success(`Profile broadcast to ${result.success}/${result.total} relays!`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to broadcast metadata'
console.error('Error broadcasting metadata:', error)
toast.error(`Failed to broadcast: ${errorMessage}`)
} finally {
isBroadcasting.value = false
}
}
// Log out + redirect to /login on this app's origin. // Log out + redirect to /login on this app's origin.
const onLogout = async () => { const onLogout = async () => {
try { try {

View file

@ -1,131 +0,0 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useFormContext } from 'vee-validate'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
const props = defineProps<{
/** Field name on the parent vee-validate form for the boolean toggle. */
allowFiatField: string
/** Field name on the parent vee-validate form for the fiat-currency dropdown. */
fiatCurrencyField: string
/** Current value of the price-denomination field (e.g. 'sat', 'USD'). */
denomination: string
/** Allowed values for the fiat-currency dropdown. */
availableFiatCurrencies: string[]
/** Disable all controls (e.g. while the parent form is submitting). */
disabled?: boolean
}>()
const { hasAnyProvider, refresh } = useFiatProviders()
const form = useFormContext()
// Refresh once on mount so the disabled-state reflects providers the
// user may have just configured in another tab.
refresh()
// "sat" / "sats" appear interchangeably across the LNbits events
// extension and the webapp's currency lists treat both as the
// BTC-denominated case for the conditional + auto-mirror.
function isSatDenomination(d: string): boolean {
return d === 'sat' || d === 'sats'
}
// When the price is denominated in a fiat currency, the rail currency
// MUST match it silently mirror so backend payload stays consistent.
watch(
() => props.denomination,
(d) => {
if (!form) return
if (
d &&
!isSatDenomination(d) &&
form.values[props.fiatCurrencyField as keyof typeof form.values] !== d
) {
form.setFieldValue(props.fiatCurrencyField, d)
}
},
{ immediate: true },
)
</script>
<template>
<FormField v-slot="{ value: allowFiat, handleChange: setAllowFiat }" :name="allowFiatField">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
<FormItem class="sm:col-span-2 flex flex-row items-center justify-between rounded-md border p-3">
<div class="space-y-0.5">
<FormLabel>Also accept fiat</FormLabel>
<FormDescription class="text-xs">
Buyers can pay with card or bank through your configured provider.
</FormDescription>
</div>
<FormControl>
<TooltipProvider v-if="!hasAnyProvider" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Switch :model-value="false" disabled />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-xs">
Your LNbits user has no fiat provider configured. Open
LNbits Account Fiat providers and add Stripe, PayPal,
or Square to enable this.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
v-else
:model-value="allowFiat as boolean"
:disabled="disabled"
@update:model-value="setAllowFiat"
/>
</FormControl>
</FormItem>
<FormField v-slot="{ componentField }" :name="fiatCurrencyField">
<FormItem v-show="(allowFiat as boolean) && isSatDenomination(denomination)">
<FormLabel>Fiat currency</FormLabel>
<FormControl>
<Select v-bind="componentField" :disabled="disabled">
<SelectTrigger>
<SelectValue placeholder="USD" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="c in availableFiatCurrencies"
:key="c"
:value="c"
>
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</FormField>
</template>

View file

@ -1,89 +0,0 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type PaymentRail =
| 'lightning'
| 'fiat'
| 'cash'
| 'internal'
| (string & {})
export interface PaymentMethod {
id: string
rail: PaymentRail
provider?: string
label: string
icon: Component
available: boolean
unavailableReason?: string
badge?: string
}
defineProps<{
methods: PaymentMethod[]
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [id: string]
}>()
function select(method: PaymentMethod) {
if (!method.available) return
emit('update:modelValue', method.id)
}
</script>
<template>
<div
class="grid gap-2"
:class="methods.length > 2 ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2'"
>
<template v-for="method in methods" :key="method.id">
<TooltipProvider
v-if="!method.available && method.unavailableReason"
:delay-duration="200"
>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="sm"
disabled
class="opacity-60 flex-col h-auto py-2 gap-1"
>
<span class="flex items-center">
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
{{ method.label }}
</span>
<span v-if="method.badge" class="text-[10px] opacity-70">
{{ method.badge }}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ method.unavailableReason }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
v-else
type="button"
:variant="modelValue === method.id ? 'default' : 'outline'"
size="sm"
:disabled="!method.available"
class="flex-col h-auto py-2 gap-1"
@click="select(method)"
>
<span class="flex items-center">
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
{{ method.label }}
</span>
<span v-if="method.badge" class="text-[10px] opacity-70">
{{ method.badge }}
</span>
</Button>
</template>
</div>
</template>

View file

@ -1,45 +0,0 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
const props = withDefaults(
defineProps<{
amount: number
from: string
to: string
/** Text prepended to the conversion line (e.g. "≈" or "Equivalent"). */
prefix?: string
/** Suffix appended after the number (e.g. " at current rate"). */
suffix?: string
}>(),
{
prefix: '≈',
suffix: ' at current rate',
},
)
const { useLivePreview } = usePriceConversion()
const { result, loading } = useLivePreview(
toRef(props, 'amount'),
toRef(props, 'from'),
toRef(props, 'to'),
)
const formatted = computed(() => {
const v = result.value
if (v == null) return null
if (props.to.toLowerCase() === 'sat') {
return `${Math.round(v).toLocaleString()} sats`
}
const fixed = v < 1 ? v.toFixed(4) : v.toFixed(2)
return `${fixed} ${props.to.toUpperCase()}`
})
</script>
<template>
<p v-if="amount > 0" class="text-xs text-muted-foreground">
<span v-if="loading && !formatted">Loading rate</span>
<span v-else-if="formatted">{{ prefix }} {{ formatted }}{{ suffix }}</span>
<span v-else class="opacity-60">(rate unavailable)</span>
</p>
</template>

View file

@ -1,53 +0,0 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
export type FiatProviderIcon = 'card' | 'bank' | 'wallet'
export interface FiatProviderMeta {
label: string
icon: FiatProviderIcon
}
const KNOWN_PROVIDERS: Record<string, FiatProviderMeta> = {
stripe: { label: 'Stripe', icon: 'card' },
paypal: { label: 'PayPal', icon: 'wallet' },
square: { label: 'Square', icon: 'card' },
sepa: { label: 'SEPA', icon: 'bank' },
}
export function providerMeta(id: string): FiatProviderMeta {
const known = KNOWN_PROVIDERS[id.toLowerCase()]
if (known) return known
return {
label: id.charAt(0).toUpperCase() + id.slice(1),
icon: 'card',
}
}
/**
* Shared accessor for the current user's available fiat providers.
*
* Fiat providers (Stripe, PayPal, Square, SEPA, ) are configured
* globally by the LNbits admin. Per-provider `allowed_users`
* whitelists narrow that to a session-specific list, exposed as
* `User.fiat_providers` on `GET /api/v1/auth`. Both organizers and
* buyers on the same instance see the same list today.
*
* Call `refresh()` from owner-side dialogs that may open right after
* the user configured a new provider in another tab.
*/
export function useFiatProviders() {
const auth = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const providers = computed<string[]>(
() => auth.currentUser.value?.fiat_providers ?? []
)
const hasAnyProvider = computed(() => providers.value.length > 0)
async function refresh(): Promise<void> {
await auth.refresh()
}
return { providers, hasAnyProvider, refresh, providerMeta }
}

View file

@ -0,0 +1,39 @@
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrMetadataService, NostrMetadata } from '../nostr/nostr-metadata-service'
/**
* Composable for accessing Nostr metadata service
*
* @example
* ```ts
* const { publishMetadata, getMetadata } = useNostrMetadata()
*
* // Get current metadata
* const metadata = getMetadata()
*
* // Publish metadata to Nostr relays
* await publishMetadata()
* ```
*/
export function useNostrMetadata() {
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
/**
* Publish user metadata to Nostr relays (NIP-01 kind 0)
*/
const publishMetadata = async (): Promise<{ success: number; total: number }> => {
return await metadataService.publishMetadata()
}
/**
* Get current user's Nostr metadata
*/
const getMetadata = (): NostrMetadata => {
return metadataService.getMetadata()
}
return {
publishMetadata,
getMetadata
}
}

View file

@ -1,88 +0,0 @@
import { ref, watch, type Ref } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { LnbitsAPI } from '@/lib/api/lnbits'
interface CacheEntry {
value: number
expiresAt: number
}
const cache = new Map<string, CacheEntry>()
const TTL_MS = 60_000
function cacheKey(amount: number, from: string, to: string): string {
return `${from.toLowerCase()}|${to.toLowerCase()}|${amount}`
}
/**
* Live + cached BTC fiat rate previews via LNbits `/api/v1/conversion`.
*
* Both helpers tolerate a transient failure (returning `null`) surface
* conversion preview as best-effort UX, never as a blocker. 60s in-memory
* cache de-duplicates dialog re-renders.
*/
export function usePriceConversion() {
const lnbitsAPI = injectService<LnbitsAPI>(SERVICE_TOKENS.LNBITS_API)
async function convert(
amount: number,
from: string,
to: string,
): Promise<number | null> {
if (!amount || !from || !to) return null
if (from.toLowerCase() === to.toLowerCase()) return amount
const key = cacheKey(amount, from, to)
const cached = cache.get(key)
if (cached && cached.expiresAt > Date.now()) return cached.value
try {
const data = await lnbitsAPI.getConversion({ from, to, amount })
const result =
data[to] ??
data[to.toUpperCase()] ??
data[to.toLowerCase()] ??
(data as Record<string, number>).amount ??
(data as Record<string, number>).result
if (typeof result !== 'number') return null
cache.set(key, { value: result, expiresAt: Date.now() + TTL_MS })
return result
} catch (err) {
console.warn('[usePriceConversion] convert failed:', err)
return null
}
}
function useLivePreview(
amount: Ref<number>,
from: Ref<string>,
to: Ref<string>,
debounceMs = 300,
): { result: Ref<number | null>; loading: Ref<boolean> } {
const result = ref<number | null>(null)
const loading = ref(false)
let activeToken = 0
let timer: ReturnType<typeof setTimeout> | null = null
watch(
[amount, from, to],
() => {
if (timer) clearTimeout(timer)
const myToken = ++activeToken
loading.value = true
timer = setTimeout(async () => {
const v = await convert(amount.value, from.value, to.value)
if (myToken === activeToken) {
result.value = v
loading.value = false
}
}, debounceMs)
},
{ immediate: true },
)
return { result, loading }
}
return { convert, useLivePreview }
}

View file

@ -2,9 +2,9 @@ import type { App } from 'vue'
import type { ModulePlugin } from '@/core/types' import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container' import { container, SERVICE_TOKENS } from '@/core/di-container'
import { relayHub } from './nostr/relay-hub' import { relayHub } from './nostr/relay-hub'
import { NostrMetadataService } from './nostr/nostr-metadata-service'
import { ProfileService } from './nostr/ProfileService' import { ProfileService } from './nostr/ProfileService'
import { ReactionService } from './nostr/ReactionService' import { ReactionService } from './nostr/ReactionService'
import { NostrTransportService } from './services/NostrTransportService'
// Import auth services // Import auth services
import { auth } from './auth/auth-service' import { auth } from './auth/auth-service'
@ -29,9 +29,9 @@ import ProfileSettings from './components/ProfileSettings.vue'
const invoiceService = new InvoiceService() const invoiceService = new InvoiceService()
const lnbitsAPI = new LnbitsAPI() const lnbitsAPI = new LnbitsAPI()
const imageUploadService = new ImageUploadService() const imageUploadService = new ImageUploadService()
const nostrMetadataService = new NostrMetadataService()
const profileService = new ProfileService() const profileService = new ProfileService()
const reactionService = new ReactionService() const reactionService = new ReactionService()
const nostrTransportService = new NostrTransportService()
/** /**
* Base Module Plugin * Base Module Plugin
@ -46,6 +46,7 @@ export const baseModule: ModulePlugin = {
// Register core Nostr services // Register core Nostr services
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub) container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
container.provide(SERVICE_TOKENS.NOSTR_METADATA_SERVICE, nostrMetadataService)
// Register auth service // Register auth service
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth) container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
@ -74,7 +75,6 @@ export const baseModule: ModulePlugin = {
// Register shared Nostr services (used by multiple modules) // Register shared Nostr services (used by multiple modules)
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService) container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService) container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
container.provide(SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, nostrTransportService)
// Register PWA service // Register PWA service
container.provide('pwaService', pwaService) container.provide('pwaService', pwaService)
@ -110,6 +110,10 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ImageUploadService depends on ToastService waitForDependencies: true, // ImageUploadService depends on ToastService
maxRetries: 3 maxRetries: 3
}) })
await nostrMetadataService.initialize({
waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub
maxRetries: 3
})
await profileService.initialize({ await profileService.initialize({
waitForDependencies: true, // ProfileService depends on RelayHub waitForDependencies: true, // ProfileService depends on RelayHub
maxRetries: 3 maxRetries: 3
@ -118,10 +122,6 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
maxRetries: 3 maxRetries: 3
}) })
await nostrTransportService.initialize({
waitForDependencies: true, // NostrTransportService depends on RelayHub and AuthService
maxRetries: 3
})
// InvoiceService doesn't need initialization as it's not a BaseService // InvoiceService doesn't need initialization as it's not a BaseService
console.log('✅ Base module installed successfully') console.log('✅ Base module installed successfully')
@ -138,9 +138,9 @@ export const baseModule: ModulePlugin = {
await storageService.dispose() await storageService.dispose()
await toastService.dispose() await toastService.dispose()
await imageUploadService.dispose() await imageUploadService.dispose()
await nostrMetadataService.dispose()
await profileService.dispose() await profileService.dispose()
await reactionService.dispose() await reactionService.dispose()
await nostrTransportService.dispose()
// InvoiceService doesn't need disposal as it's not a BaseService // InvoiceService doesn't need disposal as it's not a BaseService
await lnbitsAPI.dispose() await lnbitsAPI.dispose()
@ -148,6 +148,7 @@ export const baseModule: ModulePlugin = {
container.remove(SERVICE_TOKENS.LNBITS_API) container.remove(SERVICE_TOKENS.LNBITS_API)
container.remove(SERVICE_TOKENS.INVOICE_SERVICE) container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
container.remove(SERVICE_TOKENS.PROFILE_SERVICE) container.remove(SERVICE_TOKENS.PROFILE_SERVICE)
container.remove(SERVICE_TOKENS.REACTION_SERVICE) container.remove(SERVICE_TOKENS.REACTION_SERVICE)
@ -164,6 +165,7 @@ export const baseModule: ModulePlugin = {
invoiceService, invoiceService,
pwaService, pwaService,
imageUploadService, imageUploadService,
nostrMetadataService,
profileService, profileService,
reactionService reactionService
}, },

View file

@ -0,0 +1,162 @@
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
/**
* Nostr User Metadata (NIP-01 kind 0)
* https://github.com/nostr-protocol/nips/blob/master/01.md
*/
export interface NostrMetadata {
name?: string // Display name (from username)
display_name?: string // Alternative display name
about?: string // Bio/description
picture?: string // Profile picture URL
banner?: string // Profile banner URL
nip05?: string // NIP-05 identifier (username@domain)
lud16?: string // Lightning Address (same as nip05)
website?: string // Personal website
}
/**
* Service for publishing and managing Nostr user metadata (NIP-01 kind 0)
*
* This service handles:
* - Publishing user profile metadata to Nostr relays
* - Syncing LNbits user data with Nostr profile
* - Auto-broadcasting metadata on login and profile updates
*/
export class NostrMetadataService extends BaseService {
protected readonly metadata = {
name: 'NostrMetadataService',
version: '1.0.0',
dependencies: ['AuthService', 'RelayHub']
}
protected authService: AuthService | null = null
protected relayHub: RelayHub | null = null
protected async onInitialize(): Promise<void> {
console.log('NostrMetadataService: Starting initialization...')
this.authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
this.relayHub = injectService<RelayHub>(SERVICE_TOKENS.RELAY_HUB)
if (!this.authService) {
throw new Error('AuthService not available')
}
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('NostrMetadataService: Initialization complete')
}
/**
* Build Nostr metadata from LNbits user data
*/
private buildMetadata(): NostrMetadata {
const user = this.authService?.user.value
if (!user) {
throw new Error('No authenticated user')
}
const lightningDomain = import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
const username = user.username || user.id.slice(0, 8)
const metadata: NostrMetadata = {
name: username,
nip05: `${username}@${lightningDomain}`,
lud16: `${username}@${lightningDomain}`
}
// Add optional fields from user.extra if they exist
if (user.extra?.display_name) {
metadata.display_name = user.extra.display_name
}
if (user.extra?.picture) {
metadata.picture = user.extra.picture
}
return metadata
}
/**
* Publish user metadata to Nostr relays (NIP-01 kind 0)
*
* This creates a replaceable event that updates the user's profile.
* Only the latest kind 0 event for a given pubkey is kept by relays.
*/
async publishMetadata(): Promise<{ success: number; total: number }> {
if (!this.authService?.isAuthenticated.value) {
throw new Error('Must be authenticated to publish metadata')
}
if (!this.relayHub?.isConnected.value) {
throw new Error('Not connected to relays')
}
const user = this.authService.user.value
if (!user?.prvkey) {
throw new Error('User private key not available')
}
try {
const metadata = this.buildMetadata()
console.log('📤 Publishing Nostr metadata (kind 0):', metadata)
// Create kind 0 event (user metadata)
// Content is JSON-stringified metadata
const eventTemplate: EventTemplate = {
kind: 0,
content: JSON.stringify(metadata),
tags: [],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(user.prvkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
console.log('✅ Metadata event signed:', signedEvent.id)
console.log('📋 Full signed event:', JSON.stringify(signedEvent, null, 2))
// Publish to all connected relays
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`✅ Metadata published to ${result.success}/${result.total} relays`)
return result
} catch (error) {
console.error('Failed to publish metadata:', error)
throw error
}
}
/**
* Get current user's Nostr metadata
*/
getMetadata(): NostrMetadata {
return this.buildMetadata()
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
protected async onDestroy(): Promise<void> {
// Cleanup if needed
}
}

View file

@ -1,5 +1,4 @@
import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools' import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools'
import type { SubscribeManyParams, SubCloser } from 'nostr-tools/abstract-pool'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { ref } from 'vue' import { ref } from 'vue'
@ -439,7 +438,7 @@ export class RelayHub extends BaseService {
} }
// Recreate the subscription // Recreate the subscription
const subscription = this.poolSubscribe(availableRelays, config.filters, { const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
onevent: (event: Event) => { onevent: (event: Event) => {
config.onEvent?.(event) config.onEvent?.(event)
this.emit('event', { subscriptionId: id, event, relay: 'unknown' }) this.emit('event', { subscriptionId: id, event, relay: 'unknown' })
@ -483,7 +482,7 @@ export class RelayHub extends BaseService {
// Create subscription using the pool // Create subscription using the pool
const subscription = this.poolSubscribe(availableRelays, config.filters, { const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
onevent: (event: Event) => { onevent: (event: Event) => {
config.onEvent?.(event) config.onEvent?.(event)
this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' }) this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' })
@ -551,24 +550,6 @@ export class RelayHub extends BaseService {
return { success: successful, total } return { success: successful, total }
} }
// nostr-tools 2.23+ deprecated the Filter[] form on pool.subscribeMany; route
// single-filter through pool.subscribe and multi-filter through subscribeMap
// so a single REQ-per-relay still carries every filter.
private poolSubscribe(
relays: string[],
filters: Filter[],
params: SubscribeManyParams
): SubCloser {
if (filters.length === 0) {
throw new Error('Cannot subscribe with empty filters')
}
if (filters.length === 1) {
return this.pool.subscribe(relays, filters[0], params)
}
const requests = relays.flatMap(url => filters.map(filter => ({ url, filter })))
return this.pool.subscribeMap(requests, params)
}
/** /**
* Query events from relays (one-time fetch) * Query events from relays (one-time fetch)
*/ */

View file

@ -1,68 +0,0 @@
import { BaseService } from '@/core/base/BaseService'
import { LNBITS_CONFIG } from '@/lib/config/lnbits'
/**
* Client for LNbits's nostr-transport (kind-21000 RPC over relays,
* landed upstream Sun May 24 2026, commit f235966c).
*
* **Disabled in phase 1/2 per design-questions Q4.2.** The kind-21000
* NIP-44 RPC transport requires the caller to hold a raw user prvkey
* for the NIP-44 v2 conversation key derivation AND for signing the
* outer envelope. Post-aiolabs/lnbits#9 the webapp no longer has
* prvkey at all, and `/auth/sign-event` doesn't list 21000 in
* `ALLOWED_KINDS`. Reviving this transport is phase-3+ work (Q4.3:
* lnbits-side change so the transport accepts an inner-payload
* identity claim rather than the outer pubkey).
*
* Every prior caller has been migrated:
* - ticket scanner events extension HTTP (PR #87)
* - bookmarks / RSVP / NIP-09 deletion `signEventViaLnbits`
*
* The service + DI registration stay as scaffolding so phase-3+
* revival is a single-file diff.
*/
interface RpcCallOptions {
walletId?: string
timeoutMs?: number
}
export class NostrRpcError extends Error {
constructor(public readonly message: string) {
super(message)
this.name = 'NostrRpcError'
}
}
export class NostrTransportService extends BaseService {
protected readonly metadata = {
name: 'NostrTransportService',
version: '1.0.0',
dependencies: ['AuthService', 'RelayHub'],
}
protected async onInitialize(): Promise<void> {
if (!LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY) {
this.debug(
'No VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY configured — RPC calls will fail',
)
} else {
this.debug(
`Initialized with server pubkey ${LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY.slice(0, 16)}` +
'(phase 1/2: call() is disabled per Q4.2)',
)
}
}
async call<T = unknown>(
_rpcName: string,
_body: Record<string, unknown>,
_options: RpcCallOptions = {},
): Promise<T> {
throw new Error(
'NostrTransportService.call is disabled in phase 1/2; deferred ' +
'to phase 3+ per design-questions Q4.2/Q4.3. Route through ' +
'extension HTTP endpoints (Bucket A) or signEventViaLnbits (Bucket B).',
)
}
}

View file

@ -0,0 +1,256 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../../services/ExpensesAPI'
import type { Account } from '../../types'
import { PermissionType } from '../../types'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Loader2 } from 'lucide-vue-next'
interface Props {
isOpen: boolean
accounts: Account[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
permissionGranted: []
}>()
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const isGranting = ref(false)
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
// Form schema
const formSchema = toTypedSchema(
z.object({
user_id: z.string().min(1, 'User ID is required'),
account_id: z.string().min(1, 'Account is required'),
permission_type: z.nativeEnum(PermissionType, {
errorMap: () => ({ message: 'Permission type is required' })
}),
notes: z.string().optional(),
expires_at: z.string().optional()
})
)
// Setup form
const form = useForm({
validationSchema: formSchema,
initialValues: {
user_id: '',
account_id: '',
permission_type: PermissionType.READ,
notes: '',
expires_at: ''
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
// Permission type options
const permissionTypes = [
{ value: PermissionType.READ, label: 'Read', description: 'View account and balance' },
{
value: PermissionType.SUBMIT_EXPENSE,
label: 'Submit Expense',
description: 'Submit expenses to this account'
},
{ value: PermissionType.MANAGE, label: 'Manage', description: 'Full account management' }
]
// Submit form
const onSubmit = form.handleSubmit(async (values) => {
if (!adminKey.value) {
toast.error('Admin access required')
return
}
isGranting.value = true
try {
await expensesAPI.grantPermission(adminKey.value, {
user_id: values.user_id,
account_id: values.account_id,
permission_type: values.permission_type,
notes: values.notes || undefined,
expires_at: values.expires_at || undefined
})
emit('permissionGranted')
resetForm()
} catch (error) {
console.error('Failed to grant permission:', error)
toast.error('Failed to grant permission', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isGranting.value = false
}
})
// Handle dialog close
function handleClose() {
if (!isGranting.value) {
resetForm()
emit('close')
}
}
</script>
<template>
<Dialog :open="props.isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Grant Account Permission</DialogTitle>
<DialogDescription>
Grant a user permission to access an expense account. Permissions on parent accounts
cascade to children.
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4">
<!-- User ID -->
<FormField v-slot="{ componentField }" name="user_id">
<FormItem>
<FormLabel>User ID *</FormLabel>
<FormControl>
<Input
placeholder="Enter user wallet ID"
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>The wallet ID of the user to grant permission to</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Account -->
<FormField v-slot="{ componentField }" name="account_id">
<FormItem>
<FormLabel>Account *</FormLabel>
<Select v-bind="componentField" :disabled="isGranting">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select account" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="account in props.accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Account to grant access to</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Permission Type -->
<FormField v-slot="{ componentField }" name="permission_type">
<FormItem>
<FormLabel>Permission Type *</FormLabel>
<Select v-bind="componentField" :disabled="isGranting">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select permission type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="type in permissionTypes"
:key="type.value"
:value="type.value"
>
<div>
<div class="font-medium">{{ type.label }}</div>
<div class="text-sm text-muted-foreground">{{ type.description }}</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Type of permission to grant</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Expiration Date (Optional) -->
<FormField v-slot="{ componentField }" name="expires_at">
<FormItem>
<FormLabel>Expiration Date (Optional)</FormLabel>
<FormControl>
<Input
type="datetime-local"
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>Leave empty for permanent access</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Notes (Optional) -->
<FormField v-slot="{ componentField }" name="notes">
<FormItem>
<FormLabel>Notes (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Add notes about this permission..."
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>Optional notes for admin reference</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="handleClose"
:disabled="isGranting"
>
Cancel
</Button>
<Button type="submit" :disabled="isGranting || !isFormValid">
<Loader2 v-if="isGranting" class="mr-2 h-4 w-4 animate-spin" />
{{ isGranting ? 'Granting...' : 'Grant Permission' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,399 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../../services/ExpensesAPI'
import type { AccountPermission, Account } from '../../types'
import { PermissionType } from '../../types'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Loader2, Plus, Trash2, Users, Shield } from 'lucide-vue-next'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import GrantPermissionDialog from './GrantPermissionDialog.vue'
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const permissions = ref<AccountPermission[]>([])
const accounts = ref<Account[]>([])
const isLoading = ref(false)
const showGrantDialog = ref(false)
const permissionToRevoke = ref<AccountPermission | null>(null)
const showRevokeDialog = ref(false)
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
// Get permission type badge variant
function getPermissionBadge(type: PermissionType) {
switch (type) {
case PermissionType.READ:
return 'default'
case PermissionType.SUBMIT_EXPENSE:
return 'secondary'
case PermissionType.MANAGE:
return 'destructive'
default:
return 'outline'
}
}
// Get permission type label
function getPermissionLabel(type: PermissionType): string {
switch (type) {
case PermissionType.READ:
return 'Read'
case PermissionType.SUBMIT_EXPENSE:
return 'Submit Expense'
case PermissionType.MANAGE:
return 'Manage'
default:
return type
}
}
// Get account name by ID
function getAccountName(accountId: string): string {
const account = accounts.value.find((a) => a.id === accountId)
return account?.name || accountId
}
// Format date
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
// Load accounts
async function loadAccounts() {
if (!adminKey.value) return
try {
accounts.value = await expensesAPI.getAccounts(adminKey.value)
} catch (error) {
console.error('Failed to load accounts:', error)
toast.error('Failed to load accounts', {
description: error instanceof Error ? error.message : 'Unknown error'
})
}
}
// Load all permissions
async function loadPermissions() {
if (!adminKey.value) {
toast.error('Admin access required', {
description: 'You need admin privileges to manage permissions'
})
return
}
isLoading.value = true
try {
permissions.value = await expensesAPI.listPermissions(adminKey.value)
} catch (error) {
console.error('Failed to load permissions:', error)
toast.error('Failed to load permissions', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isLoading.value = false
}
}
// Handle permission granted
function handlePermissionGranted() {
showGrantDialog.value = false
loadPermissions()
toast.success('Permission granted', {
description: 'User permission has been successfully granted'
})
}
// Confirm revoke permission
function confirmRevoke(permission: AccountPermission) {
permissionToRevoke.value = permission
showRevokeDialog.value = true
}
// Revoke permission
async function revokePermission() {
if (!adminKey.value || !permissionToRevoke.value) return
try {
await expensesAPI.revokePermission(adminKey.value, permissionToRevoke.value.id)
toast.success('Permission revoked', {
description: 'User permission has been successfully revoked'
})
loadPermissions()
} catch (error) {
console.error('Failed to revoke permission:', error)
toast.error('Failed to revoke permission', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
showRevokeDialog.value = false
permissionToRevoke.value = null
}
}
// Group permissions by user
const permissionsByUser = computed(() => {
const grouped = new Map<string, AccountPermission[]>()
for (const permission of permissions.value) {
const existing = grouped.get(permission.user_id) || []
existing.push(permission)
grouped.set(permission.user_id, existing)
}
return grouped
})
// Group permissions by account
const permissionsByAccount = computed(() => {
const grouped = new Map<string, AccountPermission[]>()
for (const permission of permissions.value) {
const existing = grouped.get(permission.account_id) || []
existing.push(permission)
grouped.set(permission.account_id, existing)
}
return grouped
})
onMounted(() => {
loadAccounts()
loadPermissions()
})
</script>
<template>
<div class="container mx-auto p-6 space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Permission Management</h1>
<p class="text-muted-foreground">
Manage user access to expense accounts
</p>
</div>
<Button @click="showGrantDialog = true" :disabled="isLoading">
<Plus class="mr-2 h-4 w-4" />
Grant Permission
</Button>
</div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Shield class="h-5 w-5" />
Account Permissions
</CardTitle>
<CardDescription>
View and manage all account permissions. Permissions on parent accounts cascade to
children.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs default-value="by-user" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="by-user">
<Users class="mr-2 h-4 w-4" />
By User
</TabsTrigger>
<TabsTrigger value="by-account">
<Shield class="mr-2 h-4 w-4" />
By Account
</TabsTrigger>
</TabsList>
<!-- By User View -->
<TabsContent value="by-user" class="space-y-4">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="permissionsByUser.size === 0" class="text-center py-8">
<p class="text-muted-foreground">No permissions granted yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="[userId, userPermissions] in permissionsByUser"
:key="userId"
class="border rounded-lg p-4"
>
<h3 class="font-semibold mb-2">User: {{ userId }}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Granted</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Notes</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="permission in userPermissions" :key="permission.id">
<TableCell class="font-medium">
{{ getAccountName(permission.account_id) }}
</TableCell>
<TableCell>
<Badge :variant="getPermissionBadge(permission.permission_type)">
{{ getPermissionLabel(permission.permission_type) }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
<TableCell>
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ permission.notes || '-' }}
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="confirmRevoke(permission)"
:disabled="isLoading"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</TabsContent>
<!-- By Account View -->
<TabsContent value="by-account" class="space-y-4">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="permissionsByAccount.size === 0" class="text-center py-8">
<p class="text-muted-foreground">No permissions granted yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="[accountId, accountPermissions] in permissionsByAccount"
:key="accountId"
class="border rounded-lg p-4"
>
<h3 class="font-semibold mb-2">Account: {{ getAccountName(accountId) }}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Granted</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Notes</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="permission in accountPermissions" :key="permission.id">
<TableCell class="font-medium">{{ permission.user_id }}</TableCell>
<TableCell>
<Badge :variant="getPermissionBadge(permission.permission_type)">
{{ getPermissionLabel(permission.permission_type) }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
<TableCell>
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ permission.notes || '-' }}
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="confirmRevoke(permission)"
:disabled="isLoading"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<!-- Grant Permission Dialog -->
<GrantPermissionDialog
:is-open="showGrantDialog"
:accounts="accounts"
@close="showGrantDialog = false"
@permission-granted="handlePermissionGranted"
/>
<!-- Revoke Confirmation Dialog -->
<AlertDialog :open="showRevokeDialog" @update:open="showRevokeDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke Permission?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to revoke this permission? The user will immediately lose access.
<div v-if="permissionToRevoke" class="mt-4 p-4 bg-muted rounded-md">
<p class="font-medium">Permission Details:</p>
<p class="text-sm mt-2">
<strong>User:</strong> {{ permissionToRevoke.user_id }}
</p>
<p class="text-sm">
<strong>Account:</strong> {{ getAccountName(permissionToRevoke.account_id) }}
</p>
<p class="text-sm">
<strong>Type:</strong> {{ getPermissionLabel(permissionToRevoke.permission_type) }}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="revokePermission" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>

View file

@ -11,6 +11,8 @@ import type {
IncomeEntry, IncomeEntry,
AccountNode, AccountNode,
UserInfo, UserInfo,
AccountPermission,
GrantPermissionRequest,
TransactionListResponse TransactionListResponse
} from '../types' } from '../types'
import { appConfig } from '@/app.config' import { appConfig } from '@/app.config'
@ -341,6 +343,93 @@ export class ExpensesAPI extends BaseService {
} }
} }
/**
* List all account permissions (admin only)
*
* @param adminKey - Admin key for authentication
*/
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
try {
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
method: 'GET',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to list permissions: ${response.statusText}`)
}
const permissions = await response.json()
return permissions as AccountPermission[]
} catch (error) {
console.error('[ExpensesAPI] Error listing permissions:', error)
throw error
}
}
/**
* Grant account permission to a user (admin only)
*
* @param adminKey - Admin key for authentication
* @param request - Permission grant request
*/
async grantPermission(
adminKey: string,
request: GrantPermissionRequest
): Promise<AccountPermission> {
try {
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
method: 'POST',
headers: this.getHeaders(adminKey),
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to grant permission: ${response.statusText}`
throw new Error(errorMessage)
}
const permission = await response.json()
return permission as AccountPermission
} catch (error) {
console.error('[ExpensesAPI] Error granting permission:', error)
throw error
}
}
/**
* Revoke account permission (admin only)
*
* @param adminKey - Admin key for authentication
* @param permissionId - ID of the permission to revoke
*/
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
try {
const response = await fetch(
`${this.baseUrl}/libra/api/v1/permissions/${permissionId}`,
{
method: 'DELETE',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to revoke permission: ${response.statusText}`
throw new Error(errorMessage)
}
} catch (error) {
console.error('[ExpensesAPI] Error revoking permission:', error)
throw error
}
}
/** /**
* Get user's transactions from journal * Get user's transactions from journal
* *

View file

@ -28,6 +28,25 @@ export interface Account {
has_children?: boolean has_children?: boolean
} }
/**
* Account with user-specific permission metadata
* (Will be available once libra API implements permissions)
*/
export interface AccountWithPermissions extends Account {
user_permissions?: PermissionType[]
inherited_from?: string
}
/**
* Permission types for account access control
*/
export enum PermissionType {
READ = 'read',
SUBMIT_EXPENSE = 'submit_expense',
SUBMIT_INCOME = 'submit_income',
MANAGE = 'manage'
}
/** /**
* Expense entry request payload * Expense entry request payload
*/ */
@ -106,6 +125,31 @@ export interface UserInfo {
equity_account_name?: string equity_account_name?: string
} }
/**
* Account permission for user access control
*/
export interface AccountPermission {
id: string
user_id: string
account_id: string
permission_type: PermissionType
granted_at: string
granted_by: string
expires_at?: string
notes?: string
}
/**
* Grant permission request payload
*/
export interface GrantPermissionRequest {
user_id: string
account_id: string
permission_type: PermissionType
expires_at?: string
notes?: string
}
/** /**
* Transaction entry from journal (user view) * Transaction entry from journal (user view)
*/ */

View file

@ -12,6 +12,10 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
CheckCircle2,
Clock,
Flag,
XCircle,
RefreshCw, RefreshCw,
Calendar, Calendar,
Filter Filter
@ -26,26 +30,14 @@ const isLoading = ref(false)
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom' const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
const customStartDate = ref<string>('') const customStartDate = ref<string>('')
const customEndDate = ref<string>('') const customEndDate = ref<string>('')
// Each chip is an inclusion toggle for one bucket of rows. Every row const typeFilter = ref<'all' | 'income' | 'expense'>('all')
// belongs to exactly one bucket (voided rows go to 'voided' regardless
// of their income/expense type). Default hides voided.
type Category = 'income' | 'expense' | 'voided'
const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense'])) const typeFilterOptions = [
{ label: 'All', value: 'all' as const },
const categoryChips: { label: string; value: Category }[] = [ { label: 'Income', value: 'income' as const },
{ label: 'Income', value: 'income' }, { label: 'Expenses', value: 'expense' as const }
{ label: 'Expenses', value: 'expense' },
{ label: 'Voided', value: 'voided' }
] ]
function toggleCategory(cat: Category) {
const next = new Set(activeCategories.value)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
activeCategories.value = next
}
function isIncome(t: Transaction): boolean { function isIncome(t: Transaction): boolean {
return t.tags?.includes('income-entry') ?? false return t.tags?.includes('income-entry') ?? false
} }
@ -54,22 +46,6 @@ function isExpense(t: Transaction): boolean {
return t.tags?.includes('expense-entry') ?? false return t.tags?.includes('expense-entry') ?? false
} }
function isVoided(t: Transaction): boolean {
return t.tags?.includes('voided') ?? false
}
function isPending(t: Transaction): boolean {
return t.flag === '!' && !isVoided(t)
}
// Which chip bucket a row falls into. Voided always wins over type.
function getBucket(t: Transaction): Category | null {
if (isVoided(t)) return 'voided'
if (isIncome(t)) return 'income'
if (isExpense(t)) return 'expense'
return null
}
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey) const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
// Fuzzy search state and configuration // Fuzzy search state and configuration
@ -95,13 +71,12 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
matchAllWhenSearchEmpty: true matchAllWhenSearchEmpty: true
} }
// Transactions to display: row passes if its bucket's chip is active. // Transactions to display (search results or all transactions), filtered by type
const transactionsToDisplay = computed(() => { const transactionsToDisplay = computed(() => {
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
return base.filter(t => { if (typeFilter.value === 'income') return base.filter(isIncome)
const bucket = getBucket(t) if (typeFilter.value === 'expense') return base.filter(isExpense)
return bucket !== null && activeCategories.value.has(bucket) return base
})
}) })
// Handle search results // Handle search results
@ -133,28 +108,20 @@ function formatAmount(amount: number): string {
return new Intl.NumberFormat('en-US').format(amount) return new Intl.NumberFormat('en-US').format(amount)
} }
// Income gets a leading '+', expense a leading '-'. // Get status icon and color based on flag
function getAmountSign(t: Transaction): string { function getStatusInfo(flag?: string) {
if (isIncome(t)) return '+' switch (flag) {
if (isExpense(t)) return '-' case '*':
return '' return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
case '!':
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
case '#':
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
case 'x':
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
default:
return null
} }
// Color tint for the amount text. Voided entries drop to muted regardless
// of type since the strike-through carries the "ignore this" signal.
function getAmountColorClass(t: Transaction): string {
if (isVoided(t)) return 'line-through text-muted-foreground'
if (isIncome(t)) return 'text-green-600 dark:text-green-400'
if (isExpense(t)) return 'text-red-600 dark:text-red-400'
return ''
}
// Tags that drive other visual channels (border / sign / strike-through)
// suppressed from the badge row so it only carries user-added tags.
const TYPE_TAGS = new Set(['income-entry', 'expense-entry', 'voided'])
function getDisplayTags(t: Transaction): string[] {
return (t.tags ?? []).filter(tag => !TYPE_TAGS.has(tag))
} }
// Load transactions // Load transactions
@ -257,20 +224,19 @@ onMounted(() => {
</Button> </Button>
</div> </div>
<!-- Category chips: each chip toggles inclusion of one bucket <!-- Type Filter (All / Income / Expenses) -->
of rows. Defaults: Income + Expenses on, Voided off. -->
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<Filter class="h-4 w-4 text-muted-foreground" /> <Filter class="h-4 w-4 text-muted-foreground" />
<Button <Button
v-for="chip in categoryChips" v-for="option in typeFilterOptions"
:key="chip.value" :key="option.value"
:variant="activeCategories.has(chip.value) ? 'default' : 'outline'" :variant="typeFilter === option.value ? 'default' : 'outline'"
size="sm" size="sm"
class="h-7 md:h-8 px-3 text-xs" class="h-7 md:h-8 px-3 text-xs"
@click="toggleCategory(chip.value)" @click="typeFilter = option.value"
:disabled="isLoading" :disabled="isLoading"
> >
{{ chip.label }} {{ option.label }}
</Button> </Button>
</div> </div>
@ -325,7 +291,7 @@ onMounted(() => {
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }} Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span> </span>
<span v-else> <span v-else>
{{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }} {{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
</span> </span>
</div> </div>
@ -341,9 +307,7 @@ onMounted(() => {
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4"> <div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
<p class="text-muted-foreground">No transactions found</p> <p class="text-muted-foreground">No transactions found</p>
<p class="text-sm text-muted-foreground mt-2"> <p class="text-sm text-muted-foreground mt-2">
<template v-if="searchResults.length > 0">Try a different search term</template> {{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
<template v-else-if="activeCategories.size === 0">Select a category above to see transactions</template>
<template v-else>Try selecting a different time period or toggling more categories</template>
</p> </p>
</div> </div>
@ -352,19 +316,29 @@ onMounted(() => {
<div <div
v-for="transaction in transactionsToDisplay" v-for="transaction in transactionsToDisplay"
:key="transaction.id" :key="transaction.id"
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors" :class="[
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
isIncome(transaction) && 'border-l-4 border-l-green-600',
isExpense(transaction) && 'border-l-4 border-l-red-600'
]"
> >
<!-- Transaction Header --> <!-- Transaction Header -->
<div class="flex items-start justify-between gap-3 mb-2"> <div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 <div class="flex items-center gap-2 mb-1">
<!-- Status Icon -->
<component
v-if="getStatusInfo(transaction.flag)"
:is="getStatusInfo(transaction.flag)!.icon"
:class="[ :class="[
'font-medium text-sm sm:text-base truncate mb-1', 'h-4 w-4 flex-shrink-0',
isVoided(transaction) && 'line-through text-muted-foreground' getStatusInfo(transaction.flag)!.color
]" ]"
> />
<h3 class="font-medium text-sm sm:text-base truncate">
{{ transaction.description }} {{ transaction.description }}
</h3> </h3>
</div>
<p class="text-xs sm:text-sm text-muted-foreground"> <p class="text-xs sm:text-sm text-muted-foreground">
{{ formatDate(transaction.date) }} {{ formatDate(transaction.date) }}
</p> </p>
@ -372,17 +346,11 @@ onMounted(() => {
<!-- Amount --> <!-- Amount -->
<div class="text-right flex-shrink-0"> <div class="text-right flex-shrink-0">
<p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]"> <p class="font-semibold text-sm sm:text-base">
{{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats {{ formatAmount(transaction.amount) }} sats
</p> </p>
<p <p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
v-if="transaction.fiat_amount" {{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
:class="[
'text-xs',
getAmountColorClass(transaction) || 'text-muted-foreground'
]"
>
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
</p> </p>
</div> </div>
</div> </div>
@ -399,42 +367,17 @@ onMounted(() => {
<span class="font-medium">Ref:</span> {{ transaction.reference }} <span class="font-medium">Ref:</span> {{ transaction.reference }}
</div> </div>
<!-- Badges: type (Income / Expense) + status (Voided / Pending, <!-- Tags -->
mutually exclusive) + any user-added tags. --> <div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
<div class="flex flex-wrap gap-1 mt-2">
<Badge <Badge
v-if="isIncome(transaction)" v-for="tag in transaction.tags"
variant="secondary"
class="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300"
>
Income
</Badge>
<Badge
v-else-if="isExpense(transaction)"
variant="secondary"
class="text-xs bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
>
Expense
</Badge>
<Badge
v-if="isVoided(transaction)"
variant="outline"
class="text-xs text-muted-foreground"
>
Voided
</Badge>
<Badge
v-else-if="isPending(transaction)"
variant="secondary"
class="text-xs bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300"
>
Pending approval
</Badge>
<Badge
v-for="tag in getDisplayTags(transaction)"
:key="tag" :key="tag"
variant="secondary" variant="secondary"
class="text-xs" :class="[
'text-xs',
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
]"
> >
{{ tag }} {{ tag }}
</Badge> </Badge>

View file

@ -2,6 +2,8 @@ import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
import { useMarketStore } from '../stores/market' import { useMarketStore } from '../stores/market'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import type { NostrmarketService } from '../services/nostrmarketService'
import { nip59 } from 'nostr-tools'
import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { auth } from '@/composables/useAuthService' import { auth } from '@/composables/useAuthService'
@ -42,6 +44,7 @@ export function useMarket() {
const marketStore = useMarketStore() const marketStore = useMarketStore()
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
const nostrmarketService = injectService(SERVICE_TOKENS.NOSTRMARKET_SERVICE) as NostrmarketService
if (!relayHub) { if (!relayHub) {
throw new Error('RelayHub not available. Make sure base module is installed.') throw new Error('RelayHub not available. Make sure base module is installed.')
@ -430,24 +433,56 @@ export function useMarket() {
return null return null
} }
// Convert hex string to Uint8Array (browser-compatible)
const hexToUint8Array = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}
// Handle incoming order gift wraps (kind 1059) — payment requests, status updates. // Handle incoming order gift wraps (kind 1059) — payment requests, status updates.
// //
// **Disabled in phase 1/2 per design-questions Q4.2 / Bucket C.** NIP-59 // The outer event's pubkey is an ephemeral key (NIP-59); the real merchant
// gift-wrap unwrap requires a raw user prvkey for NIP-44 v2 decryption; // pubkey is on the unwrapped rumor. Content is JSON with a `type` field
// post-aiolabs/lnbits#9 the webapp doesn't hold one and lnbits doesn't // (1 = payment request, 2 = order status update).
// yet expose a server-routed nip44_decrypt endpoint (would route through
// the signer ABC's existing nip44_decrypt method — phase-3+ work).
//
// Until then incoming order DMs are not processed by the webapp.
// Buyers will see order status changes via the nostrmarket extension's
// own server-side handling rather than relying on this client-side
// unwrap. Flag locally so we notice if the path becomes hot.
const handleOrderDM = async (event: any) => { const handleOrderDM = async (event: any) => {
console.warn( try {
'[useMarket] Skipping order gift wrap (kind 1059) unwrap — phase 1/2 ' + console.log('🎁 Received order gift wrap:', event.id, '(kind', event.kind + ')')
'has no prvkey access for NIP-44 decryption. Event id:',
event?.id, const userPrivkey =
) authService.user.value?.prvkey ?? auth.currentUser.value?.prvkey
if (!userPrivkey) {
console.warn('Cannot unwrap gift wrap: no user private key available')
return
}
const prvkeyBytes = hexToUint8Array(userPrivkey)
const rumor = nip59.unwrapEvent(event, prvkeyBytes)
console.log('🔓 Unwrapped rumor from merchant:', rumor.pubkey.slice(0, 10) + '...')
const messageData = JSON.parse(rumor.content)
console.log('📨 Parsed message data:', messageData)
switch (messageData.type) {
case 1: // Payment request
console.log('💰 Processing payment request for order:', messageData.id)
await nostrmarketService.handlePaymentRequest(messageData)
console.log('✅ Payment request processed successfully')
break
case 2: // Order status update
console.log('📦 Processing order status update for order:', messageData.id)
await nostrmarketService.handleOrderStatusUpdate(messageData)
console.log('✅ Order status update processed successfully')
break
default:
console.log('❓ Unknown message type:', messageData.type)
}
} catch (error) {
console.error('Failed to handle order gift wrap:', error)
}
} }
// Handle incoming market events // Handle incoming market events

View file

@ -13,16 +13,16 @@ import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lu
import { useFeed } from '../composables/useFeed' import { useFeed } from '../composables/useFeed'
import { useProfiles } from '@/modules/base/composables/useProfiles' import { useProfiles } from '@/modules/base/composables/useProfiles'
import { useReactions } from '@/modules/base/composables/useReactions' import { useReactions } from '@/modules/base/composables/useReactions'
import { useTasks } from '@/modules/tasks/composables/useTasks' import { useScheduledEvents } from '../composables/useScheduledEvents'
import ThreadedPost from './ThreadedPost.vue' import ThreadedPost from './ThreadedPost.vue'
import ScheduledEventCard from './ScheduledEventCard.vue' import ScheduledEventCard from './ScheduledEventCard.vue'
import appConfig from '@/app.config' import appConfig from '@/app.config'
import type { ContentFilter, FeedPost } from '../services/FeedService' import type { ContentFilter, FeedPost } from '../services/FeedService'
import type { ScheduledEvent } from '@/modules/tasks/services/TaskService' import type { ScheduledEvent } from '../services/ScheduledEventService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service' import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub' import type { RelayHub } from '@/modules/base/nostr/relay-hub'
import { signEventViaLnbits } from '@/lib/nostr/signing' import { finalizeEvent } from 'nostr-tools'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
interface Emits { interface Emits {
@ -98,9 +98,7 @@ const { getDisplayName, fetchProfiles } = useProfiles()
// Use reactions service for likes/hearts // Use reactions service for likes/hearts
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
// Task service is shared with the standalone tasks app; FeedService // Use scheduled events service
// already routes kind 31922/31925/5 events to it, so opt out of the
// composable's own subscription lifecycle.
const { const {
getEventsForSpecificDate, getEventsForSpecificDate,
getCompletion, getCompletion,
@ -111,7 +109,7 @@ const {
unclaimTask, unclaimTask,
deleteTask, deleteTask,
allCompletions allCompletions
} = useTasks({ autoSubscribe: false }) } = useScheduledEvents()
// Selected date for viewing scheduled tasks (defaults to today) // Selected date for viewing scheduled tasks (defaults to today)
const selectedDate = ref(new Date().toISOString().split('T')[0]) const selectedDate = ref(new Date().toISOString().split('T')[0])
@ -366,6 +364,15 @@ function onToggleLimited(postId: string) {
limitedReplyPosts.value = newLimited limitedReplyPosts.value = newLimited
} }
// Helper function to convert hex string to Uint8Array
const hexToUint8Array = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
// Handle delete post button click - show confirmation dialog // Handle delete post button click - show confirmation dialog
function onDeletePost(note: FeedPost) { function onDeletePost(note: FeedPost) {
if (!authService?.isAuthenticated.value || !authService?.user.value) { if (!authService?.isAuthenticated.value || !authService?.user.value) {
@ -396,8 +403,9 @@ async function confirmDeletePost() {
return return
} }
if (!authService?.user.value?.pubkey) { const userPrivkey = authService?.user.value?.prvkey
toast.error("Not signed in") if (!userPrivkey) {
toast.error("User private key not available")
showDeleteDialog.value = false showDeleteDialog.value = false
postToDelete.value = null postToDelete.value = null
return return
@ -415,8 +423,9 @@ async function confirmDeletePost() {
created_at: Math.floor(Date.now() / 1000) created_at: Math.floor(Date.now() / 1000)
} }
// Sign the deletion event server-side via lnbits. // Sign the deletion event
const signedEvent = await signEventViaLnbits(deletionEvent) const privkeyBytes = hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request // Publish the deletion request
const result = await relayHub.publishEvent(signedEvent) const result = await relayHub.publishEvent(signedEvent)

View file

@ -17,7 +17,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next' import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next'
import type { ScheduledEvent, EventCompletion, TaskStatus } from '@/modules/tasks/services/TaskService' import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service' import type { AuthService } from '@/modules/base/auth/auth-service'

View file

@ -0,0 +1,102 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ReactionService, EventReactions } from '../services/ReactionService'
import { useToast } from '@/core/composables/useToast'
/**
* Composable for managing reactions in the feed
*/
export function useReactions() {
const reactionService = injectService<ReactionService>(SERVICE_TOKENS.REACTION_SERVICE)
const toast = useToast()
/**
* Get reactions for a specific event
*/
const getEventReactions = (eventId: string): EventReactions => {
if (!reactionService) {
return {
eventId,
likes: 0,
dislikes: 0,
totalReactions: 0,
userHasLiked: false,
userHasDisliked: false,
reactions: []
}
}
return reactionService.getEventReactions(eventId)
}
/**
* Subscribe to reactions for a list of event IDs
*/
const subscribeToReactions = async (eventIds: string[]): Promise<void> => {
if (!reactionService || eventIds.length === 0) return
try {
await reactionService.subscribeToReactions(eventIds)
} catch (error) {
console.error('Failed to subscribe to reactions:', error)
}
}
/**
* Toggle like on an event - like if not liked, unlike if already liked
*/
const toggleLike = async (eventId: string, eventPubkey: string, eventKind: number): Promise<void> => {
if (!reactionService) {
toast.error('Reaction service not available')
return
}
try {
await reactionService.toggleLikeEvent(eventId, eventPubkey, eventKind)
// Check if we liked or unliked
const eventReactions = reactionService.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
toast.success('Post liked!')
} else {
toast.success('Like removed')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle reaction'
if (message.includes('authenticated')) {
toast.error('Please sign in to react to posts')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
toast.error(message)
}
console.error('Failed to toggle like:', error)
}
}
/**
* Get loading state
*/
const isLoading = computed(() => {
return reactionService?.isLoading ?? false
})
/**
* Get all event reactions (for debugging)
*/
const allEventReactions = computed(() => {
return reactionService?.eventReactions ?? new Map()
})
return {
// Methods
getEventReactions,
subscribeToReactions,
toggleLike,
// State
isLoading,
allEventReactions
}
}

View file

@ -0,0 +1,261 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
import type { AuthService } from '@/modules/base/auth/auth-service'
import { useToast } from '@/core/composables/useToast'
/**
* Composable for managing scheduled events in the feed
*/
export function useScheduledEvents() {
const scheduledEventService = injectService<ScheduledEventService>(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const toast = useToast()
// Get current user's pubkey
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
/**
* Get all scheduled events
*/
const getScheduledEvents = (): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getScheduledEvents()
}
/**
* Get events for a specific date (YYYY-MM-DD)
*/
const getEventsForDate = (date: string): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getEventsForDate(date)
}
/**
* Get events for a specific date (filtered by current user participation)
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
*/
const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value)
}
/**
* Get today's scheduled events (filtered by current user participation)
*/
const getTodaysEvents = (): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getTodaysEvents(currentUserPubkey.value)
}
/**
* Get completion status for an event
*/
const getCompletion = (eventAddress: string): EventCompletion | undefined => {
if (!scheduledEventService) return undefined
return scheduledEventService.getCompletion(eventAddress)
}
/**
* Check if an event is completed
*/
const isCompleted = (eventAddress: string): boolean => {
if (!scheduledEventService) return false
return scheduledEventService.isCompleted(eventAddress)
}
/**
* Get task status for an event
*/
const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
if (!scheduledEventService) return null
return scheduledEventService.getTaskStatus(eventAddress, occurrence)
}
/**
* Claim a task
*/
const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.claimTask(event, notes, occurrence)
toast.success('Task claimed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to claim task'
if (message.includes('authenticated')) {
toast.error('Please sign in to claim tasks')
} else {
toast.error(message)
}
console.error('Failed to claim task:', error)
}
}
/**
* Start a task (mark as in-progress)
*/
const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.startTask(event, notes, occurrence)
toast.success('Task started!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start task'
toast.error(message)
console.error('Failed to start task:', error)
}
}
/**
* Unclaim a task (remove task status)
*/
const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to unclaim task'
toast.error(message)
console.error('Failed to unclaim task:', error)
}
}
/**
* Toggle completion status of an event (optionally for a specific occurrence)
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
*/
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
if (!scheduledEventService) {
console.error('❌ useScheduledEvents: Scheduled event service not available')
toast.error('Scheduled event service not available')
return
}
try {
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence)
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
if (currentlyCompleted) {
console.log('⬇️ useScheduledEvents: Unclaiming task...')
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} else {
console.log('⬆️ useScheduledEvents: Marking as complete...')
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle completion'
if (message.includes('authenticated')) {
toast.error('Please sign in to complete tasks')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
toast.error(message)
}
console.error('❌ useScheduledEvents: Failed to toggle completion:', error)
}
}
/**
* Complete an event with optional notes
*/
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to complete task'
toast.error(message)
console.error('Failed to complete task:', error)
}
}
/**
* Get loading state
*/
const isLoading = computed(() => {
return scheduledEventService?.isLoading ?? false
})
/**
* Get all scheduled events (reactive)
*/
const allScheduledEvents = computed(() => {
return scheduledEventService?.scheduledEvents ?? new Map()
})
/**
* Delete a task (only author can delete)
*/
const deleteTask = async (event: ScheduledEvent): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.deleteTask(event)
toast.success('Task deleted!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete task'
toast.error(message)
console.error('Failed to delete task:', error)
}
}
/**
* Get all completions (reactive) - returns array for better reactivity
*/
const allCompletions = computed(() => {
if (!scheduledEventService?.completions) return []
return Array.from(scheduledEventService.completions.values())
})
return {
// Methods - Getters
getScheduledEvents,
getEventsForDate,
getEventsForSpecificDate,
getTodaysEvents,
getCompletion,
isCompleted,
getTaskStatus,
// Methods - Actions
claimTask,
startTask,
completeEvent,
unclaimTask,
deleteTask,
toggleComplete, // DEPRECATED: Use specific actions instead
// State
isLoading,
allScheduledEvents,
allCompletions
}
}

View file

@ -1,6 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { eventBus } from '@/core/event-bus' import { eventBus } from '@/core/event-bus'
import type { Event as NostrEvent, Filter } from 'nostr-tools' import type { Event as NostrEvent, Filter } from 'nostr-tools'
@ -47,7 +47,7 @@ export class FeedService extends BaseService {
protected relayHub: any = null protected relayHub: any = null
protected visibilityService: any = null protected visibilityService: any = null
protected reactionService: any = null protected reactionService: any = null
protected taskService: any = null protected scheduledEventService: any = null
// Event ID tracking for deduplication // Event ID tracking for deduplication
private seenEventIds = new Set<string>() private seenEventIds = new Set<string>()
@ -73,12 +73,13 @@ export class FeedService extends BaseService {
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE) this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE) this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
this.taskService = injectService(SERVICE_TOKENS.TASK_SERVICE) // ScheduledEventService moved to tasks module - use tryInjectService for backward compat
this.scheduledEventService = tryInjectService(SERVICE_TOKENS.TASK_SERVICE)
console.log('FeedService: RelayHub injected:', !!this.relayHub) console.log('FeedService: RelayHub injected:', !!this.relayHub)
console.log('FeedService: VisibilityService injected:', !!this.visibilityService) console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
console.log('FeedService: ReactionService injected:', !!this.reactionService) console.log('FeedService: ReactionService injected:', !!this.reactionService)
console.log('FeedService: TaskService injected:', !!this.taskService) console.log('FeedService: TaskService injected:', !!this.scheduledEventService)
if (!this.relayHub) { if (!this.relayHub) {
throw new Error('RelayHub service not available') throw new Error('RelayHub service not available')
@ -260,19 +261,28 @@ export class FeedService extends BaseService {
// Route reaction events (kind 7) to ReactionService // Route reaction events (kind 7) to ReactionService
if (event.kind === 7) { if (event.kind === 7) {
if (this.reactionService) {
this.reactionService.handleReactionEvent(event) this.reactionService.handleReactionEvent(event)
}
return return
} }
// Route scheduled events (kind 31922) to TaskService // Route scheduled events (kind 31922) to ScheduledEventService
if (event.kind === 31922) { if (event.kind === 31922) {
this.taskService.handleScheduledEvent(event) if (this.scheduledEventService) {
this.scheduledEventService.handleScheduledEvent(event)
}
return return
} }
// Route RSVP/completion events (kind 31925) to TaskService // Route RSVP/completion events (kind 31925) to ScheduledEventService
if (event.kind === 31925) { if (event.kind === 31925) {
this.taskService.handleCompletionEvent(event) console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleCompletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
@ -368,19 +378,31 @@ export class FeedService extends BaseService {
// Route to ReactionService for reaction deletions (kind 7) // Route to ReactionService for reaction deletions (kind 7)
if (deletedKind === '7') { if (deletedKind === '7') {
if (this.reactionService) {
this.reactionService.handleDeletionEvent(event) this.reactionService.handleDeletionEvent(event)
}
return return
} }
// Route to TaskService for completion/RSVP deletions (kind 31925) // Route to ScheduledEventService for completion/RSVP deletions (kind 31925)
if (deletedKind === '31925') { if (deletedKind === '31925') {
this.taskService.handleDeletionEvent(event) console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleDeletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
// Route to TaskService for scheduled event deletions (kind 31922) // Route to ScheduledEventService for scheduled event deletions (kind 31922)
if (deletedKind === '31922') { if (deletedKind === '31922') {
this.taskService.handleTaskDeletion(event) console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleTaskDeletion(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
@ -601,9 +623,17 @@ export class FeedService extends BaseService {
* Get like count for a post from ReactionService * Get like count for a post from ReactionService
*/ */
private getLikeCount(postId: string): number { private getLikeCount(postId: string): number {
try {
if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') {
const reactions = this.reactionService.getEventReactions(postId) const reactions = this.reactionService.getEventReactions(postId)
return reactions?.likes || 0 return reactions?.likes || 0
} }
} catch (error) {
// Silently fail if reaction service is not available
console.debug('FeedService: Could not get like count for post', postId, error)
}
return 0
}
/** /**
* Get filtered posts for specific feed type * Get filtered posts for specific feed type

View file

@ -0,0 +1,585 @@
import { ref, reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
export interface Reaction {
id: string
eventId: string // The event being reacted to
pubkey: string // Who reacted
content: string // The reaction content ('+', '-', emoji)
created_at: number
}
export interface EventReactions {
eventId: string
likes: number
dislikes: number
totalReactions: number
userHasLiked: boolean
userHasDisliked: boolean
userReactionId?: string // Track the user's reaction ID for deletion
reactions: Reaction[]
}
export class ReactionService extends BaseService {
protected readonly metadata = {
name: 'ReactionService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected authService: any = null
// Reaction state - indexed by event ID
private _eventReactions = reactive(new Map<string, EventReactions>())
private _isLoading = ref(false)
// Track reaction subscription
private currentSubscription: string | null = null
private currentUnsubscribe: (() => void) | null = null
// Track which events we're monitoring
private monitoredEvents = new Set<string>()
// Track deleted reactions to hide them
private deletedReactions = new Set<string>()
protected async onInitialize(): Promise<void> {
console.log('ReactionService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
// Deletion monitoring is now handled by FeedService's consolidated subscription
console.log('ReactionService: Initialization complete (deletion monitoring handled by FeedService)')
}
/**
* Get reactions for a specific event
*/
getEventReactions(eventId: string): EventReactions {
if (!this._eventReactions.has(eventId)) {
this._eventReactions.set(eventId, {
eventId,
likes: 0,
dislikes: 0,
totalReactions: 0,
userHasLiked: false,
userHasDisliked: false,
reactions: []
})
}
return this._eventReactions.get(eventId)!
}
/**
* Subscribe to reactions for a list of event IDs
*/
async subscribeToReactions(eventIds: string[]): Promise<void> {
if (eventIds.length === 0) return
// Filter out events we're already monitoring
const newEventIds = eventIds.filter(id => !this.monitoredEvents.has(id))
if (newEventIds.length === 0) return
console.log(`ReactionService: Subscribing to reactions for ${newEventIds.length} events`)
try {
if (!this.relayHub?.isConnected) {
await this.relayHub?.connect()
}
// Add to monitored set
newEventIds.forEach(id => this.monitoredEvents.add(id))
const subscriptionId = `reactions-${Date.now()}`
// Subscribe to reactions (kind 7) for these events
// Deletions (kind 5) are now handled by FeedService's consolidated subscription
const filters = [
{
kinds: [7], // Reactions
'#e': newEventIds, // Events being reacted to
limit: 1000
}
]
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: filters,
onEvent: (event: NostrEvent) => {
this.handleReactionEvent(event)
},
onEose: () => {
console.log(`ReactionService: Subscription ${subscriptionId} ready`)
}
})
// Store subscription info (we can have multiple)
if (!this.currentSubscription) {
this.currentSubscription = subscriptionId
this.currentUnsubscribe = unsubscribe
}
} catch (error) {
console.error('Failed to subscribe to reactions:', error)
}
}
/**
* Handle incoming reaction event
* Made public so FeedService can route kind 7 events to this service
*/
public handleReactionEvent(event: NostrEvent): void {
try {
// Find the event being reacted to
const eTag = event.tags.find(tag => tag[0] === 'e')
if (!eTag || !eTag[1]) {
console.warn('Reaction event missing e tag:', event.id)
return
}
const eventId = eTag[1]
const content = event.content.trim()
// Create reaction object
const reaction: Reaction = {
id: event.id,
eventId,
pubkey: event.pubkey,
content,
created_at: event.created_at
}
// Update event reactions
const eventReactions = this.getEventReactions(eventId)
// Check if this reaction already exists (deduplication) or is deleted
const existingIndex = eventReactions.reactions.findIndex(r => r.id === reaction.id)
if (existingIndex >= 0) {
return // Already have this reaction
}
// Check if this reaction has been deleted
if (this.deletedReactions.has(reaction.id)) {
return // This reaction was deleted
}
// IMPORTANT: Remove any previous reaction from the same user
// This ensures one reaction per user per event, even if deletion events aren't processed
const previousReactionIndex = eventReactions.reactions.findIndex(r =>
r.pubkey === reaction.pubkey &&
r.content === reaction.content
)
if (previousReactionIndex >= 0) {
// Replace the old reaction with the new one
eventReactions.reactions[previousReactionIndex] = reaction
} else {
// Add as new reaction
eventReactions.reactions.push(reaction)
}
// Recalculate counts and user state
this.recalculateEventReactions(eventId)
} catch (error) {
console.error('Failed to handle reaction event:', error)
}
}
/**
* Handle deletion event (called by FeedService when a kind 5 event with k=7 is received)
* Made public so FeedService can route deletion events to this service
*/
public handleDeletionEvent(event: NostrEvent): void {
try {
// Process each deleted event
const eTags = event.tags.filter(tag => tag[0] === 'e')
const deletionAuthor = event.pubkey
for (const eTag of eTags) {
const deletedEventId = eTag[1]
if (deletedEventId) {
// Add to deleted set
this.deletedReactions.add(deletedEventId)
// Find and remove the reaction from all event reactions
for (const [eventId, eventReactions] of this._eventReactions) {
const reactionIndex = eventReactions.reactions.findIndex(r => r.id === deletedEventId)
if (reactionIndex >= 0) {
const reaction = eventReactions.reactions[reactionIndex]
// IMPORTANT: Only process deletion if it's from the same user who created the reaction
// This follows NIP-09 spec: "Relays SHOULD delete or stop publishing any referenced events
// that have an identical `pubkey` as the deletion request"
if (reaction.pubkey === deletionAuthor) {
eventReactions.reactions.splice(reactionIndex, 1)
// Recalculate counts for this event
this.recalculateEventReactions(eventId)
}
}
}
}
}
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Recalculate reaction counts and user state for an event
*/
private recalculateEventReactions(eventId: string): void {
const eventReactions = this.getEventReactions(eventId)
const userPubkey = this.authService?.user?.value?.pubkey
// Use Sets to track unique users who liked/disliked
const likedUsers = new Set<string>()
const dislikedUsers = new Set<string>()
let userHasLiked = false
let userHasDisliked = false
let userReactionId: string | undefined
// Group reactions by user, keeping only the most recent
const latestReactionsByUser = new Map<string, Reaction>()
for (const reaction of eventReactions.reactions) {
// Skip deleted reactions
if (this.deletedReactions.has(reaction.id)) {
continue
}
// Keep only the latest reaction from each user
const existing = latestReactionsByUser.get(reaction.pubkey)
if (!existing || reaction.created_at > existing.created_at) {
latestReactionsByUser.set(reaction.pubkey, reaction)
}
}
// Now count unique reactions
for (const reaction of latestReactionsByUser.values()) {
const isLike = reaction.content === '+' || reaction.content === '❤️' || reaction.content === ''
const isDislike = reaction.content === '-'
if (isLike) {
likedUsers.add(reaction.pubkey)
if (userPubkey && reaction.pubkey === userPubkey) {
userHasLiked = true
userReactionId = reaction.id
}
} else if (isDislike) {
dislikedUsers.add(reaction.pubkey)
if (userPubkey && reaction.pubkey === userPubkey) {
userHasDisliked = true
userReactionId = reaction.id
}
}
}
// Update the reactive state with unique user counts
eventReactions.likes = likedUsers.size
eventReactions.dislikes = dislikedUsers.size
eventReactions.totalReactions = latestReactionsByUser.size
eventReactions.userHasLiked = userHasLiked
eventReactions.userHasDisliked = userHasDisliked
eventReactions.userReactionId = userReactionId
}
/**
* Send a heart reaction (like) to an event
*/
async likeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Check if user already liked this event
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
throw new Error('Already liked this event')
}
try {
this._isLoading.value = true
// Create reaction event template according to NIP-25
const eventTemplate: EventTemplate = {
kind: 7, // Reaction
content: '+', // Like reaction
tags: [
['e', eventId, '', eventPubkey], // Event being reacted to
['p', eventPubkey], // Author of the event being reacted to
['k', eventKind.toString()] // Kind of the event being reacted to
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the reaction
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to like event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a like from an event (unlike) using NIP-09 deletion events
*/
async unlikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Get the user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasLiked || !eventReactions.userReactionId) {
throw new Error('No reaction to remove')
}
try {
this._isLoading.value = true
// Create deletion event according to NIP-09
const eventTemplate: EventTemplate = {
kind: 5, // Deletion request
content: '', // Empty content or reason
tags: [
['e', eventReactions.userReactionId], // The reaction event to delete
['k', '7'] // Kind of event being deleted (reaction)
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Deletion published to ${result.success}/${result.total} relays`)
// Optimistically update local state
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to unlike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Toggle like on an event - like if not liked, unlike if already liked
*/
async toggleLikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
// Unlike the event
await this.unlikeEvent(eventId)
} else {
// Like the event
await this.likeEvent(eventId, eventPubkey, eventKind)
}
}
/**
* Send a dislike reaction to an event
*/
async dislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Check if user already disliked this event
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasDisliked) {
throw new Error('Already disliked this event')
}
try {
this._isLoading.value = true
// Create reaction event template according to NIP-25
const eventTemplate: EventTemplate = {
kind: 7, // Reaction
content: '-', // Dislike reaction
tags: [
['e', eventId, '', eventPubkey], // Event being reacted to
['p', eventPubkey], // Author of the event being reacted to
['k', eventKind.toString()] // Kind of the event being reacted to
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the reaction
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to dislike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a dislike from an event using NIP-09 deletion events
*/
async undislikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Get the user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasDisliked || !eventReactions.userReactionId) {
throw new Error('No dislike reaction to remove')
}
try {
this._isLoading.value = true
// Create deletion event according to NIP-09
const eventTemplate: EventTemplate = {
kind: 5, // Deletion request
content: '', // Empty content or reason
tags: [
['e', eventReactions.userReactionId], // The reaction event to delete
['k', '7'] // Kind of event being deleted (reaction)
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Dislike deletion published to ${result.success}/${result.total} relays`)
// Optimistically update local state
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to remove dislike:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Get all event reactions
*/
get eventReactions(): Map<string, EventReactions> {
return this._eventReactions
}
/**
* Check if currently loading
*/
get isLoading(): boolean {
return this._isLoading.value
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
if (this.currentUnsubscribe) {
this.currentUnsubscribe()
}
// deletionUnsubscribe is no longer used - deletions handled by FeedService
this._eventReactions.clear()
this.monitoredEvents.clear()
this.deletedReactions.clear()
}
}

View file

@ -0,0 +1,678 @@
import { ref, reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
export interface RecurrencePattern {
frequency: 'daily' | 'weekly'
dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc.
endDate?: string // ISO date string - when to stop recurring (optional)
}
export interface ScheduledEvent {
id: string
pubkey: string
created_at: number
dTag: string // Unique identifier from 'd' tag
title: string
start: string // ISO date string (YYYY-MM-DD or ISO datetime)
end?: string
description?: string
location?: string
status: string
eventType?: string // 'task' for completable events, 'announcement' for informational
participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer'
content: string
tags: string[][]
recurrence?: RecurrencePattern // Optional: for recurring events
}
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
export interface EventCompletion {
id: string
eventAddress: string // "31922:pubkey:d-tag"
occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
pubkey: string // Who claimed/completed it
created_at: number
taskStatus: TaskStatus
completedAt?: number // Unix timestamp when completed
notes: string
}
export class ScheduledEventService extends BaseService {
protected readonly metadata = {
name: 'ScheduledEventService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected authService: any = null
// Scheduled events state - indexed by event address
private _scheduledEvents = reactive(new Map<string, ScheduledEvent>())
private _completions = reactive(new Map<string, EventCompletion>())
private _isLoading = ref(false)
protected async onInitialize(): Promise<void> {
console.log('ScheduledEventService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('ScheduledEventService: Initialization complete')
}
/**
* Handle incoming scheduled event (kind 31922)
* Made public so FeedService can route kind 31922 events to this service
*/
public handleScheduledEvent(event: NostrEvent): void {
try {
// Extract event data from tags
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
if (!dTag) {
console.warn('Scheduled event missing d tag:', event.id)
return
}
const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event'
const start = event.tags.find(tag => tag[0] === 'start')?.[1]
const end = event.tags.find(tag => tag[0] === 'end')?.[1]
const description = event.tags.find(tag => tag[0] === 'description')?.[1]
const location = event.tags.find(tag => tag[0] === 'location')?.[1]
const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending'
const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1]
// Parse participant tags: ["p", "<pubkey>", "<relay-hint>", "<participation-type>"]
const participantTags = event.tags.filter(tag => tag[0] === 'p')
const participants = participantTags.map(tag => ({
pubkey: tag[1],
type: tag[3] // 'required', 'optional', 'organizer'
}))
// Parse recurrence tags
const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined
const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1]
const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1]
let recurrence: RecurrencePattern | undefined
if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') {
recurrence = {
frequency: recurrenceFreq,
dayOfWeek: recurrenceDayOfWeek,
endDate: recurrenceEndDate
}
}
if (!start) {
console.warn('Scheduled event missing start date:', event.id)
return
}
// Create event address: "kind:pubkey:d-tag"
const eventAddress = `31922:${event.pubkey}:${dTag}`
const scheduledEvent: ScheduledEvent = {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
dTag,
title,
start,
end,
description,
location,
status,
eventType,
participants: participants.length > 0 ? participants : undefined,
content: event.content,
tags: event.tags,
recurrence
}
// Store or update the event (replaceable by d-tag)
this._scheduledEvents.set(eventAddress, scheduledEvent)
} catch (error) {
console.error('Failed to handle scheduled event:', error)
}
}
/**
* Handle RSVP/completion event (kind 31925)
* Made public so FeedService can route kind 31925 events to this service
*/
public handleCompletionEvent(event: NostrEvent): void {
console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id)
try {
// Find the event being responded to
const aTag = event.tags.find(tag => tag[0] === 'a')?.[1]
if (!aTag) {
console.warn('Completion event missing a tag:', event.id)
return
}
// Parse task status (new approach)
const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
// Backward compatibility: check old 'completed' tag if task-status not present
let taskStatus: TaskStatus
if (taskStatusTag) {
taskStatus = taskStatusTag
} else {
// Legacy support: convert old 'completed' tag to new taskStatus
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
taskStatus = completed ? 'completed' : 'claimed'
}
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
console.log('📋 Completion details:', {
aTag,
occurrence,
taskStatus,
pubkey: event.pubkey,
eventId: event.id
})
const completion: EventCompletion = {
id: event.id,
eventAddress: aTag,
occurrence,
pubkey: event.pubkey,
created_at: event.created_at,
taskStatus,
completedAt,
notes: event.content
}
// Store completion (most recent one wins)
// For recurring events, include occurrence in the key: "eventAddress:occurrence"
// For non-recurring, just use eventAddress
const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag
const existing = this._completions.get(completionKey)
if (!existing || event.created_at > existing.created_at) {
this._completions.set(completionKey, completion)
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
} else {
console.log('⏭️ Skipped older completion for:', completionKey)
}
} catch (error) {
console.error('Failed to handle completion event:', error)
}
}
/**
* Handle deletion event (kind 5) for completion events
* Made public so FeedService can route deletion events to this service
*/
public handleDeletionEvent(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id)
try {
// Extract event IDs to delete from 'e' tags
const eventIdsToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'e')
.map((tag: string[]) => tag[1]) || []
if (eventIdsToDelete.length === 0) {
console.warn('Deletion event missing e tags:', event.id)
return
}
console.log('🔍 Looking for completions to delete:', eventIdsToDelete)
// Find and remove completions that match the deleted event IDs
let deletedCount = 0
for (const [completionKey, completion] of this._completions.entries()) {
// Only delete if:
// 1. The completion event ID matches one being deleted
// 2. The deletion request comes from the same author (NIP-09 validation)
if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) {
this._completions.delete(completionKey)
console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id)
deletedCount++
}
}
console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`)
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Handle deletion event (kind 5) for scheduled events (kind 31922)
* Made public so FeedService can route deletion events to this service
*/
public handleTaskDeletion(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id)
try {
// Extract event addresses to delete from 'a' tags
const eventAddressesToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'a')
.map((tag: string[]) => tag[1]) || []
if (eventAddressesToDelete.length === 0) {
console.warn('Task deletion event missing a tags:', event.id)
return
}
console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete)
// Find and remove tasks that match the deleted event addresses
let deletedCount = 0
for (const eventAddress of eventAddressesToDelete) {
const task = this._scheduledEvents.get(eventAddress)
// Only delete if:
// 1. The task exists
// 2. The deletion request comes from the task author (NIP-09 validation)
if (task && task.pubkey === event.pubkey) {
this._scheduledEvents.delete(eventAddress)
console.log('✅ Deleted task:', eventAddress)
deletedCount++
} else if (task) {
console.warn('⚠️ Deletion request not from task author:', eventAddress)
}
}
console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`)
} catch (error) {
console.error('Failed to handle task deletion event:', error)
}
}
/**
* Get all scheduled events
*/
getScheduledEvents(): ScheduledEvent[] {
return Array.from(this._scheduledEvents.values())
}
/**
* Get events scheduled for a specific date (YYYY-MM-DD)
*/
getEventsForDate(date: string): ScheduledEvent[] {
return this.getScheduledEvents().filter(event => {
// Simple date matching (start date)
// For ISO datetime strings, extract just the date part
const eventDate = event.start.split('T')[0]
return eventDate === date
})
}
/**
* Check if a recurring event occurs on a specific date
*/
private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean {
if (!event.recurrence) return false
const target = new Date(targetDate)
const eventStart = new Date(event.start.split('T')[0]) // Get date part only
// Check if target date is before the event start date
if (target < eventStart) return false
// Check if target date is after the event end date (if specified)
if (event.recurrence.endDate) {
const endDate = new Date(event.recurrence.endDate)
if (target > endDate) return false
}
// Check frequency-specific rules
if (event.recurrence.frequency === 'daily') {
// Daily events occur every day within the range
return true
} else if (event.recurrence.frequency === 'weekly') {
// Weekly events occur on specific day of week
const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase()
return targetDayOfWeek === eventDayOfWeek
}
return false
}
/**
* Get events for a specific date, optionally filtered by user participation
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
* @param userPubkey - Optional user pubkey to filter by participation
*/
getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] {
const targetDate = date || new Date().toISOString().split('T')[0]
// Get one-time events for the date (exclude recurring events to avoid duplicates)
const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence)
// Get all events and check for recurring events that occur on this date
const allEvents = this.getScheduledEvents()
const recurringEventsOnDate = allEvents.filter(event =>
event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate)
)
// Combine one-time and recurring events
let events = [...oneTimeEvents, ...recurringEventsOnDate]
// Filter events based on participation (if user pubkey provided)
if (userPubkey) {
events = events.filter(event => {
// If event has no participants, it's community-wide (show to everyone)
if (!event.participants || event.participants.length === 0) return true
// Otherwise, only show if user is a participant
return event.participants.some(p => p.pubkey === userPubkey)
})
}
// Sort by start time (ascending order)
events.sort((a, b) => {
// ISO datetime strings can be compared lexicographically
return a.start.localeCompare(b.start)
})
return events
}
/**
* Get events for today, optionally filtered by user participation
*/
getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
return this.getEventsForSpecificDate(undefined, userPubkey)
}
/**
* Get completion status for an event (optionally for a specific occurrence)
*/
getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined {
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
return this._completions.get(completionKey)
}
/**
* Check if an event is completed (optionally for a specific occurrence)
*/
isCompleted(eventAddress: string, occurrence?: string): boolean {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus === 'completed'
}
/**
* Get task status for an event
*/
getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus || null
}
/**
* Claim a task (mark as claimed)
*/
async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'claimed', notes, occurrence)
}
/**
* Start a task (mark as in-progress)
*/
async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
}
/**
* Mark an event as complete (optionally for a specific occurrence)
*/
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'completed', notes, occurrence)
}
/**
* Internal method to update task status
*/
private async updateTaskStatus(
event: ScheduledEvent,
taskStatus: TaskStatus,
notes: string = '',
occurrence?: string
): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to update task status')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
if (!userPrivkey) {
throw new Error('User private key not available')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create RSVP event with task-status tag
const tags: string[][] = [
['a', eventAddress],
['task-status', taskStatus]
]
// Add completed_at timestamp if task is completed
if (taskStatus === 'completed') {
tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
}
// Add occurrence tag if provided (for recurring events)
if (occurrence) {
tags.push(['occurrence', occurrence])
}
const eventTemplate: EventTemplate = {
kind: 31925, // Calendar Event RSVP
content: notes,
tags,
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the status update
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
// Update local state (publishEvent throws if no relays accepted)
console.log('🔄 Updating local state (event published successfully)')
this.handleCompletionEvent(signedEvent)
} catch (error) {
console.error('Failed to update task status:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Unclaim/reset a task (removes task status - makes it unclaimed)
* Note: In Nostr, we can't truly "delete" an event, but we can publish
* a deletion request (kind 5) to ask relays to remove our RSVP
*/
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to unclaim tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
if (!userPrivkey) {
throw new Error('User private key not available')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
const completion = this._completions.get(completionKey)
if (!completion) {
console.log('No completion to unclaim')
return
}
// Create deletion event (kind 5) for the RSVP
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task unclaimed',
tags: [
['e', completion.id], // Reference to the RSVP event being deleted
['k', '31925'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task RSVP:', completion.id)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._completions.delete(completionKey)
console.log('🗑️ Removed completion from local state:', completionKey)
} catch (error) {
console.error('Failed to unclaim task:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Delete a scheduled event (kind 31922)
* Only the author can delete their own event
*/
async deleteTask(event: ScheduledEvent): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to delete tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
const userPubkey = this.authService.user.value?.pubkey
if (!userPrivkey || !userPubkey) {
throw new Error('User credentials not available')
}
// Only author can delete
if (userPubkey !== event.pubkey) {
throw new Error('Only the task author can delete this task')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create deletion event (kind 5) for the scheduled event
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task deleted',
tags: [
['a', eventAddress], // Reference to the parameterized replaceable event being deleted
['k', '31922'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task:', eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._scheduledEvents.delete(eventAddress)
console.log('🗑️ Removed task from local state:', eventAddress)
} catch (error) {
console.error('Failed to delete task:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Get all scheduled events
*/
get scheduledEvents(): Map<string, ScheduledEvent> {
return this._scheduledEvents
}
/**
* Get all completions
*/
get completions(): Map<string, EventCompletion> {
return this._completions
}
/**
* Check if currently loading
*/
get isLoading(): boolean {
return this._isLoading.value
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
this._scheduledEvents.clear()
this._completions.clear()
}
}