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_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)
# Override the domain used for Lightning Addresses
# 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
```
## 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
### **Problem Overview**

View file

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

86
pnpm-lock.yaml generated
View file

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

View file

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

View file

@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.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'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { isAuthenticated, currentUser } = useAuth()
const activitiesStore = useActivitiesStore()
@ -27,9 +25,9 @@ const { isAdmin, autoApprove } = useApprovalState()
const { loadOwnEvents } = useActivities()
// 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
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
// opening the dialog. Per-app placement deliberation tracked at #53.
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
// act, surfacing it as a tab keeps it one tap away when authed and out of the
// way when not. Per-app placement deliberation tracked at #53.
const tabs = computed<BottomTab[]>(() => [
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
@ -37,15 +35,6 @@ const tabs = computed<BottomTab[]>(() => [
name: t('activities.createNew'),
icon: Plus,
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
// tap always opens in Create mode regardless of a prior Edit.
activitiesStore.editingEvent = null
@ -54,27 +43,7 @@ const tabs = computed<BottomTab[]>(() => [
disabled: !isAuthenticated.value,
},
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
{
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,
},
{ name: t('activities.nav.favorites'), icon: Heart, path: '/activities/favorites' },
])
// Feed tab is active for the bare /activities route AND all sub-paths that

View file

@ -1,195 +1,179 @@
@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';
@custom-variant dark (&:is(.dark *));
@theme inline {
--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);
@theme {
--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);
--shadow-xs: var(--shadow-xs);
--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);
--color-background: oklch(var(--background));
--color-foreground: oklch(var(--foreground));
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--color-card: oklch(var(--card));
--color-card-foreground: oklch(var(--card-foreground));
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--color-popover: oklch(var(--popover));
--color-popover-foreground: oklch(var(--popover-foreground));
--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-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from { height: 0; }
to { height: var(--reka-accordion-content-height); }
from {
height: 0;
}
to {
height: var(--reka-accordion-content-height);
}
}
@keyframes accordion-up {
from { height: var(--reka-accordion-content-height); }
to { height: 0; }
from {
height: var(--reka-accordion-content-height);
}
to {
height: 0;
}
}
/* Add standard shadcn animation durations */
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
/* Add standard shadcn easings */
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* Add standard shadcn animations */
--animate-in: animate-in var(--duration-normal) var(--ease-out);
--animate-out: animate-out var(--duration-normal) var(--ease-in);
--animate-fade-in: fade-in var(--duration-normal) var(--ease-out);
--animate-fade-out: fade-out var(--duration-normal) var(--ease-in);
--animate-slide-in-from-top: slide-in-from-top var(--duration-normal) var(--ease-out);
--animate-slide-out-to-top: slide-out-to-top var(--duration-normal) var(--ease-in);
--animate-slide-in-from-bottom: slide-in-from-bottom var(--duration-normal) var(--ease-out);
--animate-slide-out-to-bottom: slide-out-to-bottom var(--duration-normal) var(--ease-in);
--animate-slide-in-from-left: slide-in-from-left var(--duration-normal) var(--ease-out);
--animate-slide-out-to-left: slide-out-to-left var(--duration-normal) var(--ease-in);
--animate-slide-in-from-right: slide-in-from-right var(--duration-normal) var(--ease-out);
--animate-slide-out-to-right: slide-out-to-right var(--duration-normal) var(--ease-in);
}
/* Default palette: Catppuccin (Latte for light, Mocha for dark).
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
:root {
--background: oklch(0.9578 0.0058 264.5321);
--foreground: oklch(0.4355 0.0430 279.3250);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.4355 0.0430 279.3250);
--popover: oklch(0.8575 0.0145 268.4756);
--popover-foreground: oklch(0.4355 0.0430 279.3250);
--primary: oklch(0.5547 0.2503 297.0156);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.8575 0.0145 268.4756);
--secondary-foreground: oklch(0.4355 0.0430 279.3250);
--muted: oklch(0.9060 0.0117 264.5071);
--muted-foreground: oklch(0.5471 0.0343 279.0837);
--accent: oklch(0.6820 0.1448 235.3822);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0.5505 0.2155 19.8095);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8083 0.0174 271.1982);
--input: oklch(0.8575 0.0145 268.4756);
--ring: oklch(0.5547 0.2503 297.0156);
--chart-1: oklch(0.5547 0.2503 297.0156);
--chart-2: oklch(0.6820 0.1448 235.3822);
--chart-3: oklch(0.6250 0.1772 140.4448);
--chart-4: oklch(0.6920 0.2041 42.4293);
--chart-5: oklch(0.7141 0.1045 33.0967);
--sidebar: oklch(0.9335 0.0087 264.5206);
--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);
--foreground: oklch(0.8787 0.0426 272.2767);
--card: oklch(0.2429 0.0304 283.9110);
--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);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
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 {
*,
::after,
::before,
::backdrop,
::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 {
@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 { useI18n } from 'vue-i18n'
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 { useTheme, PALETTES, type Palette as PaletteName } from '@/components/theme-provider'
import { useTheme } from '@/components/theme-provider'
import { useLocale } from '@/composables/useLocale'
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
@ -14,14 +14,14 @@ import {
interface Props {
/** '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'
}
const props = withDefaults(defineProps<Props>(), { layout: 'row' })
const { t } = useI18n()
const { theme, setTheme, currentTheme, palette, setPalette } = useTheme()
const { theme, setTheme, currentTheme } = useTheme()
const { currentLocale, locales, setLocale } = useLocale()
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
)
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
// the row UX is what we're building here, not the underlying preference.
function notImplemented() {
@ -118,31 +114,6 @@ function notImplemented() {
</DropdownMenuContent>
</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 -->
<DropdownMenu>
<DropdownMenuTrigger as-child>

View file

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

View file

@ -43,17 +43,16 @@
</div>
<div class="flex gap-2">
<!-- Flash toggle (if available) flashlight, not lightning-bolt:
this controls the camera torch, not anything Lightning-related,
and the bolt icon was confusing in a Lightning-payments app. -->
<!-- Flash toggle (if available) -->
<Button
v-if="flashAvailable"
@click="toggleFlash"
variant="outline"
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>
<!-- Close scanner -->
@ -74,7 +73,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Button } from '@/components/ui/button'
import { Loader2, Flashlight } from 'lucide-vue-next'
import { Loader2 } from 'lucide-vue-next'
import { useQRScanner } from '@/composables/useQRScanner'
interface Emits {

View file

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

View file

@ -29,14 +29,6 @@ const messages: LocaleMessages = {
themeLight: 'Light',
themeDark: 'Dark',
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',
currency: 'Currency',
currencyComingSoon: 'Currency picker — coming soon',
@ -65,10 +57,6 @@ const messages: LocaleMessages = {
tomorrow: 'Tomorrow',
thisWeek: 'This Week',
thisMonth: 'This Month',
myTickets: 'My tickets',
hosting: 'Hosting',
pastEvents: 'Past events',
past: 'Past',
},
categories: {
concert: 'Concert',
@ -108,15 +96,7 @@ const messages: LocaleMessages = {
when: 'When',
tickets: 'Tickets',
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',
pastEvent: 'This event has already happened',
loginToBuyTickets: 'Log in to buy tickets',
logIn: 'Log in',
free: 'Free',
},
tickets: {

View file

@ -29,14 +29,6 @@ const messages: LocaleMessages = {
themeLight: 'Claro',
themeDark: 'Oscuro',
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',
currency: 'Moneda',
currencyComingSoon: 'Selector de moneda — próximamente',
@ -65,10 +57,6 @@ const messages: LocaleMessages = {
tomorrow: 'Mañana',
thisWeek: 'Esta semana',
thisMonth: 'Este mes',
myTickets: 'Mis boletos',
hosting: 'Organizo',
pastEvents: 'Eventos pasados',
past: 'Pasado',
},
categories: {
concert: 'Concierto',
@ -108,15 +96,7 @@ const messages: LocaleMessages = {
when: 'Cuándo',
tickets: 'Boletos',
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',
pastEvent: 'Este evento ya pasó',
loginToBuyTickets: 'Inicia sesión para comprar boletos',
logIn: 'Iniciar sesión',
free: 'Gratis',
},
tickets: {

View file

@ -29,14 +29,6 @@ const messages: LocaleMessages = {
themeLight: 'Clair',
themeDark: 'Sombre',
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',
currency: 'Devise',
currencyComingSoon: 'Sélecteur de devise — bientôt disponible',
@ -65,10 +57,6 @@ const messages: LocaleMessages = {
tomorrow: 'Demain',
thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
hosting: 'J\'organise',
pastEvents: 'Événements passés',
past: 'Passé',
},
categories: {
concert: 'Concert',
@ -108,15 +96,7 @@ const messages: LocaleMessages = {
when: 'Quand',
tickets: 'Billets',
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é',
pastEvent: 'Cet événement est déjà passé',
loginToBuyTickets: 'Connectez-vous pour acheter des billets',
logIn: 'Connexion',
free: 'Gratuit',
},
tickets: {

View file

@ -28,14 +28,6 @@ export interface LocaleMessages {
themeLight: string
themeDark: 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
currency: string
currencyComingSoon: string
@ -66,10 +58,6 @@ export interface LocaleMessages {
tomorrow: string
thisWeek: string
thisMonth: string
myTickets: string
hosting: string
pastEvents: string
past: string
}
categories: Record<string, string>
detail: {
@ -83,15 +71,7 @@ export interface LocaleMessages {
when: string
tickets: string
ticketsAvailable: string
ticketsOwned: string
unlimitedTickets: string
buyTicket: string
buyAnotherTicket: string
viewMyTickets: string
soldOut: string
pastEvent: string
loginToBuyTickets: string
logIn: string
free: string
}
tickets: {

View file

@ -40,12 +40,7 @@ interface User {
username?: string
email?: string
pubkey?: string
// The `prvkey` field was removed from this interface as the final step of
// 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.
prvkey?: string // Nostr private key for user
external_id?: string
extensions: string[]
wallets: Wallet[]
@ -116,29 +111,12 @@ export class LnbitsAPI extends BaseService {
if (!response.ok) {
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:', {
endpoint,
status: response.status,
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()
@ -178,22 +156,20 @@ export class LnbitsAPI extends BaseService {
async getCurrentUser(): Promise<User> {
// First get basic user info from /auth
const basicUser = await this.request<User>('/auth')
// /auth/nostr/me used to return the user's prvkey for client-side signing;
// 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).
// Then get Nostr keys from /auth/nostr/me (this was working in main branch)
try {
const nostrUser = await this.request<User>('/auth/nostr/me')
// Merge the data - basic user info + Nostr keys
return {
...basicUser,
pubkey: nostrUser.pubkey,
prvkey: nostrUser.prvkey
}
} 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
}
}
@ -209,23 +185,12 @@ export class LnbitsAPI extends BaseService {
}
async updateProfile(data: Partial<User>): Promise<User> {
// aiolabs/lnbits PR #26 (gap-fill 869f67c3) wired
// _publish_nostr_metadata_event into PATCH /api/v1/auth
// (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',
return this.request<User>('/auth/update', {
method: 'PUT',
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 {
return !!this.accessToken
}

View file

@ -4,12 +4,6 @@ export const LNBITS_CONFIG = {
// This should point to your LNBits instance
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
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 { Card, CardContent } from '@/components/ui/card'
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 { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import type { Activity } from '../types/activity'
const props = defineProps<{
@ -20,9 +19,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const { dateLocale } = useDateLocale()
const { paidCount } = useOwnedTickets()
const ownedCount = computed(() => paidCount(props.activity.id))
const dateDisplay = computed(() => {
const a = props.activity
@ -58,13 +54,6 @@ const placeholderBg = computed(() => {
const hue = hash % 360
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>
<template>
@ -128,22 +117,6 @@ const isPast = computed(() => {
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</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>
<CardContent class="p-4 flex-1 flex flex-col gap-2">
@ -182,38 +155,19 @@ const isPast = computed(() => {
<span class="truncate">{{ activity.location }}</span>
</div>
<!-- Tickets available. `available === undefined` means
unlimited capacity (no `tickets_available` tag was
published); show the price-only line in that case. -->
<!-- Tickets available -->
<div
v-if="activity.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
<span v-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }}
</span>
</div>
<!-- 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>
</CardContent>
</Card>

View file

@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button'
import { CalendarPlus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import type { CalendarTimeEvent } from '../types/nip52'
import type { ActivityCategory } from '../types/category'
import CategorySelector from './CategorySelector.vue'
import LocationPicker from './LocationPicker.vue'
@ -67,64 +67,56 @@ const form = useForm({
const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => {
const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
if (!ticketApi) {
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
toast.error('Activities service not available')
return
}
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
if (!invoiceKey) {
toast.error('No wallet available. Please log in first.')
const signingKey = currentUser.value?.prvkey
if (!signingKey) {
toast.error('Signing key not available. Please log in again.')
return
}
isPublishing.value = true
try {
// Compose ISO 8601 datetime strings the events extension parses.
const startIso = `${values.startDate}T${values.startTime}`
const endIso =
values.endDate && values.endTime
? `${values.endDate}T${values.endTime}`
: undefined
// Fold summary + description into `info` since the events extension
// CreateEventRequest has no separate summary field.
const info =
values.summary && values.description
? `${values.summary}\n\n${values.description}`
: values.description || values.summary || ''
// Ticket-less activity amount_tickets and price_per_ticket both
// pinned at 0 (events extension treats 0 as "unlimited / not
// ticketed" per models.py:45-46). Server-side `signer.sign_event`
// produces the kind-31922 calendar event and publishes via the
// operator's configured relays no webapp signing path needed.
const eventData: CreateEventRequest = {
name: values.title,
info,
event_start_date: startIso,
event_end_date: endIso,
location: location.value || null,
banner: values.image || null,
categories: selectedCategories.value,
amount_tickets: 0,
price_per_ticket: 0,
// Build unix timestamps
const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000)
let endTimestamp: number | undefined
if (values.endDate && values.endTime) {
endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000)
}
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
// `auto_approve=false` (the default) land in the proposal queue;
// their event isn't published to relays until an admin approves.
// Admins-and-auto-approve-on instances publish immediately.
toast.success('Activity created!')
emit('created')
handleClose()
const eventData: Partial<CalendarTimeEvent> = {
dTag,
title: values.title,
summary: values.summary || undefined,
content: values.description,
image: values.image || undefined,
start: startTimestamp,
end: endTimestamp,
startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: location.value || undefined,
hashtags: selectedCategories.value,
}
const result = await nostrService.publishCalendarEvent(eventData, signingKey)
if (result.success > 0) {
toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`)
emit('created')
handleClose()
} else {
toast.error('Failed to publish to any relay')
}
} catch (err) {
console.error('Failed to create activity:', err)
toast.error(err instanceof Error ? err.message : 'Failed to create activity')
console.error('Failed to publish activity:', err)
toast.error(err instanceof Error ? err.message : 'Failed to publish activity')
} finally {
isPublishing.value = false
}

View file

@ -24,9 +24,6 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
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 {
Select,
@ -35,13 +32,12 @@ import {
SelectTrigger,
SelectValue,
} 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 { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.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 type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService'
@ -91,28 +87,6 @@ function foldDateTime(date: string, time: string): string {
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(
z
.object({
@ -124,39 +98,20 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''),
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),
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) => {
// End must not precede start. Compare on the folded date+time
// string so equal-date / later-time is enforced too.
if (v.event_end_date) {
const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['event_end_date'],
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) {
if (!v.event_end_date) return
const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['fiat_currency'],
message: 'Pick a fiat currency for buyers paying by card',
path: ['event_end_date'],
message: 'End must be on or after start',
})
}
})
@ -173,14 +128,8 @@ const form = useForm({
event_end_time: '',
location: '',
currency: 'sat',
allow_fiat: false,
fiat_currency: 'USD',
amount_tickets: 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[]>([])
// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM[:SS][±HH:MM]]"
// back into separate date + time pieces for the form inputs. The
// 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.
// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM]" back
// into separate date + time pieces for the form inputs.
function splitDateTime(value: string | null | undefined): { date: string; time: string } {
if (!value) return { date: '', time: '' }
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
// edit-mode population with start-date side effects mid-setValues.
const isPopulating = ref(false)
const notificationsOpen = ref(false)
// 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
@ -243,14 +188,8 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time,
location: event.location ?? '',
currency: event.currency ?? 'sat',
allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 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 ?? [])]
if (event.banner) {
@ -328,8 +267,9 @@ const onSubmit = form.handleSubmit(async (formValues) => {
try {
const eventData: CreateEventRequest = {
name: formValues.name,
event_start_date: withLocalTzOffset(
foldDateTime(formValues.event_start_date, formValues.event_start_time)
event_start_date: foldDateTime(
formValues.event_start_date,
formValues.event_start_time
),
}
if (!isEditMode.value) {
@ -341,8 +281,9 @@ const onSubmit = form.handleSubmit(async (formValues) => {
// Optional fields only include if provided
if (formValues.info) eventData.info = formValues.info
if (formValues.event_end_date) {
eventData.event_end_date = withLocalTzOffset(
foldDateTime(formValues.event_end_date, formValues.event_end_time)
eventData.event_end_date = foldDateTime(
formValues.event_end_date,
formValues.event_end_time
)
}
if (formValues.location) eventData.location = formValues.location
@ -354,29 +295,10 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null
}
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.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
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 (!props.onUpdateEvent || !props.event?.id) {
toastService.error('Update handler missing')
@ -602,143 +524,50 @@ const handleOpenChange = (open: boolean) => {
/>
</div>
<!-- Pricing -->
<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">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Tickets</FormLabel>
<FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = unlimited</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Tickets (optional, visible) -->
<div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Tickets</FormLabel>
<FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = unlimited</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = free</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = free</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Price currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="sat" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in availableCurrencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="sat" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in availableCurrencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</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 -->
<div class="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">

View file

@ -1,20 +1,12 @@
<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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
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 {
event: {
@ -22,9 +14,6 @@ interface Props {
name: string
price_per_ticket: number
currency: string
/** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean
fiat_currency?: string
}
isOpen: boolean
}
@ -41,7 +30,6 @@ const {
isLoading,
error,
paymentHash,
paymentRequest,
qrCode,
isPaymentPending,
isPayingWithWallet,
@ -49,198 +37,27 @@ const {
userWallets,
hasWalletWithBalance,
purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet,
resetPaymentState,
cleanup,
purchasedTicketIds,
ticketQRCode,
purchasedTicketId,
showTicketQR
} = 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() {
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 {
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
} catch (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
await purchaseTicketForEvent(props.event.id)
} catch (err) {
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
} finally {
isFiatPending.value = false
console.error('Error purchasing ticket:', err)
}
}
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
}
function handleClose() {
emit('update:isOpen', false)
resetPaymentState()
selectedMethodId.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null
quantity.value = 1
copiedInvoice.value = false
}
onUnmounted(() => {
@ -250,20 +67,14 @@ onUnmounted(() => {
<template>
<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>
<DialogTitle class="flex items-center gap-2">
<CreditCard class="w-5 h-5" />
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
Purchase Ticket
</DialogTitle>
<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) }}
</span>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
</DialogDescription>
</DialogHeader>
@ -338,235 +149,95 @@ onUnmounted(() => {
<CreditCard class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Payment Details:</span>
</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="flex justify-between">
<span class="text-sm text-muted-foreground">Event:</span>
<span class="text-sm font-medium">{{ event.name }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span>
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
<span class="text-sm text-muted-foreground">Price:</span>
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
</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>
<!-- Payment method selector (only shown when fiat is enabled
on the event). Buttons surface one per configured fiat
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.
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{{ error }}
</div>
<Button
@click="handlePurchase"
:disabled="isLoading || !canPurchase"
class="w-full"
>
<span v-if="isLoading" class="animate-spin mr-2"></span>
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
<Zap class="w-4 h-4" />
Pay with Wallet
</span>
<span v-else>Generate Payment Request</span>
</Button>
</div>
<!-- Payment QR Code and Status -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold">Payment Required</h3>
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
Processing payment with your wallet...
</p>
<p v-else class="text-sm text-muted-foreground">
Scan the QR code with your Lightning wallet to complete the payment
</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 v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4">
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
</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>
<div class="space-y-3 w-full">
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
<Wallet class="w-4 h-4 mr-2" />
Open in Lightning Wallet
</Button>
<div v-if="isPaymentPending" class="text-center space-y-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>
<span class="text-sm text-muted-foreground">
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
</span>
</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.
Payment will be confirmed automatically once received
</p>
</div>
<Button @click="openFiatCheckout" class="w-full">
<ExternalLink class="w-4 h-4 mr-2" />
Open {{ fiatProviderLabel }} checkout
</Button>
</div>
<Button
v-else
@click="handlePurchase"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full"
>
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout
</template>
<template v-else>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</template>
</Button>
</div>
<!-- Lightning invoice restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the
LNbits wallet, scan with an external wallet, or hand off
via lightning: URI on mobile. Polling fires whichever
path the buyer takes. -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 space-y-4">
<div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground">
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
</p>
</div>
<!-- QR + amount + copy/open buttons (restaurant
OrderInvoiceCard pattern). The QR keeps a white background
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>
<!-- LNbits-wallet pay button only shown when the buyer is
logged in with a funded wallet. Same screen as the QR so
the user can pick either path without having to back out
of the dialog. -->
<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>
<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 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>
<span class="text-sm text-muted-foreground">
Waiting for payment
</span>
</div>
<p class="text-xs text-muted-foreground">
Confirmation lands automatically no need to refresh.
</p>
</div>
</div>
<!-- Success state. QRs live in My Tickets no need to
pre-render them here; this view's job is to confirm the
purchase landed and route the buyer to where they actually
interact with their tickets. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
<div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" />
</div>
<!-- Ticket QR Code (After Successful Purchase) -->
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-green-600">
{{ purchasedTicketIds.length > 1
? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }}
</h3>
<h3 class="text-lg font-semibold text-green-600">Ticket Purchased Successfully!</h3>
<p class="text-sm text-muted-foreground">
<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>
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">
<Ticket class="w-12 h-12 text-green-600" />
</div>
<div>
<p class="text-sm font-medium">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p>
</div>
</div>
</div>
</div>
<div class="space-y-3 w-full">
<Button @click="() => $router.push('/my-tickets')" class="w-full">
View My Tickets

View file

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

View file

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

View file

@ -15,28 +15,6 @@ export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
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>(() => ({
temporal: temporal.value,
@ -49,9 +27,7 @@ export function useActivityFilters() {
function applyFilters(activities: Activity[]): Activity[] {
let result = activities
// Specific date filter (from DatePickerStrip) takes priority over
// temporal. Picking a date also bypasses the past/upcoming split
// so the user can browse activities for any day they choose.
// Specific date filter (from DatePickerStrip) takes priority over temporal
if (selectedDate.value) {
const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value)
@ -62,16 +38,6 @@ export function useActivityFilters() {
} else {
// Temporal filter
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
@ -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
}
@ -122,30 +81,12 @@ export function useActivityFilters() {
temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = []
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(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
selectedDate.value !== undefined ||
onlyOwnedTickets.value ||
onlyHosting.value ||
showPast.value
selectedDate.value !== undefined
)
return {
@ -153,9 +94,6 @@ export function useActivityFilters() {
temporal,
selectedCategories,
selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
filters,
hasActiveFilters,
@ -165,9 +103,6 @@ export function useActivityFilters() {
selectDate,
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters,
}
}

View file

@ -1,8 +1,7 @@
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 { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
/**
* 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.
*/
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 newCoords = new Set(state.value.bookmarkedCoords)
@ -111,13 +110,8 @@ export function useBookmarks() {
tags,
}
let signedEvent: NostrEvent
try {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useBookmarks] signEventViaLnbits failed:', err)
return
}
const signingKey = hexToUint8Array(currentUser.value.prvkey)
const signedEvent = finalizeEvent(template, signingKey)
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
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 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 { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
/**
@ -151,7 +150,7 @@ export function useRSVP() {
activityDTag: string,
status: RSVPStatus
): Promise<RSVPStatus | null> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
if (!isAuthenticated.value || !currentUser.value?.prvkey) return null
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
@ -185,13 +184,8 @@ export function useRSVP() {
],
}
let signedEvent: NostrEvent
try {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useRSVP] signEventViaLnbits failed:', err)
return null
}
const signingKey = hexToUint8Array(currentUser.value.prvkey)
const signedEvent = finalizeEvent(template, signingKey)
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
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 isPaymentPending = ref(false)
// Ticket QR code state. After payment lands, `purchasedTicketIds`
// 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.
// Ticket QR code state
const ticketQRCode = ref<string | null>(null)
const ticketQRCodes = ref<Record<string, string>>({})
const purchasedTicketId = ref<string | null>(null)
const purchasedTicketIds = ref<string[]>([])
const showTicketQR = ref(false)
// Computed properties
@ -82,15 +75,7 @@ export function useTicketPurchase() {
}
}
/** The event id this composable is currently driving kept so
* `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 } = {},
) {
async function purchaseTicketForEvent(eventId: string) {
if (!canPurchase.value || !currentUser.value) {
throw new Error('User must be authenticated to purchase tickets')
}
@ -101,11 +86,8 @@ export function useTicketPurchase() {
paymentRequest.value = null
qrCode.value = null
ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false
currentEventId.value = eventId
// Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
@ -114,36 +96,26 @@ export function useTicketPurchase() {
const invoice = await ticketApi.requestTicket(
eventId,
currentUser.value!.id,
accessToken,
{ quantity: options.quantity },
accessToken
)
// 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
paymentRequest.value = bolt11
paymentRequest.value = invoice.paymentRequest
// Generate QR code for payment
await generateQRCode(bolt11)
await generateQRCode(invoice.paymentRequest)
// Restaurant-style: don't auto-pay. Surface the QR + amount and
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
// external wallet" on the same screen. The composable just
// starts polling so when payment lands (from any path) the UI
// advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash)
// Try to pay with wallet if available
if (hasWalletWithBalance.value) {
try {
await payWithWallet(invoice.paymentRequest)
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
}, {
@ -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) {
isPaymentPending.value = true
let checkInterval: number | null = null
@ -178,34 +137,13 @@ export function useTicketPurchase() {
clearInterval(checkInterval)
}
// Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include
// `ticketId` only. Render one QR per row so each attendee
// 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
if (result.ticketId) {
purchasedTicketId.value = result.ticketId
await generateTicketQRCode(result.ticketId)
showTicketQR.value = true
}
toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
toast.success('Ticket purchased successfully!')
}
} catch (err) {
console.error('Error checking payment status:', err)
@ -227,9 +165,7 @@ export function useTicketPurchase() {
qrCode.value = null
isPaymentPending.value = false
ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false
}
@ -257,9 +193,7 @@ export function useTicketPurchase() {
isPaymentPending,
isPayingWithWallet,
ticketQRCode,
ticketQRCodes,
purchasedTicketId,
purchasedTicketIds,
showTicketQR,
// Computed
@ -270,7 +204,6 @@ export function useTicketPurchase() {
// Actions
purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet,
resetPaymentState,
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)
})
const groupedTickets = computed(() => {
const groups = new Map<string, GroupedTickets>()

View file

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

View file

@ -1,10 +1,12 @@
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 {
NIP52_KINDS,
parseCalendarTimeEvent,
parseCalendarDateEvent,
buildCalendarTimeEventTags,
type CalendarTimeEvent,
} from '../types/nip52'
import {
calendarTimeEventToActivity,
@ -23,20 +25,10 @@ export interface CalendarEventFilters {
hashtags?: string[]
/** Filter by geohash prefix (NIP-52 'g' tag) */
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.
*
* 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.
*
* Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub.
* Extends BaseService for standardized dependency injection and lifecycle.
*/
export class ActivitiesNostrService extends BaseService {
@ -113,6 +105,32 @@ export class ActivitiesNostrService extends BaseService {
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.
*/
@ -150,7 +168,6 @@ export class ActivitiesNostrService extends BaseService {
if (filters?.authors?.length) filter.authors = filters.authors
if (filters?.hashtags?.length) filter['#t'] = filters.hashtags
if (filters?.geohash) filter['#g'] = [filters.geohash]
if (filters?.dTags?.length) filter['#d'] = filters.dTags
return [filter]
}
@ -162,3 +179,11 @@ export class ActivitiesNostrService extends BaseService {
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 {
ActivityTicket,
ActivityTicketExtra,
CreateTicketRequest,
PaymentMethod,
TicketPurchaseInvoice,
TicketPaymentStatus,
TicketedEvent,
@ -52,41 +49,14 @@ export class TicketApiService {
}
/**
* Request a ticket purchase. Returns either a Lightning invoice
* (`paymentRequest` = bolt11) or a fiat invoice (`fiatPaymentRequest`
* = 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`.
* Request a ticket purchase (creates a Lightning invoice).
* Uses POST /tickets/{event_id} with user_id in body (upstream API).
*/
async requestTicket(
eventId: string,
userId: 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
} = {},
accessToken: string
): 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(
`/events/api/v1/tickets/${eventId}`,
{
@ -95,16 +65,13 @@ export class TicketApiService {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
body: JSON.stringify({ user_id: userId }),
}
)
return {
paymentHash: data.payment_hash,
paymentRequest: data.payment_request ?? undefined,
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat),
paymentRequest: data.payment_request,
}
}
@ -123,7 +90,6 @@ export class TicketApiService {
return {
paid: data.paid === true,
ticketId: data.ticket_id,
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
}
}
@ -155,7 +121,6 @@ export class TicketApiService {
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}))
}
@ -179,7 +144,6 @@ export class TicketApiService {
paid: t.paid,
time: t.time,
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
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",

View file

@ -1,6 +1,6 @@
import ngeohash from 'ngeohash'
import type { ActivityCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
import type { TicketedEvent } from './ticket'
/**
@ -74,26 +74,8 @@ export interface OrganizerInfo {
export interface ActivityTicketInfo {
price: number
currency: string
/** Remaining capacity. Undefined means unlimited. */
available?: 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,
}
available: number
total: number
}
/**
@ -122,7 +104,6 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
geohash: event.geohash,
category,
tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false,
createdAt: new Date(event.createdAt * 1000),
}
@ -159,7 +140,6 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
geohash: event.geohash,
category,
tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false,
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]
/**
* 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)
*/
@ -57,7 +36,6 @@ export interface CalendarDateEvent {
references: string[]
id: string
createdAt: number
ticket?: TicketTags
}
/**
@ -81,7 +59,6 @@ export interface CalendarTimeEvent {
references: string[]
id: string
createdAt: number
ticket?: TicketTags
}
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])
}
/**
* 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.
* 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'),
id: event.id,
createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
}
}
@ -266,7 +213,6 @@ export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | n
references: getTagValues(event.tags, 'r'),
id: event.id,
createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
}
}

View file

@ -1,44 +1,7 @@
/**
* 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.
* Database-backed ticket types (via LNbits events extension)
*/
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 {
id: string
wallet: string
@ -58,51 +21,24 @@ export interface ActivityTicket {
time: string
/** Registration/scan timestamp */
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 PaymentMethod = 'lightning' | 'fiat'
export interface TicketPurchaseRequest {
activityId: string
userId: 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 {
paymentHash: string
paymentRequest?: string
fiatPaymentRequest?: string
fiatProvider?: string
isFiat: boolean
paymentRequest: string
}
export interface TicketPaymentStatus {
paid: boolean
/** First ticket id created on this invoice. Back-compat with
* single-ticket purchases equals the payment_hash. */
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_end_date: string | null
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
price_per_ticket: number
time: string
@ -133,7 +65,6 @@ export interface TicketedEvent {
banner: string | null
location: string | null
categories: string[]
extra: EventExtra
status: string
}
@ -145,36 +76,9 @@ export interface CreateEventRequest {
event_start_date: string
event_end_date?: string
currency?: string
allow_fiat?: boolean
fiat_currency?: string
amount_tickets?: number
price_per_ticket?: number
banner?: string | null
location?: string | null
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,
CollapsibleTrigger,
} 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 { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
@ -29,22 +28,14 @@ const {
selectedCategories,
hasActiveFilters,
selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
selectDate,
setTemporal,
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters,
subscribe,
} = useActivities()
const { isAuthenticated } = useAuth()
const filtersOpen = ref(false)
onMounted(() => {
@ -83,43 +74,6 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</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) -->
<Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child>

View file

@ -8,18 +8,15 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
Calendar, MapPin, ArrowLeft, Pencil,
} from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService'
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 type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket'
@ -64,10 +61,6 @@ function openEditDialog() {
activitiesStore.showCreateDialog = true
}
function openScannerPage() {
router.push({ name: 'scan-tickets', params: { activityId } })
}
const dateDisplay = computed(() => {
if (!activity.value) return ''
const a = activity.value
@ -101,71 +94,6 @@ const categoryLabel = computed(() => {
function goBack() {
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>
<template>
@ -177,17 +105,6 @@ function goToMyTickets() {
Back
</Button>
<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
v-if="ownedLnbitsEvent"
variant="ghost"
@ -302,79 +219,6 @@ function goToMyTickets() {
: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 -->
<div class="bg-muted/50 rounded-lg p-4">
<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
price_per_ticket: number
currency: string
allow_fiat?: boolean
fiat_currency?: string
} | null>(null)
const showEventDialog = ref(false)
@ -58,8 +56,6 @@ function handlePurchaseClick(event: {
name: string
price_per_ticket: number
currency: string
allow_fiat?: boolean
fiat_currency?: string
}) {
if (!isAuthenticated.value) return
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 { BaseService } from '@/core/base/BaseService'
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 { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits'
export class AuthService extends BaseService {
@ -112,6 +114,9 @@ export class AuthService extends BaseService {
eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on login
this.broadcastNostrMetadata()
} catch (error) {
const err = this.handleError(error, 'login')
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')
// Auto-broadcast Nostr metadata on registration
this.broadcastNostrMetadata()
} catch (error) {
const err = this.handleError(error, 'register')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -180,14 +188,18 @@ export class AuthService extends BaseService {
this.isLoading.value = true
const updatedUser = await this.lnbitsAPI.updateProfile(data)
// Preserve pubkey from existing user since /auth/update doesn't return it.
// Kind-0 metadata is published server-side by lnbits's PATCH /auth handler
// (aiolabs/lnbits commit 869f67c3); no webapp-side broadcast path remains.
// Preserve prvkey and pubkey from existing user since /auth/update doesn't return them
this.user.value = {
...updatedUser,
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) {
const err = this.handleError(error, 'updateProfile')
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
*/

View file

@ -122,17 +122,32 @@
</div>
<!-- Action Buttons -->
<Button
type="submit"
:disabled="isUpdating || !isFormValid"
class="w-full"
>
<span v-if="isUpdating">Updating...</span>
<span v-else>Update Profile</span>
</Button>
<div class="flex flex-col sm:flex-row gap-3">
<Button
type="submit"
:disabled="isUpdating || !isFormValid"
class="flex-1"
>
<span v-if="isUpdating">Updating...</span>
<span v-else>Update Profile</span>
</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">
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>
</form>
@ -174,7 +189,7 @@ import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { User, Zap, Hash } from 'lucide-vue-next'
import { User, Zap, Hash, Radio } from 'lucide-vue-next'
import {
FormControl,
FormDescription,
@ -200,16 +215,19 @@ import { useAuth } from '@/composables/useAuthService'
import { useRouter } from 'vue-router'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { useToast } from '@/core/composables/useToast'
// Services
const { user, updateProfile, logout } = useAuth()
const router = useRouter()
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
const toast = useToast()
// Local state
const isUpdating = ref(false)
const isBroadcasting = ref(false)
const updateError = ref<string | null>(null)
const updateSuccess = ref(false)
const uploadedPicture = ref<any[]>([])
@ -305,12 +323,18 @@ const updateUserProfile = async (formData: any) => {
}
}
// 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).
// Update profile via AuthService (which updates LNbits)
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
// 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.
const onLogout = async () => {
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 { container, SERVICE_TOKENS } from '@/core/di-container'
import { relayHub } from './nostr/relay-hub'
import { NostrMetadataService } from './nostr/nostr-metadata-service'
import { ProfileService } from './nostr/ProfileService'
import { ReactionService } from './nostr/ReactionService'
import { NostrTransportService } from './services/NostrTransportService'
// Import auth services
import { auth } from './auth/auth-service'
@ -29,9 +29,9 @@ import ProfileSettings from './components/ProfileSettings.vue'
const invoiceService = new InvoiceService()
const lnbitsAPI = new LnbitsAPI()
const imageUploadService = new ImageUploadService()
const nostrMetadataService = new NostrMetadataService()
const profileService = new ProfileService()
const reactionService = new ReactionService()
const nostrTransportService = new NostrTransportService()
/**
* Base Module Plugin
@ -46,6 +46,7 @@ export const baseModule: ModulePlugin = {
// Register core Nostr services
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
container.provide(SERVICE_TOKENS.NOSTR_METADATA_SERVICE, nostrMetadataService)
// Register auth service
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
@ -74,7 +75,6 @@ export const baseModule: ModulePlugin = {
// Register shared Nostr services (used by multiple modules)
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
container.provide(SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, nostrTransportService)
// Register PWA service
container.provide('pwaService', pwaService)
@ -110,6 +110,10 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ImageUploadService depends on ToastService
maxRetries: 3
})
await nostrMetadataService.initialize({
waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub
maxRetries: 3
})
await profileService.initialize({
waitForDependencies: true, // ProfileService depends on RelayHub
maxRetries: 3
@ -118,10 +122,6 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
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
console.log('✅ Base module installed successfully')
@ -138,9 +138,9 @@ export const baseModule: ModulePlugin = {
await storageService.dispose()
await toastService.dispose()
await imageUploadService.dispose()
await nostrMetadataService.dispose()
await profileService.dispose()
await reactionService.dispose()
await nostrTransportService.dispose()
// InvoiceService doesn't need disposal as it's not a BaseService
await lnbitsAPI.dispose()
@ -148,6 +148,7 @@ export const baseModule: ModulePlugin = {
container.remove(SERVICE_TOKENS.LNBITS_API)
container.remove(SERVICE_TOKENS.INVOICE_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.REACTION_SERVICE)
@ -164,6 +165,7 @@ export const baseModule: ModulePlugin = {
invoiceService,
pwaService,
imageUploadService,
nostrMetadataService,
profileService,
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 type { SubscribeManyParams, SubCloser } from 'nostr-tools/abstract-pool'
import { BaseService } from '@/core/base/BaseService'
import { ref } from 'vue'
@ -439,7 +438,7 @@ export class RelayHub extends BaseService {
}
// Recreate the subscription
const subscription = this.poolSubscribe(availableRelays, config.filters, {
const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
onevent: (event: Event) => {
config.onEvent?.(event)
this.emit('event', { subscriptionId: id, event, relay: 'unknown' })
@ -483,7 +482,7 @@ export class RelayHub extends BaseService {
// Create subscription using the pool
const subscription = this.poolSubscribe(availableRelays, config.filters, {
const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
onevent: (event: Event) => {
config.onEvent?.(event)
this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' })
@ -551,24 +550,6 @@ export class RelayHub extends BaseService {
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)
*/

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,
AccountNode,
UserInfo,
AccountPermission,
GrantPermissionRequest,
TransactionListResponse
} from '../types'
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
*

View file

@ -28,6 +28,25 @@ export interface Account {
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
*/
@ -106,6 +125,31 @@ export interface UserInfo {
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)
*/

View file

@ -12,6 +12,10 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
CheckCircle2,
Clock,
Flag,
XCircle,
RefreshCw,
Calendar,
Filter
@ -26,26 +30,14 @@ const isLoading = ref(false)
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
const customStartDate = ref<string>('')
const customEndDate = ref<string>('')
// Each chip is an inclusion toggle for one bucket of rows. Every row
// 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 typeFilter = ref<'all' | 'income' | 'expense'>('all')
const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
const categoryChips: { label: string; value: Category }[] = [
{ label: 'Income', value: 'income' },
{ label: 'Expenses', value: 'expense' },
{ label: 'Voided', value: 'voided' }
const typeFilterOptions = [
{ label: 'All', value: 'all' as const },
{ label: 'Income', value: 'income' as const },
{ label: 'Expenses', value: 'expense' as const }
]
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 {
return t.tags?.includes('income-entry') ?? false
}
@ -54,22 +46,6 @@ function isExpense(t: Transaction): boolean {
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)
// Fuzzy search state and configuration
@ -95,13 +71,12 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
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 base = searchResults.value.length > 0 ? searchResults.value : transactions.value
return base.filter(t => {
const bucket = getBucket(t)
return bucket !== null && activeCategories.value.has(bucket)
})
if (typeFilter.value === 'income') return base.filter(isIncome)
if (typeFilter.value === 'expense') return base.filter(isExpense)
return base
})
// Handle search results
@ -133,28 +108,20 @@ function formatAmount(amount: number): string {
return new Intl.NumberFormat('en-US').format(amount)
}
// Income gets a leading '+', expense a leading '-'.
function getAmountSign(t: Transaction): string {
if (isIncome(t)) return '+'
if (isExpense(t)) return '-'
return ''
}
// 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))
// Get status icon and color based on flag
function getStatusInfo(flag?: string) {
switch (flag) {
case '*':
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
}
}
// Load transactions
@ -257,20 +224,19 @@ onMounted(() => {
</Button>
</div>
<!-- Category chips: each chip toggles inclusion of one bucket
of rows. Defaults: Income + Expenses on, Voided off. -->
<!-- Type Filter (All / Income / Expenses) -->
<div class="flex items-center gap-2 flex-wrap">
<Filter class="h-4 w-4 text-muted-foreground" />
<Button
v-for="chip in categoryChips"
:key="chip.value"
:variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
v-for="option in typeFilterOptions"
:key="option.value"
:variant="typeFilter === option.value ? 'default' : 'outline'"
size="sm"
class="h-7 md:h-8 px-3 text-xs"
@click="toggleCategory(chip.value)"
@click="typeFilter = option.value"
:disabled="isLoading"
>
{{ chip.label }}
{{ option.label }}
</Button>
</div>
@ -325,7 +291,7 @@ onMounted(() => {
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span>
<span v-else>
{{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
</span>
</div>
@ -341,9 +307,7 @@ onMounted(() => {
<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-sm text-muted-foreground mt-2">
<template v-if="searchResults.length > 0">Try a different search term</template>
<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>
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
</p>
</div>
@ -352,19 +316,29 @@ onMounted(() => {
<div
v-for="transaction in transactionsToDisplay"
: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 -->
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0">
<h3
:class="[
'font-medium text-sm sm:text-base truncate mb-1',
isVoided(transaction) && 'line-through text-muted-foreground'
]"
>
{{ transaction.description }}
</h3>
<div class="flex items-center gap-2 mb-1">
<!-- Status Icon -->
<component
v-if="getStatusInfo(transaction.flag)"
:is="getStatusInfo(transaction.flag)!.icon"
:class="[
'h-4 w-4 flex-shrink-0',
getStatusInfo(transaction.flag)!.color
]"
/>
<h3 class="font-medium text-sm sm:text-base truncate">
{{ transaction.description }}
</h3>
</div>
<p class="text-xs sm:text-sm text-muted-foreground">
{{ formatDate(transaction.date) }}
</p>
@ -372,17 +346,11 @@ onMounted(() => {
<!-- Amount -->
<div class="text-right flex-shrink-0">
<p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
{{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
<p class="font-semibold text-sm sm:text-base">
{{ formatAmount(transaction.amount) }} sats
</p>
<p
v-if="transaction.fiat_amount"
:class="[
'text-xs',
getAmountColorClass(transaction) || 'text-muted-foreground'
]"
>
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
</p>
</div>
</div>
@ -399,42 +367,17 @@ onMounted(() => {
<span class="font-medium">Ref:</span> {{ transaction.reference }}
</div>
<!-- Badges: type (Income / Expense) + status (Voided / Pending,
mutually exclusive) + any user-added tags. -->
<div class="flex flex-wrap gap-1 mt-2">
<!-- Tags -->
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
<Badge
v-if="isIncome(transaction)"
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)"
v-for="tag in transaction.tags"
:key="tag"
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 }}
</Badge>

View file

@ -2,6 +2,8 @@ import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
import { useMarketStore } from '../stores/market'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { config } from '@/lib/config'
import type { NostrmarketService } from '../services/nostrmarketService'
import { nip59 } from 'nostr-tools'
import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { auth } from '@/composables/useAuthService'
@ -42,6 +44,7 @@ export function useMarket() {
const marketStore = useMarketStore()
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
const nostrmarketService = injectService(SERVICE_TOKENS.NOSTRMARKET_SERVICE) as NostrmarketService
if (!relayHub) {
throw new Error('RelayHub not available. Make sure base module is installed.')
@ -430,24 +433,56 @@ export function useMarket() {
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.
//
// **Disabled in phase 1/2 per design-questions Q4.2 / Bucket C.** NIP-59
// gift-wrap unwrap requires a raw user prvkey for NIP-44 v2 decryption;
// post-aiolabs/lnbits#9 the webapp doesn't hold one and lnbits doesn't
// 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.
// The outer event's pubkey is an ephemeral key (NIP-59); the real merchant
// pubkey is on the unwrapped rumor. Content is JSON with a `type` field
// (1 = payment request, 2 = order status update).
const handleOrderDM = async (event: any) => {
console.warn(
'[useMarket] Skipping order gift wrap (kind 1059) unwrap — phase 1/2 ' +
'has no prvkey access for NIP-44 decryption. Event id:',
event?.id,
)
try {
console.log('🎁 Received order gift wrap:', event.id, '(kind', event.kind + ')')
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

View file

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

View file

@ -17,7 +17,7 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
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 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 { 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 type { Event as NostrEvent, Filter } from 'nostr-tools'
@ -47,7 +47,7 @@ export class FeedService extends BaseService {
protected relayHub: any = null
protected visibilityService: any = null
protected reactionService: any = null
protected taskService: any = null
protected scheduledEventService: any = null
// Event ID tracking for deduplication
private seenEventIds = new Set<string>()
@ -73,12 +73,13 @@ export class FeedService extends BaseService {
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_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: VisibilityService injected:', !!this.visibilityService)
console.log('FeedService: ReactionService injected:', !!this.reactionService)
console.log('FeedService: TaskService injected:', !!this.taskService)
console.log('FeedService: TaskService injected:', !!this.scheduledEventService)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
@ -260,19 +261,28 @@ export class FeedService extends BaseService {
// Route reaction events (kind 7) to ReactionService
if (event.kind === 7) {
this.reactionService.handleReactionEvent(event)
if (this.reactionService) {
this.reactionService.handleReactionEvent(event)
}
return
}
// Route scheduled events (kind 31922) to TaskService
// Route scheduled events (kind 31922) to ScheduledEventService
if (event.kind === 31922) {
this.taskService.handleScheduledEvent(event)
if (this.scheduledEventService) {
this.scheduledEventService.handleScheduledEvent(event)
}
return
}
// Route RSVP/completion events (kind 31925) to TaskService
// Route RSVP/completion events (kind 31925) to ScheduledEventService
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
}
@ -368,19 +378,31 @@ export class FeedService extends BaseService {
// Route to ReactionService for reaction deletions (kind 7)
if (deletedKind === '7') {
this.reactionService.handleDeletionEvent(event)
if (this.reactionService) {
this.reactionService.handleDeletionEvent(event)
}
return
}
// Route to TaskService for completion/RSVP deletions (kind 31925)
// Route to ScheduledEventService for completion/RSVP deletions (kind 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
}
// Route to TaskService for scheduled event deletions (kind 31922)
// Route to ScheduledEventService for scheduled event deletions (kind 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
}
@ -601,8 +623,16 @@ export class FeedService extends BaseService {
* Get like count for a post from ReactionService
*/
private getLikeCount(postId: string): number {
const reactions = this.reactionService.getEventReactions(postId)
return reactions?.likes || 0
try {
if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') {
const reactions = this.reactionService.getEventReactions(postId)
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
}
/**

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()
}
}