Compare commits
No commits in common. "1f20d5f00ce6964535102f9b64843d6b1c2a7304" and "2bc0b9d57bb196ed97d15579e04278ab12f58411" have entirely different histories.
1f20d5f00c
...
2bc0b9d57b
73 changed files with 3439 additions and 3923 deletions
|
|
@ -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
|
||||
|
|
|
|||
84
CLAUDE.md
84
CLAUDE.md
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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
86
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
64
src/assets/index.css.gruv
Normal 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 */ */
|
||||
/* } */
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 true→false 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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 calendar→Activity 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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
}
|
||||
39
src/modules/base/composables/useNostrMetadata.ts
Normal file
39
src/modules/base/composables/useNostrMetadata.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
162
src/modules/base/nostr/nostr-metadata-service.ts
Normal file
162
src/modules/base/nostr/nostr-metadata-service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
)
|
||||
}
|
||||
}
|
||||
256
src/modules/expenses/components/admin/GrantPermissionDialog.vue
Normal file
256
src/modules/expenses/components/admin/GrantPermissionDialog.vue
Normal 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>
|
||||
399
src/modules/expenses/components/admin/PermissionManager.vue
Normal file
399
src/modules/expenses/components/admin/PermissionManager.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
102
src/modules/nostr-feed/composables/useReactions.ts
Normal file
102
src/modules/nostr-feed/composables/useReactions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
261
src/modules/nostr-feed/composables/useScheduledEvents.ts
Normal file
261
src/modules/nostr-feed/composables/useScheduledEvents.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
585
src/modules/nostr-feed/services/ReactionService.ts
Normal file
585
src/modules/nostr-feed/services/ReactionService.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
678
src/modules/nostr-feed/services/ScheduledEventService.ts
Normal file
678
src/modules/nostr-feed/services/ScheduledEventService.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue