Compare commits
62 commits
2bc0b9d57b
...
1f20d5f00c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f20d5f00c | |||
| 75dfd8a541 | |||
| 4af220adda | |||
| 1fbf7b3d26 | |||
| e9195978c1 | |||
| 4c704e5a41 | |||
| ce2488941f | |||
| 53af36ad01 | |||
| ca3ad434d3 | |||
| b8910868cd | |||
| 52c03328b4 | |||
| ebd8cef8cd | |||
| 9048248353 | |||
| ce4ee80359 | |||
| 386273baab | |||
| c07de62af1 | |||
| 9a300c1679 | |||
| 05bbe68682 | |||
| 9bef2d58ac | |||
| bc565ebf4b | |||
| cb6e1351fb | |||
| 261eded316 | |||
| 414b79565c | |||
| 114d2837c9 | |||
| 221c927c74 | |||
| e2a1f024e4 | |||
| 99ca0bf64a | |||
| 464ee642de | |||
| aee29f1ad5 | |||
| f92d4090dd | |||
| aa2e573f0e | |||
| f6c15beb81 | |||
| b4baad0d82 | |||
| 815bc2d15f | |||
| 2498fbe518 | |||
| 5ebf0582e0 | |||
| 0f8f98d4c5 | |||
| 02c1be0ba7 | |||
| f3c8b1cf95 | |||
| 7e3ecf81db | |||
| 218ff30983 | |||
| da8de0a219 | |||
| 493a12a86b | |||
| c6d3e5cb26 | |||
| 40edba8a8d | |||
| 75306eaae8 | |||
| 794b63e699 | |||
| 722bc21f4d | |||
| 5ed0d6da9e | |||
| a59712327f | |||
| 6a35e8e0cb | |||
| 9f38611f4f | |||
| a4200749ae | |||
| d6efbd2c65 | |||
| 574c178d89 | |||
| 985c10939d | |||
| caec8eddcc | |||
| ec0dbf727b | |||
| 73aee75b5b | |||
| 6cd420d9cb | |||
| 8c09fbdc18 | |||
| cf1740d025 |
73 changed files with 3923 additions and 3439 deletions
|
|
@ -11,6 +11,13 @@ VITE_API_KEY=your-api-key-here
|
||||||
VITE_LNBITS_DEBUG=false
|
VITE_LNBITS_DEBUG=false
|
||||||
VITE_WEBSOCKET_ENABLED=true
|
VITE_WEBSOCKET_ENABLED=true
|
||||||
|
|
||||||
|
# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint).
|
||||||
|
# Logged by the LNbits server at startup:
|
||||||
|
# `Nostr transport: starting with pubkey <hex>... on N relay(s)`
|
||||||
|
# Required for the activities ticket scanner; legacy HTTP path still
|
||||||
|
# works without it.
|
||||||
|
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY=
|
||||||
|
|
||||||
# Lightning Address Domain (optional)
|
# Lightning Address Domain (optional)
|
||||||
# Override the domain used for Lightning Addresses
|
# Override the domain used for Lightning Addresses
|
||||||
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL
|
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL
|
||||||
|
|
|
||||||
84
CLAUDE.md
84
CLAUDE.md
|
|
@ -714,6 +714,90 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
|
||||||
VITE_WEBSOCKET_ENABLED=true
|
VITE_WEBSOCKET_ENABLED=true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Payment Rails Pattern
|
||||||
|
|
||||||
|
Shared primitives for modules that mix Lightning + fiat (and, future,
|
||||||
|
cash / internal-wallet) payment rails. Activities is the first
|
||||||
|
consumer; restaurant + marketplace will adopt the same primitives as
|
||||||
|
their backends gain fiat support.
|
||||||
|
|
||||||
|
### Vocabulary (canonical — used in code AND UI labels)
|
||||||
|
|
||||||
|
| Term | Meaning | Field |
|
||||||
|
|---|---|---|
|
||||||
|
| **Price currency** | unit the price is quoted in | `currency` |
|
||||||
|
| **Payment method** | the rail used to pay (Lightning / Fiat / Cash / Internal) | `payment_method` |
|
||||||
|
| **Fiat currency** | currency the fiat provider settles in | `fiat_currency` |
|
||||||
|
| **Also accept fiat** | the user-facing label for the `allow_fiat` toggle | `allow_fiat` |
|
||||||
|
|
||||||
|
The bare word `Currency` is **banned** in payment-context UI labels —
|
||||||
|
it always carries a `Price` or `Fiat` qualifier. The literal string
|
||||||
|
`Pay in fiat` is also banned on buyer-side buttons — each fiat rail
|
||||||
|
shows as a button labeled with its provider name (`Stripe`, `PayPal`,
|
||||||
|
`Square`, `SEPA`). Only the degenerate "providers unknown" fallback
|
||||||
|
shows a generic `Card`.
|
||||||
|
|
||||||
|
### Fiat-provider architecture (LNbits today)
|
||||||
|
|
||||||
|
Fiat providers are configured **globally** by the LNbits admin
|
||||||
|
(`lnbits/settings.py`). Each provider has an optional `allowed_users`
|
||||||
|
whitelist; the per-session filtered list is exposed as
|
||||||
|
`User.fiat_providers: string[]` on `GET /api/v1/auth` (which the
|
||||||
|
webapp already reads as `currentUser.fiat_providers`). Both organizer
|
||||||
|
and buyer on the same instance see the same list.
|
||||||
|
|
||||||
|
Per-user provider configuration is a deferred backend feature. Until
|
||||||
|
then, `useFiatProviders` reads the same `currentUser.fiat_providers`
|
||||||
|
for both sides.
|
||||||
|
|
||||||
|
### Shared primitives (live in base module)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/modules/base/
|
||||||
|
├── composables/
|
||||||
|
│ ├── useFiatProviders.ts // providers + hasAnyProvider + refresh + providerMeta()
|
||||||
|
│ └── usePriceConversion.ts // convert() + useLivePreview(); 60s cache; null on failure
|
||||||
|
└── components/payments/
|
||||||
|
├── PaymentMethodSelector.vue // buyer-side rail picker
|
||||||
|
├── FiatToggleField.vue // organizer-side toggle + conditional fiat-currency dropdown
|
||||||
|
└── PriceConversionPreview.vue // muted "≈ X.XX USD" line
|
||||||
|
```
|
||||||
|
|
||||||
|
All three components consume services via DI — never import them
|
||||||
|
directly across module boundaries.
|
||||||
|
|
||||||
|
### `PaymentMethodSelector` data shape
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PaymentRail = 'lightning' | 'fiat' | 'cash' | 'internal' | (string & {})
|
||||||
|
|
||||||
|
type PaymentMethod = {
|
||||||
|
id: string // unique v-for key, e.g. 'fiat:stripe'
|
||||||
|
rail: PaymentRail // sent as payment_method
|
||||||
|
provider?: string // sent as fiat_provider when present
|
||||||
|
label: string // 'Lightning' | 'Stripe' | 'SEPA' | 'Cash' | …
|
||||||
|
icon: Component // lucide icon
|
||||||
|
available: boolean // false ⇒ rendered disabled with tooltip
|
||||||
|
unavailableReason?: string // tooltip when disabled
|
||||||
|
badge?: string // optional secondary hint, e.g. '≈ 1000 sats'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Module usage:
|
||||||
|
- **Activities** passes `[lightning, ...one entry per organizer provider]`.
|
||||||
|
- **Restaurant** (future) passes the subset of
|
||||||
|
`[lightning, cash, internal, ...fiat providers]` enabled by the
|
||||||
|
restaurant's `accepts_*` flags.
|
||||||
|
|
||||||
|
### Adding a new fiat provider
|
||||||
|
|
||||||
|
1. Backend exposes the provider id in `User.fiat_providers`.
|
||||||
|
2. Add the id to `KNOWN_PROVIDERS` in `useFiatProviders.ts` with its
|
||||||
|
display label and icon hint (`'card' | 'bank' | 'wallet'`).
|
||||||
|
3. Unknown ids fall back to a `Capitalized` label with a `'card'`
|
||||||
|
icon hint — no code change required just for the buttons to
|
||||||
|
render, only for nice branding.
|
||||||
|
|
||||||
## Mobile Browser File Input & Form Refresh Issues
|
## Mobile Browser File Input & Form Refresh Issues
|
||||||
|
|
||||||
### **Problem Overview**
|
### **Problem Overview**
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
"light-bolt11-decoder": "^3.2.0",
|
"light-bolt11-decoder": "^3.2.0",
|
||||||
"lucide-vue-next": "^0.474.0",
|
"lucide-vue-next": "^0.474.0",
|
||||||
"ngeohash": "^0.6.3",
|
"ngeohash": "^0.6.3",
|
||||||
"nostr-tools": "2.15.0",
|
"nostr-tools": "^2.23.3",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|
|
||||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
|
|
@ -57,8 +57,8 @@ importers:
|
||||||
specifier: ^0.6.3
|
specifier: ^0.6.3
|
||||||
version: 0.6.3
|
version: 0.6.3
|
||||||
nostr-tools:
|
nostr-tools:
|
||||||
specifier: 2.15.0
|
specifier: ^2.23.3
|
||||||
version: 2.15.0(typescript@5.6.3)
|
version: 2.23.5(typescript@5.6.3)
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^2.3.1
|
specifier: ^2.3.1
|
||||||
version: 2.3.1(typescript@5.6.3)(vue@3.5.34(typescript@5.6.3))
|
version: 2.3.1(typescript@5.6.3)(vue@3.5.34(typescript@5.6.3))
|
||||||
|
|
@ -1247,22 +1247,17 @@ packages:
|
||||||
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
||||||
engines: {node: '>= 12.13.0'}
|
engines: {node: '>= 12.13.0'}
|
||||||
|
|
||||||
'@noble/ciphers@0.5.3':
|
'@noble/ciphers@2.1.1':
|
||||||
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
|
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
'@noble/curves@1.1.0':
|
'@noble/curves@2.0.1':
|
||||||
resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==}
|
resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
'@noble/curves@1.2.0':
|
'@noble/hashes@2.0.1':
|
||||||
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
|
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
'@noble/hashes@1.3.1':
|
|
||||||
resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==}
|
|
||||||
engines: {node: '>= 16'}
|
|
||||||
|
|
||||||
'@noble/hashes@1.3.2':
|
|
||||||
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
|
|
||||||
engines: {node: '>= 16'}
|
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
|
|
@ -1478,11 +1473,14 @@ packages:
|
||||||
'@scure/base@1.1.1':
|
'@scure/base@1.1.1':
|
||||||
resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==}
|
resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==}
|
||||||
|
|
||||||
'@scure/bip32@1.3.1':
|
'@scure/base@2.0.0':
|
||||||
resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==}
|
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
|
||||||
|
|
||||||
'@scure/bip39@1.2.1':
|
'@scure/bip32@2.0.1':
|
||||||
resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==}
|
resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==}
|
||||||
|
|
||||||
|
'@scure/bip39@2.0.1':
|
||||||
|
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0':
|
'@sindresorhus/is@4.6.0':
|
||||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||||
|
|
@ -3535,8 +3533,8 @@ packages:
|
||||||
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
nostr-tools@2.15.0:
|
nostr-tools@2.23.5:
|
||||||
resolution: {integrity: sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==}
|
resolution: {integrity: sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=5.0.0'
|
typescript: '>=5.0.0'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
|
|
@ -6204,19 +6202,13 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
|
|
||||||
'@noble/ciphers@0.5.3': {}
|
'@noble/ciphers@2.1.1': {}
|
||||||
|
|
||||||
'@noble/curves@1.1.0':
|
'@noble/curves@2.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 1.3.1
|
'@noble/hashes': 2.0.1
|
||||||
|
|
||||||
'@noble/curves@1.2.0':
|
'@noble/hashes@2.0.1': {}
|
||||||
dependencies:
|
|
||||||
'@noble/hashes': 1.3.2
|
|
||||||
|
|
||||||
'@noble/hashes@1.3.1': {}
|
|
||||||
|
|
||||||
'@noble/hashes@1.3.2': {}
|
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -6362,16 +6354,18 @@ snapshots:
|
||||||
|
|
||||||
'@scure/base@1.1.1': {}
|
'@scure/base@1.1.1': {}
|
||||||
|
|
||||||
'@scure/bip32@1.3.1':
|
'@scure/base@2.0.0': {}
|
||||||
dependencies:
|
|
||||||
'@noble/curves': 1.1.0
|
|
||||||
'@noble/hashes': 1.3.1
|
|
||||||
'@scure/base': 1.1.1
|
|
||||||
|
|
||||||
'@scure/bip39@1.2.1':
|
'@scure/bip32@2.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 1.3.1
|
'@noble/curves': 2.0.1
|
||||||
'@scure/base': 1.1.1
|
'@noble/hashes': 2.0.1
|
||||||
|
'@scure/base': 2.0.0
|
||||||
|
|
||||||
|
'@scure/bip39@2.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 2.0.1
|
||||||
|
'@scure/base': 2.0.0
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0': {}
|
'@sindresorhus/is@4.6.0': {}
|
||||||
|
|
||||||
|
|
@ -8539,14 +8533,14 @@ snapshots:
|
||||||
|
|
||||||
normalize-url@6.1.0: {}
|
normalize-url@6.1.0: {}
|
||||||
|
|
||||||
nostr-tools@2.15.0(typescript@5.6.3):
|
nostr-tools@2.23.5(typescript@5.6.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/ciphers': 0.5.3
|
'@noble/ciphers': 2.1.1
|
||||||
'@noble/curves': 1.2.0
|
'@noble/curves': 2.0.1
|
||||||
'@noble/hashes': 1.3.1
|
'@noble/hashes': 2.0.1
|
||||||
'@scure/base': 1.1.1
|
'@scure/base': 2.0.0
|
||||||
'@scure/bip32': 1.3.1
|
'@scure/bip32': 2.0.1
|
||||||
'@scure/bip39': 1.2.1
|
'@scure/bip39': 2.0.1
|
||||||
nostr-wasm: 0.1.0
|
nostr-wasm: 0.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.6.3
|
typescript: 5.6.3
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,11 @@ async function loadData() {
|
||||||
totalIncomeSats.value = balanceData.total_income_sats || 0
|
totalIncomeSats.value = balanceData.total_income_sats || 0
|
||||||
totalIncomeFiat.value = balanceData.total_income_fiat || {}
|
totalIncomeFiat.value = balanceData.total_income_fiat || {}
|
||||||
|
|
||||||
// Filter for pending transactions (flag = '!')
|
// Filter for pending transactions (flag = '!'), excluding voided ones
|
||||||
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
|
// (libra convention: voided keeps '!' flag and carries a 'voided' tag).
|
||||||
|
pendingTransactions.value = txData.entries.filter(
|
||||||
|
tx => tx.flag === '!' && !tx.tags?.includes('voided')
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BalancePage] Error loading data:', error)
|
console.error('[BalancePage] Error loading data:', error)
|
||||||
toast.error('Failed to load balance data')
|
toast.error('Failed to load balance data')
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
||||||
import AppShell from '@/components/layout/AppShell.vue'
|
import AppShell from '@/components/layout/AppShell.vue'
|
||||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||||
|
|
@ -15,6 +16,7 @@ import type { CreateEventRequest } from '@/modules/activities/types/ticket'
|
||||||
import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue'
|
import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
const activitiesStore = useActivitiesStore()
|
const activitiesStore = useActivitiesStore()
|
||||||
|
|
@ -25,9 +27,9 @@ const { isAdmin, autoApprove } = useApprovalState()
|
||||||
const { loadOwnEvents } = useActivities()
|
const { loadOwnEvents } = useActivities()
|
||||||
|
|
||||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||||
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
|
// Create lives in the bottom nav: when logged out, tapping it shows an
|
||||||
// act, surfacing it as a tab keeps it one tap away when authed and out of the
|
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
|
||||||
// way when not. Per-app placement deliberation tracked at #53.
|
// opening the dialog. Per-app placement deliberation tracked at #53.
|
||||||
const tabs = computed<BottomTab[]>(() => [
|
const tabs = computed<BottomTab[]>(() => [
|
||||||
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
|
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
|
||||||
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
|
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
|
||||||
|
|
@ -35,6 +37,15 @@ const tabs = computed<BottomTab[]>(() => [
|
||||||
name: t('activities.createNew'),
|
name: t('activities.createNew'),
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toast.info('Log in to create an activity', {
|
||||||
|
action: {
|
||||||
|
label: 'Log in',
|
||||||
|
onClick: () => router.push('/login'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
// Defensively clear any lingering edit selection so the Create
|
// Defensively clear any lingering edit selection so the Create
|
||||||
// tap always opens in Create mode regardless of a prior Edit.
|
// tap always opens in Create mode regardless of a prior Edit.
|
||||||
activitiesStore.editingEvent = null
|
activitiesStore.editingEvent = null
|
||||||
|
|
@ -43,7 +54,27 @@ const tabs = computed<BottomTab[]>(() => [
|
||||||
disabled: !isAuthenticated.value,
|
disabled: !isAuthenticated.value,
|
||||||
},
|
},
|
||||||
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
||||||
{ name: t('activities.nav.favorites'), icon: Heart, path: '/activities/favorites' },
|
{
|
||||||
|
name: t('activities.nav.favorites'),
|
||||||
|
icon: Heart,
|
||||||
|
// path kept so the tab stays active-highlighted while the user is
|
||||||
|
// on /activities/favorites; onClick wins for the actual tap so we
|
||||||
|
// can gate on auth (mirrors the Create tab pattern above).
|
||||||
|
path: '/activities/favorites',
|
||||||
|
onClick: () => {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toast.info(t('activities.favorites.loginPrompt'), {
|
||||||
|
action: {
|
||||||
|
label: t('activities.favorites.logIn'),
|
||||||
|
onClick: () => router.push('/login'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push('/activities/favorites')
|
||||||
|
},
|
||||||
|
disabled: !isAuthenticated.value,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
// Feed tab is active for the bare /activities route AND all sub-paths that
|
// Feed tab is active for the bare /activities route AND all sub-paths that
|
||||||
|
|
|
||||||
|
|
@ -1,179 +1,195 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import './themes/countrysidecastle.css';
|
||||||
|
@import './themes/darkmatter.css';
|
||||||
|
@import './themes/emeraldforest.css';
|
||||||
|
@import './themes/lightgreen.css';
|
||||||
|
@import './themes/neobrut.css';
|
||||||
|
@import './themes/starrynight.css';
|
||||||
|
|
||||||
@plugin 'tailwindcss-animate';
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme inline {
|
||||||
--radius-lg: var(--radius);
|
--color-background: var(--background);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--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-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
--color-background: oklch(var(--background));
|
--shadow-2xs: var(--shadow-2xs);
|
||||||
--color-foreground: oklch(var(--foreground));
|
--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-card: oklch(var(--card));
|
--duration-fast: 150ms;
|
||||||
--color-card-foreground: oklch(var(--card-foreground));
|
--duration-normal: 200ms;
|
||||||
|
--duration-slow: 300ms;
|
||||||
|
|
||||||
--color-popover: oklch(var(--popover));
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
--color-popover-foreground: oklch(var(--popover-foreground));
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--color-primary: oklch(var(--primary));
|
|
||||||
--color-primary-foreground: oklch(var(--primary-foreground));
|
|
||||||
|
|
||||||
--color-secondary: oklch(var(--secondary));
|
|
||||||
--color-secondary-foreground: oklch(var(--secondary-foreground));
|
|
||||||
|
|
||||||
--color-muted: oklch(var(--muted));
|
|
||||||
--color-muted-foreground: oklch(var(--muted-foreground));
|
|
||||||
|
|
||||||
--color-accent: oklch(var(--accent));
|
|
||||||
--color-accent-foreground: oklch(var(--accent-foreground));
|
|
||||||
|
|
||||||
--color-destructive: oklch(var(--destructive));
|
|
||||||
--color-destructive-foreground: oklch(var(--destructive-foreground));
|
|
||||||
|
|
||||||
--color-border: oklch(var(--border));
|
|
||||||
--color-input: oklch(var(--input));
|
|
||||||
--color-ring: oklch(var(--ring));
|
|
||||||
|
|
||||||
--color-chart-1: oklch(var(--chart-1));
|
|
||||||
--color-chart-2: oklch(var(--chart-2));
|
|
||||||
--color-chart-3: oklch(var(--chart-3));
|
|
||||||
--color-chart-4: oklch(var(--chart-4));
|
|
||||||
--color-chart-5: oklch(var(--chart-5));
|
|
||||||
|
|
||||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
|
||||||
@keyframes accordion-down {
|
@keyframes accordion-down {
|
||||||
from {
|
from { height: 0; }
|
||||||
height: 0;
|
to { height: var(--reka-accordion-content-height); }
|
||||||
}
|
|
||||||
to {
|
|
||||||
height: var(--reka-accordion-content-height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@keyframes accordion-up {
|
@keyframes accordion-up {
|
||||||
from {
|
from { height: var(--reka-accordion-content-height); }
|
||||||
height: var(--reka-accordion-content-height);
|
to { height: 0; }
|
||||||
}
|
}
|
||||||
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).
|
||||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
|
||||||
so we've added these compatibility styles to make sure everything still
|
:root {
|
||||||
looks the same as it did with Tailwind CSS v3.
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
If we ever want to remove these styles, we need to add an explicit border
|
|
||||||
color utility to any element that depends on these defaults.
|
|
||||||
*/
|
|
||||||
@layer base {
|
@layer base {
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
::backdrop,
|
::backdrop,
|
||||||
::file-selector-button {
|
::file-selector-button {
|
||||||
border-color: var(--color-gray-200, currentColor);
|
border-color: var(--color-border, 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;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
/* ... 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 */ */
|
|
||||||
/* } */
|
|
||||||
89
src/assets/themes/countrysidecastle.css
Normal file
89
src/assets/themes/countrysidecastle.css
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
: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);
|
||||||
|
}
|
||||||
81
src/assets/themes/darkmatter.css
Normal file
81
src/assets/themes/darkmatter.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
: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);
|
||||||
|
}
|
||||||
89
src/assets/themes/emeraldforest.css
Normal file
89
src/assets/themes/emeraldforest.css
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
: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);
|
||||||
|
}
|
||||||
89
src/assets/themes/lightgreen.css
Normal file
89
src/assets/themes/lightgreen.css
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
: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);
|
||||||
|
}
|
||||||
81
src/assets/themes/neobrut.css
Normal file
81
src/assets/themes/neobrut.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
: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);
|
||||||
|
}
|
||||||
81
src/assets/themes/starrynight.css
Normal file
81
src/assets/themes/starrynight.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
: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 { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { Sun, Moon, Monitor, Globe, Coins } from 'lucide-vue-next'
|
import { Sun, Moon, Monitor, Globe, Coins, Palette } from 'lucide-vue-next'
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import { useTheme } from '@/components/theme-provider'
|
import { useTheme, PALETTES, type Palette as PaletteName } from '@/components/theme-provider'
|
||||||
import { useLocale } from '@/composables/useLocale'
|
import { useLocale } from '@/composables/useLocale'
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
|
|
@ -14,14 +14,14 @@ import {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 'row' = three icon-stacked buttons side-by-side (used by Hub bottom nav).
|
/** 'row' = three icon-stacked buttons side-by-side (used by Hub bottom nav).
|
||||||
* 'list' = three full-width list rows (used inside the profile sheet). */
|
* 'list' = full-width list rows (used inside the profile sheet). */
|
||||||
layout?: 'row' | 'list'
|
layout?: 'row' | 'list'
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), { layout: 'row' })
|
const props = withDefaults(defineProps<Props>(), { layout: 'row' })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { theme, setTheme, currentTheme } = useTheme()
|
const { theme, setTheme, currentTheme, palette, setPalette } = useTheme()
|
||||||
const { currentLocale, locales, setLocale } = useLocale()
|
const { currentLocale, locales, setLocale } = useLocale()
|
||||||
|
|
||||||
const ThemeIcon = computed(() => (currentTheme.value === 'dark' ? Moon : Sun))
|
const ThemeIcon = computed(() => (currentTheme.value === 'dark' ? Moon : Sun))
|
||||||
|
|
@ -29,6 +29,10 @@ const currentLocaleLabel = computed(
|
||||||
() => locales.value.find(l => l.code === currentLocale.value)?.name ?? currentLocale.value
|
() => locales.value.find(l => l.code === currentLocale.value)?.name ?? currentLocale.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const paletteLabel = (p: PaletteName) =>
|
||||||
|
t(`common.nav.palette_${p}`)
|
||||||
|
const currentPaletteLabel = computed(() => paletteLabel(palette.value))
|
||||||
|
|
||||||
// Currency picker is intentionally still a placeholder until #45 lands —
|
// Currency picker is intentionally still a placeholder until #45 lands —
|
||||||
// the row UX is what we're building here, not the underlying preference.
|
// the row UX is what we're building here, not the underlying preference.
|
||||||
function notImplemented() {
|
function notImplemented() {
|
||||||
|
|
@ -114,6 +118,31 @@ function notImplemented() {
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<!-- Color scheme (palette) -->
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<button class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Palette class="w-5 h-5 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t('common.nav.colorScheme') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>{{ currentPaletteLabel }}</span>
|
||||||
|
<ChevronRight class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuLabel>{{ t('common.nav.colorScheme') }}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup :model-value="palette" @update:model-value="(v) => v != null && setPalette(v as PaletteName)">
|
||||||
|
<DropdownMenuRadioItem v-for="p in PALETTES" :key="p" :value="p">
|
||||||
|
{{ paletteLabel(p) }}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
<!-- Language -->
|
<!-- Language -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,31 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
type Theme = 'dark' | 'light' | 'system'
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
export type Palette =
|
||||||
|
| 'catppuccin'
|
||||||
|
| 'countrysidecastle'
|
||||||
|
| 'darkmatter'
|
||||||
|
| 'emeraldforest'
|
||||||
|
| 'lightgreen'
|
||||||
|
| 'neobrut'
|
||||||
|
| 'starrynight'
|
||||||
|
|
||||||
|
export const PALETTES: Palette[] = [
|
||||||
|
'catppuccin',
|
||||||
|
'countrysidecastle',
|
||||||
|
'darkmatter',
|
||||||
|
'emeraldforest',
|
||||||
|
'lightgreen',
|
||||||
|
'neobrut',
|
||||||
|
'starrynight',
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_PALETTE: Palette = 'catppuccin'
|
||||||
|
|
||||||
const useTheme = () => {
|
const useTheme = () => {
|
||||||
const theme = ref<Theme>('dark')
|
const theme = ref<Theme>('dark')
|
||||||
const systemTheme = ref<'dark' | 'light'>('light')
|
const systemTheme = ref<'dark' | 'light'>('light')
|
||||||
|
const palette = ref<Palette>(DEFAULT_PALETTE)
|
||||||
|
|
||||||
const updateSystemTheme = () => {
|
const updateSystemTheme = () => {
|
||||||
systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
|
@ -22,31 +44,56 @@ const useTheme = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyPalette = () => {
|
||||||
|
if (palette.value === DEFAULT_PALETTE) {
|
||||||
|
document.documentElement.removeAttribute('data-theme')
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', palette.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const stored = localStorage.getItem('ui-theme')
|
const stored = localStorage.getItem('ui-theme')
|
||||||
if (stored && (stored === 'dark' || stored === 'light' || stored === 'system')) {
|
if (stored && (stored === 'dark' || stored === 'light' || stored === 'system')) {
|
||||||
theme.value = stored as Theme
|
theme.value = stored as Theme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storedPalette = localStorage.getItem('ui-palette')
|
||||||
|
if (storedPalette && (PALETTES as string[]).includes(storedPalette)) {
|
||||||
|
palette.value = storedPalette as Palette
|
||||||
|
}
|
||||||
|
|
||||||
updateSystemTheme()
|
updateSystemTheme()
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateSystemTheme)
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateSystemTheme)
|
||||||
applyTheme()
|
applyTheme()
|
||||||
|
applyPalette()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(currentTheme, () => {
|
watch(currentTheme, () => {
|
||||||
applyTheme()
|
applyTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(palette, () => {
|
||||||
|
applyPalette()
|
||||||
|
})
|
||||||
|
|
||||||
const setTheme = (newTheme: Theme) => {
|
const setTheme = (newTheme: Theme) => {
|
||||||
theme.value = newTheme
|
theme.value = newTheme
|
||||||
localStorage.setItem('ui-theme', newTheme)
|
localStorage.setItem('ui-theme', newTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setPalette = (newPalette: Palette) => {
|
||||||
|
palette.value = newPalette
|
||||||
|
localStorage.setItem('ui-palette', newPalette)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
systemTheme,
|
systemTheme,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
|
palette,
|
||||||
|
setPalette,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,16 +43,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<!-- Flash toggle (if available) -->
|
<!-- 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. -->
|
||||||
<Button
|
<Button
|
||||||
v-if="flashAvailable"
|
v-if="flashAvailable"
|
||||||
@click="toggleFlash"
|
@click="toggleFlash"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-label="Toggle flashlight"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Flashlight class="w-4 h-4" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Close scanner -->
|
<!-- Close scanner -->
|
||||||
|
|
@ -73,7 +74,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Loader2 } from 'lucide-vue-next'
|
import { Loader2, Flashlight } from 'lucide-vue-next'
|
||||||
import { useQRScanner } from '@/composables/useQRScanner'
|
import { useQRScanner } from '@/composables/useQRScanner'
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
|
|
|
||||||
|
|
@ -139,15 +139,13 @@ export const SERVICE_TOKENS = {
|
||||||
|
|
||||||
// Tasks services
|
// Tasks services
|
||||||
TASK_SERVICE: Symbol('taskService'),
|
TASK_SERVICE: Symbol('taskService'),
|
||||||
/** @deprecated Use TASK_SERVICE instead */
|
|
||||||
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
|
|
||||||
|
|
||||||
// Links services
|
// Links services
|
||||||
SUBMISSION_SERVICE: Symbol('submissionService'),
|
SUBMISSION_SERVICE: Symbol('submissionService'),
|
||||||
LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'),
|
LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'),
|
||||||
|
|
||||||
// Nostr metadata services
|
// Nostr transport (kind-21000 RPC over relays — LNbits backend)
|
||||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),
|
||||||
|
|
||||||
// Activities services (Nostr-native events + ticketing module)
|
// Activities services (Nostr-native events + ticketing module)
|
||||||
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ const messages: LocaleMessages = {
|
||||||
themeLight: 'Light',
|
themeLight: 'Light',
|
||||||
themeDark: 'Dark',
|
themeDark: 'Dark',
|
||||||
themeSystem: 'System',
|
themeSystem: 'System',
|
||||||
|
colorScheme: 'Color scheme',
|
||||||
|
palette_catppuccin: 'Catppuccin',
|
||||||
|
palette_countrysidecastle: 'Countryside Castle',
|
||||||
|
palette_darkmatter: 'Dark Matter',
|
||||||
|
palette_emeraldforest: 'Emerald Forest',
|
||||||
|
palette_lightgreen: 'Light Green',
|
||||||
|
palette_neobrut: 'Neo Brutalist',
|
||||||
|
palette_starrynight: 'Starry Night',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
currency: 'Currency',
|
currency: 'Currency',
|
||||||
currencyComingSoon: 'Currency picker — coming soon',
|
currencyComingSoon: 'Currency picker — coming soon',
|
||||||
|
|
@ -57,6 +65,10 @@ const messages: LocaleMessages = {
|
||||||
tomorrow: 'Tomorrow',
|
tomorrow: 'Tomorrow',
|
||||||
thisWeek: 'This Week',
|
thisWeek: 'This Week',
|
||||||
thisMonth: 'This Month',
|
thisMonth: 'This Month',
|
||||||
|
myTickets: 'My tickets',
|
||||||
|
hosting: 'Hosting',
|
||||||
|
pastEvents: 'Past events',
|
||||||
|
past: 'Past',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -96,7 +108,15 @@ const messages: LocaleMessages = {
|
||||||
when: 'When',
|
when: 'When',
|
||||||
tickets: 'Tickets',
|
tickets: 'Tickets',
|
||||||
ticketsAvailable: '{count} tickets available',
|
ticketsAvailable: '{count} tickets available',
|
||||||
|
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
||||||
|
unlimitedTickets: 'Unlimited tickets',
|
||||||
|
buyTicket: 'Buy ticket',
|
||||||
|
buyAnotherTicket: 'Buy another ticket',
|
||||||
|
viewMyTickets: 'View in My Tickets',
|
||||||
soldOut: 'Sold Out',
|
soldOut: 'Sold Out',
|
||||||
|
pastEvent: 'This event has already happened',
|
||||||
|
loginToBuyTickets: 'Log in to buy tickets',
|
||||||
|
logIn: 'Log in',
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ const messages: LocaleMessages = {
|
||||||
themeLight: 'Claro',
|
themeLight: 'Claro',
|
||||||
themeDark: 'Oscuro',
|
themeDark: 'Oscuro',
|
||||||
themeSystem: 'Sistema',
|
themeSystem: 'Sistema',
|
||||||
|
colorScheme: 'Paleta',
|
||||||
|
palette_catppuccin: 'Catppuccin',
|
||||||
|
palette_countrysidecastle: 'Castillo Campestre',
|
||||||
|
palette_darkmatter: 'Dark Matter',
|
||||||
|
palette_emeraldforest: 'Bosque Esmeralda',
|
||||||
|
palette_lightgreen: 'Verde Claro',
|
||||||
|
palette_neobrut: 'Neo Brutalista',
|
||||||
|
palette_starrynight: 'Noche Estrellada',
|
||||||
language: 'Idioma',
|
language: 'Idioma',
|
||||||
currency: 'Moneda',
|
currency: 'Moneda',
|
||||||
currencyComingSoon: 'Selector de moneda — próximamente',
|
currencyComingSoon: 'Selector de moneda — próximamente',
|
||||||
|
|
@ -57,6 +65,10 @@ const messages: LocaleMessages = {
|
||||||
tomorrow: 'Mañana',
|
tomorrow: 'Mañana',
|
||||||
thisWeek: 'Esta semana',
|
thisWeek: 'Esta semana',
|
||||||
thisMonth: 'Este mes',
|
thisMonth: 'Este mes',
|
||||||
|
myTickets: 'Mis boletos',
|
||||||
|
hosting: 'Organizo',
|
||||||
|
pastEvents: 'Eventos pasados',
|
||||||
|
past: 'Pasado',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concierto',
|
concert: 'Concierto',
|
||||||
|
|
@ -96,7 +108,15 @@ const messages: LocaleMessages = {
|
||||||
when: 'Cuándo',
|
when: 'Cuándo',
|
||||||
tickets: 'Boletos',
|
tickets: 'Boletos',
|
||||||
ticketsAvailable: '{count} boletos disponibles',
|
ticketsAvailable: '{count} boletos disponibles',
|
||||||
|
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
||||||
|
unlimitedTickets: 'Boletos ilimitados',
|
||||||
|
buyTicket: 'Comprar boleto',
|
||||||
|
buyAnotherTicket: 'Comprar otro boleto',
|
||||||
|
viewMyTickets: 'Ver en Mis boletos',
|
||||||
soldOut: 'Agotado',
|
soldOut: 'Agotado',
|
||||||
|
pastEvent: 'Este evento ya pasó',
|
||||||
|
loginToBuyTickets: 'Inicia sesión para comprar boletos',
|
||||||
|
logIn: 'Iniciar sesión',
|
||||||
free: 'Gratis',
|
free: 'Gratis',
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ const messages: LocaleMessages = {
|
||||||
themeLight: 'Clair',
|
themeLight: 'Clair',
|
||||||
themeDark: 'Sombre',
|
themeDark: 'Sombre',
|
||||||
themeSystem: 'Système',
|
themeSystem: 'Système',
|
||||||
|
colorScheme: 'Palette',
|
||||||
|
palette_catppuccin: 'Catppuccin',
|
||||||
|
palette_countrysidecastle: 'Château Champêtre',
|
||||||
|
palette_darkmatter: 'Dark Matter',
|
||||||
|
palette_emeraldforest: 'Forêt d\'Émeraude',
|
||||||
|
palette_lightgreen: 'Vert Clair',
|
||||||
|
palette_neobrut: 'Néo-Brutaliste',
|
||||||
|
palette_starrynight: 'Nuit Étoilée',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
currency: 'Devise',
|
currency: 'Devise',
|
||||||
currencyComingSoon: 'Sélecteur de devise — bientôt disponible',
|
currencyComingSoon: 'Sélecteur de devise — bientôt disponible',
|
||||||
|
|
@ -57,6 +65,10 @@ const messages: LocaleMessages = {
|
||||||
tomorrow: 'Demain',
|
tomorrow: 'Demain',
|
||||||
thisWeek: 'Cette semaine',
|
thisWeek: 'Cette semaine',
|
||||||
thisMonth: 'Ce mois-ci',
|
thisMonth: 'Ce mois-ci',
|
||||||
|
myTickets: 'Mes billets',
|
||||||
|
hosting: 'J\'organise',
|
||||||
|
pastEvents: 'Événements passés',
|
||||||
|
past: 'Passé',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -96,7 +108,15 @@ const messages: LocaleMessages = {
|
||||||
when: 'Quand',
|
when: 'Quand',
|
||||||
tickets: 'Billets',
|
tickets: 'Billets',
|
||||||
ticketsAvailable: '{count} billets disponibles',
|
ticketsAvailable: '{count} billets disponibles',
|
||||||
|
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
||||||
|
unlimitedTickets: 'Billets illimités',
|
||||||
|
buyTicket: 'Acheter un billet',
|
||||||
|
buyAnotherTicket: 'Acheter un autre billet',
|
||||||
|
viewMyTickets: 'Voir dans Mes billets',
|
||||||
soldOut: 'Épuisé',
|
soldOut: 'Épuisé',
|
||||||
|
pastEvent: 'Cet événement est déjà passé',
|
||||||
|
loginToBuyTickets: 'Connectez-vous pour acheter des billets',
|
||||||
|
logIn: 'Connexion',
|
||||||
free: 'Gratuit',
|
free: 'Gratuit',
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ export interface LocaleMessages {
|
||||||
themeLight: string
|
themeLight: string
|
||||||
themeDark: string
|
themeDark: string
|
||||||
themeSystem: string
|
themeSystem: string
|
||||||
|
colorScheme: string
|
||||||
|
palette_catppuccin: string
|
||||||
|
palette_countrysidecastle: string
|
||||||
|
palette_darkmatter: string
|
||||||
|
palette_emeraldforest: string
|
||||||
|
palette_lightgreen: string
|
||||||
|
palette_neobrut: string
|
||||||
|
palette_starrynight: string
|
||||||
language: string
|
language: string
|
||||||
currency: string
|
currency: string
|
||||||
currencyComingSoon: string
|
currencyComingSoon: string
|
||||||
|
|
@ -58,6 +66,10 @@ export interface LocaleMessages {
|
||||||
tomorrow: string
|
tomorrow: string
|
||||||
thisWeek: string
|
thisWeek: string
|
||||||
thisMonth: string
|
thisMonth: string
|
||||||
|
myTickets: string
|
||||||
|
hosting: string
|
||||||
|
pastEvents: string
|
||||||
|
past: string
|
||||||
}
|
}
|
||||||
categories: Record<string, string>
|
categories: Record<string, string>
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -71,7 +83,15 @@ export interface LocaleMessages {
|
||||||
when: string
|
when: string
|
||||||
tickets: string
|
tickets: string
|
||||||
ticketsAvailable: string
|
ticketsAvailable: string
|
||||||
|
ticketsOwned: string
|
||||||
|
unlimitedTickets: string
|
||||||
|
buyTicket: string
|
||||||
|
buyAnotherTicket: string
|
||||||
|
viewMyTickets: string
|
||||||
soldOut: string
|
soldOut: string
|
||||||
|
pastEvent: string
|
||||||
|
loginToBuyTickets: string
|
||||||
|
logIn: string
|
||||||
free: string
|
free: string
|
||||||
}
|
}
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,12 @@ interface User {
|
||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
prvkey?: string // Nostr private key for user
|
// 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.
|
||||||
external_id?: string
|
external_id?: string
|
||||||
extensions: string[]
|
extensions: string[]
|
||||||
wallets: Wallet[]
|
wallets: Wallet[]
|
||||||
|
|
@ -111,12 +116,29 @@ export class LnbitsAPI extends BaseService {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
|
// Try to surface FastAPI's `{"detail": "..."}` shape; fall back to raw
|
||||||
|
// body for non-JSON errors. Without this, every backend error renders
|
||||||
|
// as a generic "API request failed: <status>" and you can't distinguish
|
||||||
|
// "wrong endpoint" from "expired token" from "validation failure".
|
||||||
|
let detail: string = errorText
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(errorText)
|
||||||
|
if (parsed && typeof parsed.detail === 'string') {
|
||||||
|
detail = parsed.detail
|
||||||
|
} else if (parsed && Array.isArray(parsed.detail)) {
|
||||||
|
// pydantic ValidationError: take the first msg
|
||||||
|
detail = parsed.detail[0]?.msg ?? errorText
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// body wasn't JSON; keep the raw text in `detail`
|
||||||
|
}
|
||||||
console.error('LNBits API Error:', {
|
console.error('LNBits API Error:', {
|
||||||
|
endpoint,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
errorText
|
detail,
|
||||||
})
|
})
|
||||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
throw new Error(`LNbits ${endpoint} ${response.status}: ${detail || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
@ -157,19 +179,21 @@ export class LnbitsAPI extends BaseService {
|
||||||
// First get basic user info from /auth
|
// First get basic user info from /auth
|
||||||
const basicUser = await this.request<User>('/auth')
|
const basicUser = await this.request<User>('/auth')
|
||||||
|
|
||||||
// Then get Nostr keys from /auth/nostr/me (this was working in main branch)
|
// /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).
|
||||||
try {
|
try {
|
||||||
const nostrUser = await this.request<User>('/auth/nostr/me')
|
const nostrUser = await this.request<User>('/auth/nostr/me')
|
||||||
|
|
||||||
// Merge the data - basic user info + Nostr keys
|
|
||||||
return {
|
return {
|
||||||
...basicUser,
|
...basicUser,
|
||||||
pubkey: nostrUser.pubkey,
|
pubkey: nostrUser.pubkey,
|
||||||
prvkey: nostrUser.prvkey
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch Nostr keys, returning basic user info:', error)
|
console.warn('Failed to fetch Nostr pubkey from /auth/nostr/me, returning basic user info:', error)
|
||||||
// Return basic user info without Nostr keys if the endpoint fails
|
|
||||||
return basicUser
|
return basicUser
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -185,12 +209,23 @@ export class LnbitsAPI extends BaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(data: Partial<User>): Promise<User> {
|
async updateProfile(data: Partial<User>): Promise<User> {
|
||||||
return this.request<User>('/auth/update', {
|
// aiolabs/lnbits PR #26 (gap-fill 869f67c3) wired
|
||||||
method: 'PUT',
|
// _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',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConversion(params: { from: string; to: string; amount: number }): Promise<Record<string, number>> {
|
||||||
|
return this.request<Record<string, number>>('/conversion', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ from_: params.from, to: params.to, amount: params.amount }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
return !!this.accessToken
|
return !!this.accessToken
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ export const LNBITS_CONFIG = {
|
||||||
// This should point to your LNBits instance
|
// This should point to your LNBits instance
|
||||||
API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`,
|
API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`,
|
||||||
|
|
||||||
|
// LNbits Nostr-transport server pubkey. The webapp encrypts its
|
||||||
|
// signed kind-21000 RPC events to this pubkey and listens for
|
||||||
|
// signed responses from it. Logged by the LNbits server at startup
|
||||||
|
// (`Nostr transport: starting with pubkey <hex>...`).
|
||||||
|
NOSTR_TRANSPORT_PUBKEY: import.meta.env.VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY || '',
|
||||||
|
|
||||||
// Whether to enable debug logging
|
// Whether to enable debug logging
|
||||||
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',
|
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',
|
||||||
|
|
||||||
|
|
|
||||||
108
src/lib/nostr/signing.ts
Normal file
108
src/lib/nostr/signing.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
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,9 +4,10 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next'
|
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
|
||||||
import BookmarkButton from './BookmarkButton.vue'
|
import BookmarkButton from './BookmarkButton.vue'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -19,6 +20,9 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { dateLocale } = useDateLocale()
|
const { dateLocale } = useDateLocale()
|
||||||
|
const { paidCount } = useOwnedTickets()
|
||||||
|
|
||||||
|
const ownedCount = computed(() => paidCount(props.activity.id))
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
const a = props.activity
|
const a = props.activity
|
||||||
|
|
@ -54,6 +58,13 @@ const placeholderBg = computed(() => {
|
||||||
const hue = hash % 360
|
const hue = hash % 360
|
||||||
return `hsl(${hue}, 40%, 85%)`
|
return `hsl(${hue}, 40%, 85%)`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isPast = computed(() => {
|
||||||
|
const a = props.activity
|
||||||
|
const end = a.endDate ?? a.startDate
|
||||||
|
if (!end || isNaN(end.getTime())) return false
|
||||||
|
return end.getTime() < Date.now()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -117,6 +128,22 @@ const placeholderBg = computed(() => {
|
||||||
>
|
>
|
||||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
|
<!-- Past badge — shown when the activity has already ended.
|
||||||
|
Only relevant on the feed when the "Past events" filter
|
||||||
|
chip is toggled on (otherwise these cards aren't rendered);
|
||||||
|
on the detail page the card view isn't used. Suppressed
|
||||||
|
when a pending/rejected status badge is taking the same
|
||||||
|
slot — that case is the creator's own past draft, which is
|
||||||
|
vanishingly rare and the status hint is more actionable. -->
|
||||||
|
<Badge
|
||||||
|
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
|
||||||
|
variant="outline"
|
||||||
|
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||||
|
>
|
||||||
|
<History class="w-3 h-3" />
|
||||||
|
{{ t('activities.filters.past', 'Past') }}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||||
|
|
@ -155,19 +182,38 @@ const placeholderBg = computed(() => {
|
||||||
<span class="truncate">{{ activity.location }}</span>
|
<span class="truncate">{{ activity.location }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tickets available -->
|
<!-- Tickets available. `available === undefined` means
|
||||||
|
unlimited capacity (no `tickets_available` tag was
|
||||||
|
published); show the price-only line in that case. -->
|
||||||
<div
|
<div
|
||||||
v-if="activity.ticketInfo"
|
v-if="activity.ticketInfo"
|
||||||
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Ticket class="w-3.5 h-3.5 shrink-0" />
|
<Ticket class="w-3.5 h-3.5 shrink-0" />
|
||||||
<span v-if="activity.ticketInfo.available > 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 }) }}
|
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-destructive font-medium">
|
<span v-else class="text-destructive font-medium">
|
||||||
{{ t('activities.detail.soldOut') }}
|
{{ t('activities.detail.soldOut') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Owned tickets — shown when the current user holds at
|
||||||
|
least one paid ticket for this activity. Sits next to
|
||||||
|
the availability line so the buyer can see at a glance
|
||||||
|
whether they've already bought in. -->
|
||||||
|
<div
|
||||||
|
v-if="ownedCount > 0"
|
||||||
|
class="flex items-center gap-1.5 text-sm text-primary"
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button'
|
||||||
import { CalendarPlus } from 'lucide-vue-next'
|
import { CalendarPlus } from 'lucide-vue-next'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { CalendarTimeEvent } from '../types/nip52'
|
import type { CreateEventRequest } from '../types/ticket'
|
||||||
import type { ActivityCategory } from '../types/category'
|
import type { ActivityCategory } from '../types/category'
|
||||||
import CategorySelector from './CategorySelector.vue'
|
import CategorySelector from './CategorySelector.vue'
|
||||||
import LocationPicker from './LocationPicker.vue'
|
import LocationPicker from './LocationPicker.vue'
|
||||||
|
|
@ -67,56 +67,64 @@ const form = useForm({
|
||||||
const isFormValid = computed(() => form.meta.value.valid)
|
const isFormValid = computed(() => form.meta.value.valid)
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
|
const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
||||||
if (!nostrService) {
|
if (!ticketApi) {
|
||||||
toast.error('Activities service not available')
|
toast.error('Activities service not available')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const signingKey = currentUser.value?.prvkey
|
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
||||||
if (!signingKey) {
|
if (!invoiceKey) {
|
||||||
toast.error('Signing key not available. Please log in again.')
|
toast.error('No wallet available. Please log in first.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isPublishing.value = true
|
isPublishing.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build unix timestamps
|
// Compose ISO 8601 datetime strings the events extension parses.
|
||||||
const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000)
|
const startIso = `${values.startDate}T${values.startTime}`
|
||||||
let endTimestamp: number | undefined
|
const endIso =
|
||||||
if (values.endDate && values.endTime) {
|
values.endDate && values.endTime
|
||||||
endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000)
|
? `${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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique d-tag
|
await ticketApi.createEvent(eventData, invoiceKey)
|
||||||
const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
||||||
|
|
||||||
const eventData: Partial<CalendarTimeEvent> = {
|
// Approval workflow caveat: non-admin users on instances with
|
||||||
dTag,
|
// `auto_approve=false` (the default) land in the proposal queue;
|
||||||
title: values.title,
|
// their event isn't published to relays until an admin approves.
|
||||||
summary: values.summary || undefined,
|
// Admins-and-auto-approve-on instances publish immediately.
|
||||||
content: values.description,
|
toast.success('Activity created!')
|
||||||
image: values.image || undefined,
|
|
||||||
start: startTimestamp,
|
|
||||||
end: endTimestamp,
|
|
||||||
startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
||||||
location: location.value || undefined,
|
|
||||||
hashtags: selectedCategories.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await nostrService.publishCalendarEvent(eventData, signingKey)
|
|
||||||
|
|
||||||
if (result.success > 0) {
|
|
||||||
toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`)
|
|
||||||
emit('created')
|
emit('created')
|
||||||
handleClose()
|
handleClose()
|
||||||
} else {
|
|
||||||
toast.error('Failed to publish to any relay')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to publish activity:', err)
|
console.error('Failed to create activity:', err)
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to publish activity')
|
toast.error(err instanceof Error ? err.message : 'Failed to create activity')
|
||||||
} finally {
|
} finally {
|
||||||
isPublishing.value = false
|
isPublishing.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
|
import { Bell, ChevronDown } from 'lucide-vue-next'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -32,12 +35,13 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next'
|
import { Calendar, Loader2, MapPin, AlertCircle, Zap } from 'lucide-vue-next'
|
||||||
import { toastService } from '@/core/services/ToastService'
|
import { toastService } from '@/core/services/ToastService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
||||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||||
import TimePicker from '@/modules/base/components/TimePicker.vue'
|
import TimePicker from '@/modules/base/components/TimePicker.vue'
|
||||||
|
import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
|
|
@ -87,6 +91,28 @@ function foldDateTime(date: string, time: string): string {
|
||||||
return time ? `${date}T${time}` : date
|
return time ? `${date}T${time}` : date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stamp the form's wall-clock datetime with the user's local UTC offset
|
||||||
|
// before sending it to the LNbits events backend. Without this, the
|
||||||
|
// backend's `_to_unix` (nostr_publisher.py) treats a naive ISO string
|
||||||
|
// as UTC, so e.g. "08:00" entered in CEST gets stored as 08:00 UTC and
|
||||||
|
// the NIP-52 `start` tag is off by the user's offset on the relay
|
||||||
|
// — the detail page then renders it +offset (08:00 → 10:00 in CEST).
|
||||||
|
// Preserving the user's intended wall-clock means stamping it here.
|
||||||
|
// Date-only values (no "T") pass through unchanged.
|
||||||
|
function withLocalTzOffset(value: string): string {
|
||||||
|
if (!value || !value.includes('T')) return value
|
||||||
|
// The form's "YYYY-MM-DDTHH:MM" is parsed by JS Date as local time;
|
||||||
|
// getTimezoneOffset() returns minutes west of UTC, so negate it.
|
||||||
|
const offMin = -new Date(value).getTimezoneOffset()
|
||||||
|
const sign = offMin >= 0 ? '+' : '-'
|
||||||
|
const abs = Math.abs(offMin)
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
|
||||||
|
const mm = String(abs % 60).padStart(2, '0')
|
||||||
|
// Include `:00` seconds for compatibility with older Python
|
||||||
|
// `datetime.fromisoformat` (pre-3.11 won't accept "HH:MM+HH:MM").
|
||||||
|
return `${value}:00${sign}${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
const formSchema = toTypedSchema(
|
const formSchema = toTypedSchema(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
|
|
@ -98,13 +124,19 @@ const formSchema = toTypedSchema(
|
||||||
event_end_time: z.string().optional().default(''),
|
event_end_time: z.string().optional().default(''),
|
||||||
location: z.string().max(500).optional().default(''),
|
location: z.string().max(500).optional().default(''),
|
||||||
currency: z.string().default("sat"),
|
currency: z.string().default("sat"),
|
||||||
|
allow_fiat: z.boolean().default(false),
|
||||||
|
fiat_currency: z.string().default("USD"),
|
||||||
amount_tickets: z.number().min(0).max(100000).default(0),
|
amount_tickets: z.number().min(0).max(100000).default(0),
|
||||||
price_per_ticket: z.number().min(0).default(0),
|
price_per_ticket: z.number().min(0).default(0),
|
||||||
|
email_notifications: z.boolean().default(false),
|
||||||
|
nostr_notifications: z.boolean().default(false),
|
||||||
|
notification_subject: z.string().max(200).default(''),
|
||||||
|
notification_body: z.string().max(2000).default(''),
|
||||||
})
|
})
|
||||||
.superRefine((v, ctx) => {
|
.superRefine((v, ctx) => {
|
||||||
// End must not precede start. Compare on the folded date+time
|
// End must not precede start. Compare on the folded date+time
|
||||||
// string so equal-date / later-time is enforced too.
|
// string so equal-date / later-time is enforced too.
|
||||||
if (!v.event_end_date) return
|
if (v.event_end_date) {
|
||||||
const start = foldDateTime(v.event_start_date, v.event_start_time)
|
const start = foldDateTime(v.event_start_date, v.event_start_time)
|
||||||
const end = foldDateTime(v.event_end_date, v.event_end_time)
|
const end = foldDateTime(v.event_end_date, v.event_end_time)
|
||||||
if (start && end && end < start) {
|
if (start && end && end < start) {
|
||||||
|
|
@ -114,6 +146,19 @@ const formSchema = toTypedSchema(
|
||||||
message: 'End must be on or after start',
|
message: 'End must be on or after start',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// When the price is in sats and the organizer also accepts fiat,
|
||||||
|
// they MUST choose a settle currency. Other price denominations
|
||||||
|
// mirror themselves into fiat_currency automatically. The events
|
||||||
|
// extension uses 'sat' and 'sats' interchangeably — accept both.
|
||||||
|
const isSat = v.currency === 'sat' || v.currency === 'sats'
|
||||||
|
if (v.allow_fiat && isSat && !v.fiat_currency) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['fiat_currency'],
|
||||||
|
message: 'Pick a fiat currency for buyers paying by card',
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -128,8 +173,14 @@ const form = useForm({
|
||||||
event_end_time: '',
|
event_end_time: '',
|
||||||
location: '',
|
location: '',
|
||||||
currency: 'sat',
|
currency: 'sat',
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'USD',
|
||||||
amount_tickets: 0,
|
amount_tickets: 0,
|
||||||
price_per_ticket: 0,
|
price_per_ticket: 0,
|
||||||
|
email_notifications: false,
|
||||||
|
nostr_notifications: false,
|
||||||
|
notification_subject: '',
|
||||||
|
notification_body: '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -138,8 +189,11 @@ interface BannerImage extends UploadedImage {
|
||||||
}
|
}
|
||||||
const bannerImages = ref<BannerImage[]>([])
|
const bannerImages = ref<BannerImage[]>([])
|
||||||
|
|
||||||
// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM]" back
|
// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM[:SS][±HH:MM]]"
|
||||||
// into separate date + time pieces for the form inputs.
|
// 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.
|
||||||
function splitDateTime(value: string | null | undefined): { date: string; time: string } {
|
function splitDateTime(value: string | null | undefined): { date: string; time: string } {
|
||||||
if (!value) return { date: '', time: '' }
|
if (!value) return { date: '', time: '' }
|
||||||
const [date, time = ''] = value.split('T')
|
const [date, time = ''] = value.split('T')
|
||||||
|
|
@ -149,6 +203,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
|
||||||
// When `true`, suppress the auto-mirror watcher so we don't clobber an
|
// When `true`, suppress the auto-mirror watcher so we don't clobber an
|
||||||
// edit-mode population with start-date side effects mid-setValues.
|
// edit-mode population with start-date side effects mid-setValues.
|
||||||
const isPopulating = ref(false)
|
const isPopulating = ref(false)
|
||||||
|
const notificationsOpen = ref(false)
|
||||||
|
|
||||||
// Auto-mirror end date to start: when the user picks a start date,
|
// Auto-mirror end date to start: when the user picks a start date,
|
||||||
// surface that same date in the end-date picker so a one-day event
|
// surface that same date in the end-date picker so a one-day event
|
||||||
|
|
@ -188,8 +243,14 @@ async function populateFromEvent(event: TicketedEvent) {
|
||||||
event_end_time: end.time,
|
event_end_time: end.time,
|
||||||
location: event.location ?? '',
|
location: event.location ?? '',
|
||||||
currency: event.currency ?? 'sat',
|
currency: event.currency ?? 'sat',
|
||||||
|
allow_fiat: event.allow_fiat ?? false,
|
||||||
|
fiat_currency: event.fiat_currency ?? 'USD',
|
||||||
amount_tickets: event.amount_tickets ?? 0,
|
amount_tickets: event.amount_tickets ?? 0,
|
||||||
price_per_ticket: event.price_per_ticket ?? 0,
|
price_per_ticket: event.price_per_ticket ?? 0,
|
||||||
|
email_notifications: event.extra?.email_notifications ?? false,
|
||||||
|
nostr_notifications: event.extra?.nostr_notifications ?? false,
|
||||||
|
notification_subject: event.extra?.notification_subject ?? '',
|
||||||
|
notification_body: event.extra?.notification_body ?? '',
|
||||||
})
|
})
|
||||||
selectedCategories.value = [...(event.categories ?? [])]
|
selectedCategories.value = [...(event.categories ?? [])]
|
||||||
if (event.banner) {
|
if (event.banner) {
|
||||||
|
|
@ -267,9 +328,8 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
try {
|
try {
|
||||||
const eventData: CreateEventRequest = {
|
const eventData: CreateEventRequest = {
|
||||||
name: formValues.name,
|
name: formValues.name,
|
||||||
event_start_date: foldDateTime(
|
event_start_date: withLocalTzOffset(
|
||||||
formValues.event_start_date,
|
foldDateTime(formValues.event_start_date, formValues.event_start_time)
|
||||||
formValues.event_start_time
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if (!isEditMode.value) {
|
if (!isEditMode.value) {
|
||||||
|
|
@ -281,9 +341,8 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
// Optional fields — only include if provided
|
// Optional fields — only include if provided
|
||||||
if (formValues.info) eventData.info = formValues.info
|
if (formValues.info) eventData.info = formValues.info
|
||||||
if (formValues.event_end_date) {
|
if (formValues.event_end_date) {
|
||||||
eventData.event_end_date = foldDateTime(
|
eventData.event_end_date = withLocalTzOffset(
|
||||||
formValues.event_end_date,
|
foldDateTime(formValues.event_end_date, formValues.event_end_time)
|
||||||
formValues.event_end_time
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (formValues.location) eventData.location = formValues.location
|
if (formValues.location) eventData.location = formValues.location
|
||||||
|
|
@ -295,10 +354,29 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
eventData.banner = null
|
eventData.banner = null
|
||||||
}
|
}
|
||||||
if (formValues.currency) eventData.currency = formValues.currency
|
if (formValues.currency) eventData.currency = formValues.currency
|
||||||
|
// allow_fiat always sends so a 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.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
||||||
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
||||||
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
||||||
|
|
||||||
|
// Notification config goes inside the `extra` envelope. On edit
|
||||||
|
// overlay onto the existing event.extra so unrelated fields the
|
||||||
|
// LNbits admin UI sets (promo_codes, conditional, min_tickets)
|
||||||
|
// survive the round-trip.
|
||||||
|
eventData.extra = {
|
||||||
|
...(props.event?.extra ?? {}),
|
||||||
|
email_notifications: formValues.email_notifications,
|
||||||
|
nostr_notifications: formValues.nostr_notifications,
|
||||||
|
notification_subject: formValues.notification_subject,
|
||||||
|
notification_body: formValues.notification_body,
|
||||||
|
}
|
||||||
|
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
if (!props.onUpdateEvent || !props.event?.id) {
|
if (!props.onUpdateEvent || !props.event?.id) {
|
||||||
toastService.error('Update handler missing')
|
toastService.error('Update handler missing')
|
||||||
|
|
@ -524,7 +602,15 @@ const handleOpenChange = (open: boolean) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tickets (optional, visible) -->
|
<!-- ── 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">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<FormField v-slot="{ componentField }" name="amount_tickets">
|
<FormField v-slot="{ componentField }" name="amount_tickets">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
|
@ -550,7 +636,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="currency">
|
<FormField v-slot="{ componentField }" name="currency">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Currency</FormLabel>
|
<FormLabel>Price currency</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select v-bind="componentField">
|
<Select v-bind="componentField">
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -567,6 +653,91 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Payment methods ──────────────────────────────────── -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">Payment methods</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Lightning is always available. Enable fiat to also accept
|
||||||
|
card and bank payments through your configured provider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Zap class="w-4 h-4" />
|
||||||
|
<span>Lightning — always on</span>
|
||||||
|
</div>
|
||||||
|
<FiatToggleField
|
||||||
|
allow-fiat-field="allow_fiat"
|
||||||
|
fiat-currency-field="fiat_currency"
|
||||||
|
:denomination="form.values.currency ?? 'sat'"
|
||||||
|
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket buyer notifications (collapsible). The backend
|
||||||
|
sends email + NIP-04 Nostr DM confirmations on
|
||||||
|
payment when these are on. notification_subject /
|
||||||
|
body let the organizer customize the message; empty
|
||||||
|
strings fall back to the extension's defaults. -->
|
||||||
|
<Collapsible v-model:open="notificationsOpen">
|
||||||
|
<CollapsibleTrigger as-child>
|
||||||
|
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<Bell class="w-4 h-4" />
|
||||||
|
Buyer notifications
|
||||||
|
</span>
|
||||||
|
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': notificationsOpen }" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent class="space-y-3 pt-2">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="email_notifications">
|
||||||
|
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3">
|
||||||
|
<FormLabel class="text-sm">Email confirmation</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="nostr_notifications">
|
||||||
|
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3">
|
||||||
|
<FormLabel class="text-sm">Nostr DM confirmation</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="notification_subject">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel class="text-sm">Subject</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Your ticket for {event_name}" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription class="text-xs">Leave blank to use the default.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="notification_body">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel class="text-sm">Body</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="See you there!" rows="3" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription class="text-xs">
|
||||||
|
Leave blank to use the default. The ticket link is appended automatically.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onUnmounted } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
|
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 { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
|
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
|
||||||
|
import PaymentMethodSelector, {
|
||||||
|
type PaymentMethod as PaymentMethodEntry,
|
||||||
|
} from '@/modules/base/components/payments/PaymentMethodSelector.vue'
|
||||||
|
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
|
||||||
|
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
|
||||||
|
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: {
|
event: {
|
||||||
|
|
@ -14,6 +22,9 @@ interface Props {
|
||||||
name: string
|
name: string
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
currency: string
|
currency: string
|
||||||
|
/** Whether the event accepts fiat payments. From v1.4.0+ */
|
||||||
|
allow_fiat?: boolean
|
||||||
|
fiat_currency?: string
|
||||||
}
|
}
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +41,7 @@ const {
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
paymentHash,
|
paymentHash,
|
||||||
|
paymentRequest,
|
||||||
qrCode,
|
qrCode,
|
||||||
isPaymentPending,
|
isPaymentPending,
|
||||||
isPayingWithWallet,
|
isPayingWithWallet,
|
||||||
|
|
@ -37,27 +49,198 @@ const {
|
||||||
userWallets,
|
userWallets,
|
||||||
hasWalletWithBalance,
|
hasWalletWithBalance,
|
||||||
purchaseTicketForEvent,
|
purchaseTicketForEvent,
|
||||||
|
payCurrentInvoiceWithWallet,
|
||||||
handleOpenLightningWallet,
|
handleOpenLightningWallet,
|
||||||
resetPaymentState,
|
resetPaymentState,
|
||||||
cleanup,
|
cleanup,
|
||||||
ticketQRCode,
|
purchasedTicketIds,
|
||||||
purchasedTicketId,
|
|
||||||
showTicketQR
|
showTicketQR
|
||||||
} = useTicketPurchase()
|
} = useTicketPurchase()
|
||||||
|
|
||||||
|
const MAX_QUANTITY = 10
|
||||||
|
const quantity = ref(1)
|
||||||
|
const copiedInvoice = ref(false)
|
||||||
|
|
||||||
|
function decreaseQuantity() {
|
||||||
|
if (quantity.value > 1) quantity.value -= 1
|
||||||
|
}
|
||||||
|
function increaseQuantity() {
|
||||||
|
if (quantity.value < MAX_QUANTITY) quantity.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
|
||||||
|
|
||||||
|
async function copyInvoice() {
|
||||||
|
if (!paymentRequest.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(paymentRequest.value)
|
||||||
|
copiedInvoice.value = true
|
||||||
|
setTimeout(() => (copiedInvoice.value = false), 1500)
|
||||||
|
} catch {
|
||||||
|
// Older browsers / insecure contexts; the Open-in-wallet button
|
||||||
|
// still works as a fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providers, providerMeta } = useFiatProviders()
|
||||||
|
const { convert } = usePriceConversion()
|
||||||
|
|
||||||
|
const selectedMethodId = ref<string>('lightning')
|
||||||
|
const fiatRedirectUrl = ref<string | null>(null)
|
||||||
|
const fiatProviderLabel = ref<string | null>(null)
|
||||||
|
const isFiatPending = ref(false)
|
||||||
|
const fiatError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
|
||||||
|
const isPriceInSats = computed(
|
||||||
|
() => props.event.currency === 'sat' || props.event.currency === 'sats',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lightning-button badge: when the price is denominated in fiat, show
|
||||||
|
// the live sat equivalent so the buyer knows roughly what their wallet
|
||||||
|
// will be charged. Best-effort — silent if the conversion fails.
|
||||||
|
const lightningSats = ref<number | null>(null)
|
||||||
|
watch(
|
||||||
|
() => [props.event.currency, props.event.price_per_ticket, props.isOpen] as const,
|
||||||
|
async ([cur, amt, open]) => {
|
||||||
|
if (!open || !amt || cur === 'sat' || cur === 'sats') {
|
||||||
|
lightningSats.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lightningSats.value = await convert(amt as number, cur as string, 'sat')
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function iconFor(hint: 'card' | 'bank' | 'wallet') {
|
||||||
|
if (hint === 'bank') return Landmark
|
||||||
|
if (hint === 'wallet') return Wallet
|
||||||
|
return CreditCard
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
|
||||||
|
const lightning: PaymentMethodEntry = {
|
||||||
|
id: 'lightning',
|
||||||
|
rail: 'lightning',
|
||||||
|
label: 'Lightning',
|
||||||
|
icon: Zap,
|
||||||
|
available: true,
|
||||||
|
badge:
|
||||||
|
!isPriceInSats.value && lightningSats.value
|
||||||
|
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
if (!props.event.allow_fiat) return [lightning]
|
||||||
|
|
||||||
|
if (providers.value.length > 0) {
|
||||||
|
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
|
||||||
|
const meta = providerMeta(id)
|
||||||
|
return {
|
||||||
|
id: `fiat:${id}`,
|
||||||
|
rail: 'fiat',
|
||||||
|
provider: id,
|
||||||
|
label: meta.label,
|
||||||
|
icon: iconFor(meta.icon),
|
||||||
|
available: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [lightning, ...fiatRails]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degenerate fallback — allow_fiat is on but the buyer's session
|
||||||
|
// can't enumerate the organizer's providers. Show a generic Card
|
||||||
|
// button and let the backend pick a default at request time.
|
||||||
|
return [
|
||||||
|
lightning,
|
||||||
|
{
|
||||||
|
id: 'fiat',
|
||||||
|
rail: 'fiat',
|
||||||
|
label: 'Card',
|
||||||
|
icon: CreditCard,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedMethod = computed(() =>
|
||||||
|
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
|
||||||
|
)
|
||||||
|
|
||||||
async function handlePurchase() {
|
async function handlePurchase() {
|
||||||
if (!canPurchase.value) return
|
if (!canPurchase.value) return
|
||||||
|
fiatError.value = null
|
||||||
|
|
||||||
|
const method = selectedMethod.value
|
||||||
|
if (!method) return
|
||||||
|
|
||||||
|
// Lightning path: the composable just creates the invoice + starts
|
||||||
|
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in
|
||||||
|
// external wallet" on the invoice screen (restaurant pattern), so
|
||||||
|
// no auto-pay here.
|
||||||
|
if (method.rail === 'lightning') {
|
||||||
try {
|
try {
|
||||||
await purchaseTicketForEvent(props.event.id)
|
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error purchasing ticket:', err)
|
console.error('Error purchasing ticket:', err)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
|
||||||
|
// API directly with the chosen provider, then redirect the buyer to
|
||||||
|
// the provider's checkout URL. Payment confirmation happens via
|
||||||
|
// webhook on the backend and shows up next time the buyer reloads
|
||||||
|
// MyTickets.
|
||||||
|
try {
|
||||||
|
isFiatPending.value = true
|
||||||
|
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||||
|
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
||||||
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
const currentUser = (lnbitsAPI?.currentUser?.value) || null
|
||||||
|
const userId = currentUser?.id
|
||||||
|
if (!userId) {
|
||||||
|
fiatError.value = 'Missing user id'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const invoice = await ticketApi.requestTicket(
|
||||||
|
props.event.id,
|
||||||
|
userId,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
paymentMethod: 'fiat',
|
||||||
|
fiatProvider: method.provider,
|
||||||
|
quantity: quantity.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
||||||
|
fiatError.value = 'Fiat provider did not return a checkout URL.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fiatRedirectUrl.value = invoice.fiatPaymentRequest
|
||||||
|
fiatProviderLabel.value = invoice.fiatProvider
|
||||||
|
? providerMeta(invoice.fiatProvider).label
|
||||||
|
: method.label
|
||||||
|
} catch (err) {
|
||||||
|
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
|
||||||
|
} finally {
|
||||||
|
isFiatPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFiatCheckout() {
|
||||||
|
if (!fiatRedirectUrl.value) return
|
||||||
|
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
emit('update:isOpen', false)
|
emit('update:isOpen', false)
|
||||||
resetPaymentState()
|
resetPaymentState()
|
||||||
|
selectedMethodId.value = 'lightning'
|
||||||
|
fiatRedirectUrl.value = null
|
||||||
|
fiatProviderLabel.value = null
|
||||||
|
fiatError.value = null
|
||||||
|
quantity.value = 1
|
||||||
|
copiedInvoice.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
@ -67,14 +250,20 @@ onUnmounted(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="isOpen" @update:open="handleClose">
|
<Dialog :open="isOpen" @update:open="handleClose">
|
||||||
<DialogContent class="sm:max-w-[425px]">
|
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle class="flex items-center gap-2">
|
<DialogTitle class="flex items-center gap-2">
|
||||||
<CreditCard class="w-5 h-5" />
|
<CreditCard class="w-5 h-5" />
|
||||||
Purchase Ticket
|
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
<span v-if="quantity > 1">
|
||||||
|
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
|
||||||
|
{{ formatEventPrice(totalPrice, event.currency) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
|
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
|
||||||
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -149,93 +338,233 @@ onUnmounted(() => {
|
||||||
<CreditCard class="w-4 h-4 text-muted-foreground" />
|
<CreditCard class="w-4 h-4 text-muted-foreground" />
|
||||||
<span class="text-sm font-medium">Payment Details:</span>
|
<span class="text-sm font-medium">Payment Details:</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quantity selector — backend caps at 10. One invoice for
|
||||||
|
the whole purchase, one ticket row representing N seats. -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-muted-foreground">Tickets:</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
:disabled="quantity <= 1"
|
||||||
|
@click="decreaseQuantity"
|
||||||
|
>
|
||||||
|
<Minus class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span class="w-6 text-center text-sm font-medium">{{ quantity }}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
:disabled="quantity >= MAX_QUANTITY"
|
||||||
|
@click="increaseQuantity"
|
||||||
|
>
|
||||||
|
<Plus class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-muted-foreground">Event:</span>
|
<span class="text-sm text-muted-foreground">Event:</span>
|
||||||
<span class="text-sm font-medium">{{ event.name }}</span>
|
<span class="text-sm font-medium">{{ event.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-muted-foreground">Price:</span>
|
<span class="text-sm text-muted-foreground">
|
||||||
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
|
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<PriceConversionPreview
|
||||||
|
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
||||||
|
:amount="totalPrice"
|
||||||
|
from="sat"
|
||||||
|
:to="event.fiat_currency"
|
||||||
|
prefix="Equivalent ~"
|
||||||
|
suffix=" if paid in fiat"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
<!-- Payment method selector (only shown when fiat is enabled
|
||||||
{{ error }}
|
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.
|
||||||
|
</p>
|
||||||
|
<PaymentMethodSelector
|
||||||
|
:methods="paymentMethods"
|
||||||
|
:model-value="selectedMethodId"
|
||||||
|
@update:model-value="selectedMethodId = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
||||||
|
{{ error || fiatError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fiat checkout panel — shown after a successful fiat
|
||||||
|
POST when we have a provider URL to redirect to. -->
|
||||||
|
<div v-if="fiatRedirectUrl" class="space-y-3">
|
||||||
|
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
|
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Opens the provider's checkout in a new tab. Your ticket
|
||||||
|
appears in My Tickets once the payment settles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button @click="openFiatCheckout" class="w-full">
|
||||||
|
<ExternalLink class="w-4 h-4 mr-2" />
|
||||||
|
Open {{ fiatProviderLabel }} checkout
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
v-else
|
||||||
@click="handlePurchase"
|
@click="handlePurchase"
|
||||||
:disabled="isLoading || !canPurchase"
|
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading" class="animate-spin mr-2">⚡</span>
|
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
|
<template v-else-if="selectedMethod?.rail === 'fiat'">
|
||||||
<Zap class="w-4 h-4" />
|
<CreditCard class="w-4 h-4 mr-2" />
|
||||||
Pay with Wallet
|
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>
|
||||||
<span v-else>Generate Payment Request</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>
|
</Button>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4">
|
<!-- LNbits-wallet pay button — only shown when the buyer is
|
||||||
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
|
logged in with a funded wallet. Same screen as the QR so
|
||||||
</div>
|
the user can pick either path without having to back out
|
||||||
|
of the dialog. -->
|
||||||
<div class="space-y-3 w-full">
|
<Button
|
||||||
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
|
v-if="hasWalletWithBalance"
|
||||||
<Wallet class="w-4 h-4 mr-2" />
|
size="lg"
|
||||||
Open in Lightning Wallet
|
class="w-full"
|
||||||
|
:disabled="isPayingWithWallet"
|
||||||
|
@click="payCurrentInvoiceWithWallet"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isPayingWithWallet" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
<Wallet v-else class="mr-2 h-4 w-4" />
|
||||||
|
{{ isPayingWithWallet ? 'Paying…' : 'Pay from my LNbits wallet' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p
|
||||||
|
v-else-if="userWallets.length > 0"
|
||||||
|
class="text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Your LNbits wallet is empty — pay with an external wallet
|
||||||
|
using the QR or "Open in wallet" above.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div v-if="isPaymentPending" class="text-center space-y-2">
|
<div v-if="isPaymentPending" class="text-center space-y-1">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
|
Waiting for payment…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Payment will be confirmed automatically once received
|
Confirmation lands automatically — no need to refresh.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ticket QR Code (After Successful Purchase) -->
|
<!-- Success state. QRs live in My Tickets — no need to
|
||||||
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
|
pre-render them here; this view's job is to confirm the
|
||||||
<div class="text-center space-y-2">
|
purchase landed and route the buyer to where they actually
|
||||||
<h3 class="text-lg font-semibold text-green-600">Ticket Purchased Successfully!</h3>
|
interact with their tickets. -->
|
||||||
<p class="text-sm text-muted-foreground">
|
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
|
||||||
Your ticket has been purchased and is now available in your tickets area.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-muted/50 rounded-lg p-4 w-full">
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<Ticket class="w-12 h-12 text-green-600" />
|
<Ticket class="w-12 h-12 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="text-center space-y-2">
|
||||||
<p class="text-sm font-medium">Ticket ID</p>
|
<h3 class="text-lg font-semibold text-green-600">
|
||||||
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
|
{{ purchasedTicketIds.length > 1
|
||||||
<p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p>
|
? `${purchasedTicketIds.length} tickets purchased!`
|
||||||
</div>
|
: 'Ticket purchased!' }}
|
||||||
</div>
|
</h3>
|
||||||
</div>
|
<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>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 w-full">
|
<div class="space-y-3 w-full">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type { TicketedEvent } from '../types/ticket'
|
||||||
import { ticketedEventToActivity } from '../types/activity'
|
import { ticketedEventToActivity } from '../types/activity'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
import { useActivityFilters } from './useActivityFilters'
|
import { useActivityFilters } from './useActivityFilters'
|
||||||
|
import { useOwnedTickets } from './useOwnedTickets'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main composable for activities discovery.
|
* Main composable for activities discovery.
|
||||||
|
|
@ -17,6 +18,7 @@ export function useActivities() {
|
||||||
const store = useActivitiesStore()
|
const store = useActivitiesStore()
|
||||||
const filters = useActivityFilters()
|
const filters = useActivityFilters()
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
|
const { ownedActivityIds } = useOwnedTickets()
|
||||||
|
|
||||||
const isSubscribed = ref(false)
|
const isSubscribed = ref(false)
|
||||||
const subscriptionError = ref<string | null>(null)
|
const subscriptionError = ref<string | null>(null)
|
||||||
|
|
@ -70,7 +72,10 @@ export function useActivities() {
|
||||||
const all = store.activities.sort(
|
const all = store.activities.sort(
|
||||||
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
||||||
)
|
)
|
||||||
return filters.applyFilters(all)
|
const filtered = filters.applyFilters(all)
|
||||||
|
if (!filters.onlyOwnedTickets.value) return filtered
|
||||||
|
const owned = ownedActivityIds.value
|
||||||
|
return filtered.filter(a => owned.has(a.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,24 @@ export function useActivityDetail(activityId: string) {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
// Subscribe and wait for this specific event
|
// 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] }
|
||||||
|
|
||||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||||
(incoming) => {
|
(incoming) => {
|
||||||
store.upsertActivity(incoming)
|
store.upsertActivity(incoming)
|
||||||
if (incoming.id === activityId) {
|
if (incoming.id === activityId) {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
detailFilters
|
||||||
)
|
)
|
||||||
|
|
||||||
// Also do a one-shot query
|
const results = await nostrService.queryCalendarEvents(detailFilters)
|
||||||
const results = await nostrService.queryCalendarEvents()
|
|
||||||
store.upsertActivities(results)
|
store.upsertActivities(results)
|
||||||
|
|
||||||
// If we still don't have it after query, stop loading
|
// If we still don't have it after query, stop loading
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,28 @@ export function useActivityFilters() {
|
||||||
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||||
const selectedCategories = ref<ActivityCategory[]>([])
|
const selectedCategories = ref<ActivityCategory[]>([])
|
||||||
const selectedDate = ref<Date | undefined>(undefined)
|
const selectedDate = ref<Date | undefined>(undefined)
|
||||||
|
/**
|
||||||
|
* When true, the feed is narrowed to activities the current user
|
||||||
|
* holds at least one paid ticket for. Crossed with the
|
||||||
|
* `ownedActivityIds` set from useOwnedTickets in useActivities
|
||||||
|
* (this composable stays free of ticket fetching).
|
||||||
|
*/
|
||||||
|
const onlyOwnedTickets = ref(false)
|
||||||
|
/**
|
||||||
|
* When true, the feed is narrowed to activities the current user
|
||||||
|
* is hosting (organizer pubkey matches the signed-in user, or the
|
||||||
|
* row is a local LNbits draft of theirs). Reads `activity.isMine`
|
||||||
|
* which `useActivities.tagOwnership()` populates.
|
||||||
|
*/
|
||||||
|
const onlyHosting = ref(false)
|
||||||
|
/**
|
||||||
|
* When false (default), activities that have already ended are
|
||||||
|
* hidden from the feed. Toggling on includes them so the user can
|
||||||
|
* browse past events. The date-picker overrides this — picking a
|
||||||
|
* specific past date shows that day's activities regardless,
|
||||||
|
* mirroring how it overrides the temporal pills.
|
||||||
|
*/
|
||||||
|
const showPast = ref(false)
|
||||||
|
|
||||||
const filters = computed<ActivityFilters>(() => ({
|
const filters = computed<ActivityFilters>(() => ({
|
||||||
temporal: temporal.value,
|
temporal: temporal.value,
|
||||||
|
|
@ -27,7 +49,9 @@ export function useActivityFilters() {
|
||||||
function applyFilters(activities: Activity[]): Activity[] {
|
function applyFilters(activities: Activity[]): Activity[] {
|
||||||
let result = activities
|
let result = activities
|
||||||
|
|
||||||
// Specific date filter (from DatePickerStrip) takes priority over temporal
|
// 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.
|
||||||
if (selectedDate.value) {
|
if (selectedDate.value) {
|
||||||
const dayStart = startOfDay(selectedDate.value)
|
const dayStart = startOfDay(selectedDate.value)
|
||||||
const dayEnd = endOfDay(selectedDate.value)
|
const dayEnd = endOfDay(selectedDate.value)
|
||||||
|
|
@ -38,6 +62,16 @@ export function useActivityFilters() {
|
||||||
} else {
|
} else {
|
||||||
// Temporal filter
|
// Temporal filter
|
||||||
result = applyTemporalFilter(result, temporal.value)
|
result = applyTemporalFilter(result, temporal.value)
|
||||||
|
// Past/upcoming split — the chip narrows to one side of "now",
|
||||||
|
// mirroring the "My tickets" / "Hosting" mental model. Default
|
||||||
|
// (showPast=false) is upcoming-only; toggling on flips to
|
||||||
|
// past-only. Composes with temporal pills: "This Week" +
|
||||||
|
// showPast=true shows only the days already passed this week.
|
||||||
|
const now = new Date()
|
||||||
|
result = result.filter(a => {
|
||||||
|
const activityEnd = a.endDate ?? a.startDate
|
||||||
|
return showPast.value ? activityEnd < now : activityEnd >= now
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category filter
|
// Category filter
|
||||||
|
|
@ -47,6 +81,13 @@ export function useActivityFilters() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hosting filter — activities the signed-in user organizes.
|
||||||
|
// Read off `activity.isMine` which `useActivities.tagOwnership()`
|
||||||
|
// populates from organizer-pubkey match + LNbits drafts.
|
||||||
|
if (onlyHosting.value) {
|
||||||
|
result = result.filter(a => a.isMine === true)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,12 +122,30 @@ export function useActivityFilters() {
|
||||||
temporal.value = DEFAULT_FILTERS.temporal
|
temporal.value = DEFAULT_FILTERS.temporal
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
selectedDate.value = undefined
|
selectedDate.value = undefined
|
||||||
|
onlyOwnedTickets.value = false
|
||||||
|
onlyHosting.value = false
|
||||||
|
showPast.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOwnedTickets() {
|
||||||
|
onlyOwnedTickets.value = !onlyOwnedTickets.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHosting() {
|
||||||
|
onlyHosting.value = !onlyHosting.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePast() {
|
||||||
|
showPast.value = !showPast.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveFilters = computed(() =>
|
const hasActiveFilters = computed(() =>
|
||||||
temporal.value !== 'all' ||
|
temporal.value !== 'all' ||
|
||||||
selectedCategories.value.length > 0 ||
|
selectedCategories.value.length > 0 ||
|
||||||
selectedDate.value !== undefined
|
selectedDate.value !== undefined ||
|
||||||
|
onlyOwnedTickets.value ||
|
||||||
|
onlyHosting.value ||
|
||||||
|
showPast.value
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -94,6 +153,9 @@ export function useActivityFilters() {
|
||||||
temporal,
|
temporal,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
|
onlyOwnedTickets,
|
||||||
|
onlyHosting,
|
||||||
|
showPast,
|
||||||
filters,
|
filters,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
|
|
||||||
|
|
@ -103,6 +165,9 @@ export function useActivityFilters() {
|
||||||
selectDate,
|
selectDate,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
|
toggleOwnedTickets,
|
||||||
|
toggleHosting,
|
||||||
|
togglePast,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools'
|
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-51 Bookmarks (kind 10003) for saving favorite activities.
|
* NIP-51 Bookmarks (kind 10003) for saving favorite activities.
|
||||||
|
|
@ -89,7 +90,7 @@ export function useBookmarks() {
|
||||||
* Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list.
|
* Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list.
|
||||||
*/
|
*/
|
||||||
async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) {
|
async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) {
|
||||||
if (!isAuthenticated.value || !currentUser.value?.prvkey) return
|
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
||||||
|
|
||||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||||
const newCoords = new Set(state.value.bookmarkedCoords)
|
const newCoords = new Set(state.value.bookmarkedCoords)
|
||||||
|
|
@ -110,8 +111,13 @@ export function useBookmarks() {
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
const signingKey = hexToUint8Array(currentUser.value.prvkey)
|
let signedEvent: NostrEvent
|
||||||
const signedEvent = finalizeEvent(template, signingKey)
|
try {
|
||||||
|
signedEvent = await signEventViaLnbits(template)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useBookmarks] signEventViaLnbits failed:', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
if (!relayHub) return
|
if (!relayHub) return
|
||||||
|
|
@ -147,10 +153,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
127
src/modules/activities/composables/useOwnedTickets.ts
Normal file
127
src/modules/activities/composables/useOwnedTickets.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
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,7 +1,8 @@
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools'
|
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||||
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
|
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -150,7 +151,7 @@ export function useRSVP() {
|
||||||
activityDTag: string,
|
activityDTag: string,
|
||||||
status: RSVPStatus
|
status: RSVPStatus
|
||||||
): Promise<RSVPStatus | null> {
|
): Promise<RSVPStatus | null> {
|
||||||
if (!isAuthenticated.value || !currentUser.value?.prvkey) return null
|
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
|
||||||
|
|
||||||
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
||||||
|
|
||||||
|
|
@ -184,8 +185,13 @@ export function useRSVP() {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const signingKey = hexToUint8Array(currentUser.value.prvkey)
|
let signedEvent: NostrEvent
|
||||||
const signedEvent = finalizeEvent(template, signingKey)
|
try {
|
||||||
|
signedEvent = await signEventViaLnbits(template)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useRSVP] signEventViaLnbits failed:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
if (!relayHub) return null
|
if (!relayHub) return null
|
||||||
|
|
@ -240,10 +246,3 @@ 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,9 +20,16 @@ export function useTicketPurchase() {
|
||||||
const qrCode = ref<string | null>(null)
|
const qrCode = ref<string | null>(null)
|
||||||
const isPaymentPending = ref(false)
|
const isPaymentPending = ref(false)
|
||||||
|
|
||||||
// Ticket QR code state
|
// 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.
|
||||||
const ticketQRCode = ref<string | null>(null)
|
const ticketQRCode = ref<string | null>(null)
|
||||||
|
const ticketQRCodes = ref<Record<string, string>>({})
|
||||||
const purchasedTicketId = ref<string | null>(null)
|
const purchasedTicketId = ref<string | null>(null)
|
||||||
|
const purchasedTicketIds = ref<string[]>([])
|
||||||
const showTicketQR = ref(false)
|
const showTicketQR = ref(false)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
|
|
@ -75,7 +82,15 @@ export function useTicketPurchase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purchaseTicketForEvent(eventId: string) {
|
/** 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 } = {},
|
||||||
|
) {
|
||||||
if (!canPurchase.value || !currentUser.value) {
|
if (!canPurchase.value || !currentUser.value) {
|
||||||
throw new Error('User must be authenticated to purchase tickets')
|
throw new Error('User must be authenticated to purchase tickets')
|
||||||
}
|
}
|
||||||
|
|
@ -86,8 +101,11 @@ export function useTicketPurchase() {
|
||||||
paymentRequest.value = null
|
paymentRequest.value = null
|
||||||
qrCode.value = null
|
qrCode.value = null
|
||||||
ticketQRCode.value = null
|
ticketQRCode.value = null
|
||||||
|
ticketQRCodes.value = {}
|
||||||
purchasedTicketId.value = null
|
purchasedTicketId.value = null
|
||||||
|
purchasedTicketIds.value = []
|
||||||
showTicketQR.value = false
|
showTicketQR.value = false
|
||||||
|
currentEventId.value = eventId
|
||||||
|
|
||||||
// Get the invoice via TicketApiService
|
// Get the invoice via TicketApiService
|
||||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||||
|
|
@ -96,26 +114,36 @@ export function useTicketPurchase() {
|
||||||
const invoice = await ticketApi.requestTicket(
|
const invoice = await ticketApi.requestTicket(
|
||||||
eventId,
|
eventId,
|
||||||
currentUser.value!.id,
|
currentUser.value!.id,
|
||||||
accessToken
|
accessToken,
|
||||||
|
{ quantity: options.quantity },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Backend now returns either a Lightning invoice or a fiat
|
||||||
|
// checkout URL (post-events-v1.4.0). This composable only knows
|
||||||
|
// how to drive the Lightning path; fiat would need a separate
|
||||||
|
// redirect-to-provider flow that lives in PurchaseTicketDialog
|
||||||
|
// (it has the user-visible payment-method selector). Reject the
|
||||||
|
// fiat response here so callers get a clear error instead of a
|
||||||
|
// silent broken QR.
|
||||||
|
if (invoice.isFiat || !invoice.paymentRequest) {
|
||||||
|
throw new Error(
|
||||||
|
'This event uses fiat checkout. Use the purchase dialog ' +
|
||||||
|
'to follow the provider link.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const bolt11: string = invoice.paymentRequest
|
||||||
paymentHash.value = invoice.paymentHash
|
paymentHash.value = invoice.paymentHash
|
||||||
paymentRequest.value = invoice.paymentRequest
|
paymentRequest.value = bolt11
|
||||||
|
|
||||||
// Generate QR code for payment
|
// Generate QR code for payment
|
||||||
await generateQRCode(invoice.paymentRequest)
|
await generateQRCode(bolt11)
|
||||||
|
|
||||||
// Try to pay with wallet if available
|
// Restaurant-style: don't auto-pay. Surface the QR + amount and
|
||||||
if (hasWalletWithBalance.value) {
|
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
|
||||||
try {
|
// external wallet" on the same screen. The composable just
|
||||||
await payWithWallet(invoice.paymentRequest)
|
// starts polling so when payment lands (from any path) the UI
|
||||||
|
// advances to the ticket-QR success state.
|
||||||
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
||||||
} catch (walletError) {
|
|
||||||
console.log('Wallet payment failed, falling back to manual payment:', walletError)
|
|
||||||
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
return invoice
|
return invoice
|
||||||
}, {
|
}, {
|
||||||
|
|
@ -123,6 +151,19 @@ export function useTicketPurchase() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger LNbits-wallet payment of the invoice this composable is
|
||||||
|
* currently displaying. Called when the buyer clicks the "Pay from
|
||||||
|
* my LNbits wallet" button on the invoice screen.
|
||||||
|
*/
|
||||||
|
async function payCurrentInvoiceWithWallet(): Promise<void> {
|
||||||
|
if (!paymentRequest.value) return
|
||||||
|
await payWithWallet(paymentRequest.value)
|
||||||
|
// Polling is already running from purchaseTicketForEvent — when
|
||||||
|
// the payment lands, it advances to showTicketQR. No need to
|
||||||
|
// restart it here.
|
||||||
|
}
|
||||||
|
|
||||||
async function startPaymentStatusCheck(eventId: string, hash: string) {
|
async function startPaymentStatusCheck(eventId: string, hash: string) {
|
||||||
isPaymentPending.value = true
|
isPaymentPending.value = true
|
||||||
let checkInterval: number | null = null
|
let checkInterval: number | null = null
|
||||||
|
|
@ -137,13 +178,34 @@ export function useTicketPurchase() {
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.ticketId) {
|
// Multi-ticket purchases come back with `ticketIds` (N rows
|
||||||
purchasedTicketId.value = result.ticketId
|
// sharing one invoice). Single-ticket purchases include
|
||||||
await generateTicketQRCode(result.ticketId)
|
// `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
|
||||||
showTicketQR.value = true
|
showTicketQR.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Ticket purchased successfully!')
|
toast.success(
|
||||||
|
ids.length > 1
|
||||||
|
? `${ids.length} tickets purchased!`
|
||||||
|
: 'Ticket purchased successfully!',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error checking payment status:', err)
|
console.error('Error checking payment status:', err)
|
||||||
|
|
@ -165,7 +227,9 @@ export function useTicketPurchase() {
|
||||||
qrCode.value = null
|
qrCode.value = null
|
||||||
isPaymentPending.value = false
|
isPaymentPending.value = false
|
||||||
ticketQRCode.value = null
|
ticketQRCode.value = null
|
||||||
|
ticketQRCodes.value = {}
|
||||||
purchasedTicketId.value = null
|
purchasedTicketId.value = null
|
||||||
|
purchasedTicketIds.value = []
|
||||||
showTicketQR.value = false
|
showTicketQR.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,7 +257,9 @@ export function useTicketPurchase() {
|
||||||
isPaymentPending,
|
isPaymentPending,
|
||||||
isPayingWithWallet,
|
isPayingWithWallet,
|
||||||
ticketQRCode,
|
ticketQRCode,
|
||||||
|
ticketQRCodes,
|
||||||
purchasedTicketId,
|
purchasedTicketId,
|
||||||
|
purchasedTicketIds,
|
||||||
showTicketQR,
|
showTicketQR,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
|
|
@ -204,6 +270,7 @@ export function useTicketPurchase() {
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
purchaseTicketForEvent,
|
purchaseTicketForEvent,
|
||||||
|
payCurrentInvoiceWithWallet,
|
||||||
handleOpenLightningWallet,
|
handleOpenLightningWallet,
|
||||||
resetPaymentState,
|
resetPaymentState,
|
||||||
cleanup,
|
cleanup,
|
||||||
|
|
|
||||||
214
src/modules/activities/composables/useTicketScanner.ts
Normal file
214
src/modules/activities/composables/useTicketScanner.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
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,6 +66,7 @@ export function useUserTickets() {
|
||||||
return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered)
|
return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const groupedTickets = computed(() => {
|
const groupedTickets = computed(() => {
|
||||||
const groups = new Map<string, GroupedTickets>()
|
const groups = new Map<string, GroupedTickets>()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,15 @@ export const activitiesModule = createModulePlugin({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/scan/:activityId',
|
||||||
|
name: 'scan-tickets',
|
||||||
|
component: () => import('./views/ScanTicketsPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Scan Tickets',
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/events',
|
path: '/events',
|
||||||
name: 'events',
|
name: 'events',
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools'
|
import type { Event as NostrEvent } from 'nostr-tools'
|
||||||
import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
|
import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
|
||||||
import {
|
import {
|
||||||
NIP52_KINDS,
|
NIP52_KINDS,
|
||||||
parseCalendarTimeEvent,
|
parseCalendarTimeEvent,
|
||||||
parseCalendarDateEvent,
|
parseCalendarDateEvent,
|
||||||
buildCalendarTimeEventTags,
|
|
||||||
type CalendarTimeEvent,
|
|
||||||
} from '../types/nip52'
|
} from '../types/nip52'
|
||||||
import {
|
import {
|
||||||
calendarTimeEventToActivity,
|
calendarTimeEventToActivity,
|
||||||
|
|
@ -25,10 +23,20 @@ export interface CalendarEventFilters {
|
||||||
hashtags?: string[]
|
hashtags?: string[]
|
||||||
/** Filter by geohash prefix (NIP-52 'g' tag) */
|
/** Filter by geohash prefix (NIP-52 'g' tag) */
|
||||||
geohash?: string
|
geohash?: string
|
||||||
|
/** Filter by NIP-52 'd' tag — scopes the query to specific parameterized-replaceable events */
|
||||||
|
dTags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub.
|
* 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.
|
||||||
|
*
|
||||||
* Extends BaseService for standardized dependency injection and lifecycle.
|
* Extends BaseService for standardized dependency injection and lifecycle.
|
||||||
*/
|
*/
|
||||||
export class ActivitiesNostrService extends BaseService {
|
export class ActivitiesNostrService extends BaseService {
|
||||||
|
|
@ -105,32 +113,6 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
return activities
|
return activities
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a NIP-52 time-based calendar event.
|
|
||||||
* Requires an authenticated user with a signing key.
|
|
||||||
*/
|
|
||||||
async publishCalendarEvent(
|
|
||||||
eventData: Partial<CalendarTimeEvent>,
|
|
||||||
signingKeyHex: string
|
|
||||||
): Promise<{ success: number; total: number }> {
|
|
||||||
if (!this.relayHub) {
|
|
||||||
throw new Error('RelayHub not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = buildCalendarTimeEventTags(eventData)
|
|
||||||
const template: EventTemplate = {
|
|
||||||
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
content: eventData.content ?? '',
|
|
||||||
tags,
|
|
||||||
}
|
|
||||||
|
|
||||||
const privkeyBytes = hexToUint8Array(signingKeyHex)
|
|
||||||
const signedEvent = finalizeEvent(template, privkeyBytes)
|
|
||||||
|
|
||||||
return await this.relayHub.publishEvent(signedEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a raw Nostr event into an Activity view model.
|
* Parse a raw Nostr event into an Activity view model.
|
||||||
*/
|
*/
|
||||||
|
|
@ -168,6 +150,7 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
if (filters?.authors?.length) filter.authors = filters.authors
|
if (filters?.authors?.length) filter.authors = filters.authors
|
||||||
if (filters?.hashtags?.length) filter['#t'] = filters.hashtags
|
if (filters?.hashtags?.length) filter['#t'] = filters.hashtags
|
||||||
if (filters?.geohash) filter['#g'] = [filters.geohash]
|
if (filters?.geohash) filter['#g'] = [filters.geohash]
|
||||||
|
if (filters?.dTags?.length) filter['#d'] = filters.dTags
|
||||||
|
|
||||||
return [filter]
|
return [filter]
|
||||||
}
|
}
|
||||||
|
|
@ -179,11 +162,3 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
this.activeUnsubscribes = []
|
this.activeUnsubscribes = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToUint8Array(hex: string): Uint8Array {
|
|
||||||
const bytes = new Uint8Array(hex.length / 2)
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import type {
|
import type {
|
||||||
ActivityTicket,
|
ActivityTicket,
|
||||||
|
ActivityTicketExtra,
|
||||||
|
CreateTicketRequest,
|
||||||
|
PaymentMethod,
|
||||||
TicketPurchaseInvoice,
|
TicketPurchaseInvoice,
|
||||||
TicketPaymentStatus,
|
TicketPaymentStatus,
|
||||||
TicketedEvent,
|
TicketedEvent,
|
||||||
|
|
@ -49,14 +52,41 @@ export class TicketApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request a ticket purchase (creates a Lightning invoice).
|
* Request a ticket purchase. Returns either a Lightning invoice
|
||||||
* Uses POST /tickets/{event_id} with user_id in body (upstream API).
|
* (`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`.
|
||||||
*/
|
*/
|
||||||
async requestTicket(
|
async requestTicket(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
accessToken: string
|
accessToken: string,
|
||||||
|
options: {
|
||||||
|
paymentMethod?: PaymentMethod
|
||||||
|
fiatProvider?: string
|
||||||
|
promoCode?: string
|
||||||
|
refundAddress?: string
|
||||||
|
nostrIdentifier?: string
|
||||||
|
/** Number of tickets to buy on this invoice. Backend caps at 10. */
|
||||||
|
quantity?: number
|
||||||
|
} = {},
|
||||||
): Promise<TicketPurchaseInvoice> {
|
): Promise<TicketPurchaseInvoice> {
|
||||||
|
const body: CreateTicketRequest = { user_id: userId }
|
||||||
|
if (options.paymentMethod) body.payment_method = options.paymentMethod
|
||||||
|
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
|
||||||
|
if (options.promoCode) body.promo_code = options.promoCode
|
||||||
|
if (options.refundAddress) body.refund_address = options.refundAddress
|
||||||
|
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
|
||||||
|
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
|
||||||
|
|
||||||
const data = await this.request(
|
const data = await this.request(
|
||||||
`/events/api/v1/tickets/${eventId}`,
|
`/events/api/v1/tickets/${eventId}`,
|
||||||
{
|
{
|
||||||
|
|
@ -65,13 +95,16 @@ export class TicketApiService {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ user_id: userId }),
|
body: JSON.stringify(body),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentHash: data.payment_hash,
|
paymentHash: data.payment_hash,
|
||||||
paymentRequest: data.payment_request,
|
paymentRequest: data.payment_request ?? undefined,
|
||||||
|
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
|
||||||
|
fiatProvider: data.fiat_provider ?? undefined,
|
||||||
|
isFiat: Boolean(data.is_fiat),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +123,7 @@ export class TicketApiService {
|
||||||
return {
|
return {
|
||||||
paid: data.paid === true,
|
paid: data.paid === true,
|
||||||
ticketId: data.ticket_id,
|
ticketId: data.ticket_id,
|
||||||
|
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +155,7 @@ export class TicketApiService {
|
||||||
paid: t.paid,
|
paid: t.paid,
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
|
extra: t.extra as ActivityTicketExtra | undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +179,7 @@ export class TicketApiService {
|
||||||
paid: t.paid,
|
paid: t.paid,
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
|
extra: t.extra as ActivityTicketExtra | undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +219,93 @@ export class TicketApiService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend the ticket confirmation email for a paid ticket. Requires
|
||||||
|
* the event's wallet admin key (organizer-only). Returns the updated
|
||||||
|
* Ticket with the `email_notification_sent` flag refreshed.
|
||||||
|
*
|
||||||
|
* Endpoint added upstream in v1.6.1 (PR #51).
|
||||||
|
*/
|
||||||
|
async resendTicketEmail(
|
||||||
|
ticketId: string,
|
||||||
|
adminKey: string,
|
||||||
|
): Promise<ActivityTicket> {
|
||||||
|
const t = await this.request(
|
||||||
|
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-API-KEY': adminKey },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
wallet: t.wallet,
|
||||||
|
activityId: t.event,
|
||||||
|
name: t.name,
|
||||||
|
email: t.email,
|
||||||
|
userId: t.user_id,
|
||||||
|
registered: t.registered,
|
||||||
|
paid: t.paid,
|
||||||
|
time: t.time,
|
||||||
|
regTimestamp: t.reg_timestamp,
|
||||||
|
extra: t.extra as ActivityTicketExtra | undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Door-scanner roster + counts for one event. Organizer-only —
|
||||||
|
* requires the event-owning wallet's admin_key. Returns the same
|
||||||
|
* shape the `events_list_event_tickets` nostr-transport RPC does;
|
||||||
|
* we route via HTTP because post-#9 the webapp no longer holds a
|
||||||
|
* raw user prvkey to sign kind-21000 envelopes with.
|
||||||
|
*/
|
||||||
|
async getEventStats(
|
||||||
|
eventId: string,
|
||||||
|
adminKey: string,
|
||||||
|
): Promise<{
|
||||||
|
event_id: string
|
||||||
|
sold: number
|
||||||
|
registered: number
|
||||||
|
remaining: number
|
||||||
|
tickets: Array<{
|
||||||
|
id: string
|
||||||
|
name?: string | null
|
||||||
|
registered: boolean
|
||||||
|
registered_at: string | null
|
||||||
|
}>
|
||||||
|
}> {
|
||||||
|
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'X-API-KEY': adminKey },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a paid ticket as registered at the door. Organizer-only —
|
||||||
|
* requires the event-owning wallet's admin_key. Backend rejects
|
||||||
|
* unpaid / already-registered / not-owned cases with HTTP errors
|
||||||
|
* whose `detail` becomes the thrown Error message.
|
||||||
|
*/
|
||||||
|
async registerTicket(ticketId: string, adminKey: string): Promise<ActivityTicket> {
|
||||||
|
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'X-API-KEY': adminKey },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
wallet: t.wallet,
|
||||||
|
activityId: t.event,
|
||||||
|
name: t.name,
|
||||||
|
email: t.email,
|
||||||
|
userId: t.user_id,
|
||||||
|
registered: t.registered,
|
||||||
|
paid: t.paid,
|
||||||
|
time: t.time,
|
||||||
|
regTimestamp: t.reg_timestamp,
|
||||||
|
extra: t.extra as ActivityTicketExtra | undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe whether the current user has LNbits admin privileges. The
|
* Probe whether the current user has LNbits admin privileges. The
|
||||||
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",
|
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import ngeohash from 'ngeohash'
|
import ngeohash from 'ngeohash'
|
||||||
import type { ActivityCategory } from './category'
|
import type { ActivityCategory } from './category'
|
||||||
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
|
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
|
||||||
import type { TicketedEvent } from './ticket'
|
import type { TicketedEvent } from './ticket'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,8 +74,26 @@ export interface OrganizerInfo {
|
||||||
export interface ActivityTicketInfo {
|
export interface ActivityTicketInfo {
|
||||||
price: number
|
price: number
|
||||||
currency: string
|
currency: string
|
||||||
available: number
|
/** Remaining capacity. Undefined means unlimited. */
|
||||||
total: number
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -104,6 +122,7 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
|
||||||
geohash: event.geohash,
|
geohash: event.geohash,
|
||||||
category,
|
category,
|
||||||
tags: event.hashtags,
|
tags: event.hashtags,
|
||||||
|
ticketInfo: ticketTagsToInfo(event.ticket),
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
createdAt: new Date(event.createdAt * 1000),
|
createdAt: new Date(event.createdAt * 1000),
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +159,7 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
|
||||||
geohash: event.geohash,
|
geohash: event.geohash,
|
||||||
category,
|
category,
|
||||||
tags: event.hashtags,
|
tags: event.hashtags,
|
||||||
|
ticketInfo: ticketTagsToInfo(event.ticket),
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
createdAt: new Date(event.createdAt * 1000),
|
createdAt: new Date(event.createdAt * 1000),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,27 @@ export const NIP52_KINDS = {
|
||||||
|
|
||||||
export type Nip52Kind = typeof NIP52_KINDS[keyof typeof NIP52_KINDS]
|
export type Nip52Kind = typeof NIP52_KINDS[keyof typeof NIP52_KINDS]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AIO custom tags carried on ticketed calendar events. The aiolabs/events
|
||||||
|
* extension adds these so connected clients can render the buy CTA + the
|
||||||
|
* "X tickets remaining" badge without an extra REST hop. Absent when the
|
||||||
|
* event was published by a non-AIO client.
|
||||||
|
*/
|
||||||
|
export interface TicketTags {
|
||||||
|
/** Remaining capacity. Undefined means unlimited. */
|
||||||
|
available?: number
|
||||||
|
/** Running paid-count. */
|
||||||
|
sold: number
|
||||||
|
/** Price per ticket in the event's `currency`. */
|
||||||
|
price: number
|
||||||
|
/** Currency string (e.g. 'sat', 'sats', 'USD'). */
|
||||||
|
currency: string
|
||||||
|
/** Whether the organizer enabled fiat checkout. */
|
||||||
|
allowFiat: boolean
|
||||||
|
/** Fiat settle currency when allowFiat is true. */
|
||||||
|
fiatCurrency?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsed NIP-52 date-based calendar event (kind 31922)
|
* Parsed NIP-52 date-based calendar event (kind 31922)
|
||||||
*/
|
*/
|
||||||
|
|
@ -36,6 +57,7 @@ export interface CalendarDateEvent {
|
||||||
references: string[]
|
references: string[]
|
||||||
id: string
|
id: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
ticket?: TicketTags
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,6 +81,7 @@ export interface CalendarTimeEvent {
|
||||||
references: string[]
|
references: string[]
|
||||||
id: string
|
id: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
ticket?: TicketTags
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Participant {
|
export interface Participant {
|
||||||
|
|
@ -96,6 +119,35 @@ function getTagValues(tags: string[][], tagName: string): string[] {
|
||||||
return tags.filter(t => t[0] === tagName).map(t => t[1])
|
return tags.filter(t => t[0] === tagName).map(t => t[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the AIO ticket_* tags off a NIP-52 calendar event. Returns
|
||||||
|
* undefined when the event carries no ticket info (e.g. an event
|
||||||
|
* published by a non-AIO client or a non-ticketed AIO event — though
|
||||||
|
* the latter doesn't currently exist since every aiolabs/events row
|
||||||
|
* has a price + currency).
|
||||||
|
*
|
||||||
|
* `tickets_currency` is the discriminator: when absent, the event has
|
||||||
|
* no inventory metadata and the buy UI stays hidden.
|
||||||
|
*/
|
||||||
|
function parseTicketTags(tags: string[][]): TicketTags | undefined {
|
||||||
|
const currency = getTagValue(tags, 'tickets_currency')
|
||||||
|
if (!currency) return undefined
|
||||||
|
|
||||||
|
const availableStr = getTagValue(tags, 'tickets_available')
|
||||||
|
const soldStr = getTagValue(tags, 'tickets_sold')
|
||||||
|
const priceStr = getTagValue(tags, 'tickets_price')
|
||||||
|
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: availableStr != null ? Number(availableStr) : undefined,
|
||||||
|
sold: soldStr != null ? Number(soldStr) : 0,
|
||||||
|
price: priceStr != null ? Number(priceStr) : 0,
|
||||||
|
currency,
|
||||||
|
allowFiat: allowFiatStr === 'true',
|
||||||
|
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
|
* Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
|
||||||
* Handles: unix seconds, unix milliseconds, and ISO date strings.
|
* Handles: unix seconds, unix milliseconds, and ISO date strings.
|
||||||
|
|
@ -166,6 +218,7 @@ export function parseCalendarTimeEvent(event: NostrEvent): CalendarTimeEvent | n
|
||||||
references: getTagValues(event.tags, 'r'),
|
references: getTagValues(event.tags, 'r'),
|
||||||
id: event.id,
|
id: event.id,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
ticket: parseTicketTags(event.tags),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,6 +266,7 @@ export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | n
|
||||||
references: getTagValues(event.tags, 'r'),
|
references: getTagValues(event.tags, 'r'),
|
||||||
id: event.id,
|
id: event.id,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
ticket: parseTicketTags(event.tags),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,44 @@
|
||||||
/**
|
/**
|
||||||
* Database-backed ticket types (via LNbits events extension)
|
* Database-backed ticket types (via LNbits events extension).
|
||||||
|
*
|
||||||
|
* Wire-format types — names match the snake_case fields the events
|
||||||
|
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
|
||||||
|
* below) are the webapp-internal view models after adapter conversion.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface PromoCode {
|
||||||
|
code: string
|
||||||
|
discount_percent: number
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventExtra mirrors the EventExtra Pydantic model in
|
||||||
|
* `events/models.py`. Carries promo codes, conditional-event config,
|
||||||
|
* and the per-event notification toggles + custom subject/body added
|
||||||
|
* in upstream v1.4.0 (PR #50) and v1.6.0.
|
||||||
|
*/
|
||||||
|
export interface EventExtra {
|
||||||
|
promo_codes: PromoCode[]
|
||||||
|
conditional: boolean
|
||||||
|
min_tickets: number
|
||||||
|
email_notifications: boolean
|
||||||
|
nostr_notifications: boolean
|
||||||
|
notification_subject: string
|
||||||
|
notification_body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityTicketExtra {
|
||||||
|
applied_promo_code?: string | null
|
||||||
|
sats_paid?: number | null
|
||||||
|
refund_address?: string | null
|
||||||
|
nostr_identifier?: string | null
|
||||||
|
ticket_base_url?: string | null
|
||||||
|
email_notification_sent: boolean
|
||||||
|
nostr_notification_sent: boolean
|
||||||
|
refunded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActivityTicket {
|
export interface ActivityTicket {
|
||||||
id: string
|
id: string
|
||||||
wallet: string
|
wallet: string
|
||||||
|
|
@ -21,24 +58,51 @@ export interface ActivityTicket {
|
||||||
time: string
|
time: string
|
||||||
/** Registration/scan timestamp */
|
/** Registration/scan timestamp */
|
||||||
regTimestamp: string
|
regTimestamp: string
|
||||||
|
/** Optional metadata — promo code applied, sats paid, notification
|
||||||
|
* delivery flags, refund state. May be absent on older tickets. */
|
||||||
|
extra?: ActivityTicketExtra
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
||||||
|
|
||||||
|
export type PaymentMethod = 'lightning' | 'fiat'
|
||||||
|
|
||||||
export interface TicketPurchaseRequest {
|
export interface TicketPurchaseRequest {
|
||||||
activityId: string
|
activityId: string
|
||||||
userId: string
|
userId: string
|
||||||
accessToken: string
|
accessToken: string
|
||||||
|
/** Lightning (default) or fiat. Only meaningful if the event has
|
||||||
|
* `allow_fiat=true` on the backend; otherwise the backend coerces
|
||||||
|
* to lightning. */
|
||||||
|
paymentMethod?: PaymentMethod
|
||||||
|
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
|
||||||
|
* user's default if omitted. */
|
||||||
|
fiatProvider?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server response from `POST /tickets/{event_id}`. Either Lightning
|
||||||
|
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
|
||||||
|
* the buyer follows to complete payment with `fiatProvider`).
|
||||||
|
* `isFiat` is the discriminator.
|
||||||
|
*/
|
||||||
export interface TicketPurchaseInvoice {
|
export interface TicketPurchaseInvoice {
|
||||||
paymentHash: string
|
paymentHash: string
|
||||||
paymentRequest: string
|
paymentRequest?: string
|
||||||
|
fiatPaymentRequest?: string
|
||||||
|
fiatProvider?: string
|
||||||
|
isFiat: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketPaymentStatus {
|
export interface TicketPaymentStatus {
|
||||||
paid: boolean
|
paid: boolean
|
||||||
|
/** First ticket id created on this invoice. Back-compat with
|
||||||
|
* single-ticket purchases — equals the payment_hash. */
|
||||||
ticketId?: string
|
ticketId?: string
|
||||||
|
/** Every row created on this invoice — one for single-ticket
|
||||||
|
* purchases, N for multi-ticket. Each row is independently
|
||||||
|
* scannable at the door. */
|
||||||
|
ticketIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,6 +122,10 @@ export interface TicketedEvent {
|
||||||
event_start_date: string
|
event_start_date: string
|
||||||
event_end_date: string | null
|
event_end_date: string | null
|
||||||
currency: string
|
currency: string
|
||||||
|
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */
|
||||||
|
allow_fiat: boolean
|
||||||
|
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
|
||||||
|
fiat_currency: string
|
||||||
amount_tickets: number
|
amount_tickets: number
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
time: string
|
time: string
|
||||||
|
|
@ -65,6 +133,7 @@ export interface TicketedEvent {
|
||||||
banner: string | null
|
banner: string | null
|
||||||
location: string | null
|
location: string | null
|
||||||
categories: string[]
|
categories: string[]
|
||||||
|
extra: EventExtra
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,9 +145,36 @@ export interface CreateEventRequest {
|
||||||
event_start_date: string
|
event_start_date: string
|
||||||
event_end_date?: string
|
event_end_date?: string
|
||||||
currency?: string
|
currency?: string
|
||||||
|
allow_fiat?: boolean
|
||||||
|
fiat_currency?: string
|
||||||
amount_tickets?: number
|
amount_tickets?: number
|
||||||
price_per_ticket?: number
|
price_per_ticket?: number
|
||||||
banner?: string | null
|
banner?: string | null
|
||||||
location?: string | null
|
location?: string | null
|
||||||
categories?: string[]
|
categories?: string[]
|
||||||
|
/** Optional — notification toggles + custom subject/body, promo
|
||||||
|
* codes, conditional-event config. Backend defaults to a fresh
|
||||||
|
* EventExtra if omitted. */
|
||||||
|
extra?: Partial<EventExtra>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /tickets/{event_id}`. Either `user_id` OR the
|
||||||
|
* `name`+`email` pair is required (backend root_validator enforces
|
||||||
|
* mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM
|
||||||
|
* delivery when the event has nostr_notifications enabled. The
|
||||||
|
* `payment_method` + `fiat_provider` pair selects between Lightning
|
||||||
|
* and fiat checkout.
|
||||||
|
*/
|
||||||
|
export interface CreateTicketRequest {
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
user_id?: string
|
||||||
|
promo_code?: string
|
||||||
|
refund_address?: string
|
||||||
|
nostr_identifier?: string
|
||||||
|
payment_method?: PaymentMethod
|
||||||
|
fiat_provider?: string
|
||||||
|
/** Number of tickets on this invoice (backend bounds 1..10). */
|
||||||
|
quantity?: number
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
import { SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
|
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
|
||||||
import { useActivities } from '../composables/useActivities'
|
import { useActivities } from '../composables/useActivities'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
|
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
|
||||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||||
|
|
@ -28,14 +29,22 @@ const {
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
|
onlyOwnedTickets,
|
||||||
|
onlyHosting,
|
||||||
|
showPast,
|
||||||
selectDate,
|
selectDate,
|
||||||
setTemporal,
|
setTemporal,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
|
toggleOwnedTickets,
|
||||||
|
toggleHosting,
|
||||||
|
togglePast,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
subscribe,
|
subscribe,
|
||||||
} = useActivities()
|
} = useActivities()
|
||||||
|
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
const filtersOpen = ref(false)
|
const filtersOpen = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
@ -74,6 +83,43 @@ function handleSelectActivity(activity: Activity) {
|
||||||
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
||||||
|
"Hosting") narrow the feed to activities the signed-in user
|
||||||
|
has skin in and are hidden when logged out. The "Past events"
|
||||||
|
chip is always visible since past-browsing doesn't require an
|
||||||
|
account. -->
|
||||||
|
<div class="mb-4 flex flex-wrap gap-2">
|
||||||
|
<template v-if="isAuthenticated">
|
||||||
|
<Button
|
||||||
|
:variant="onlyOwnedTickets ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="gap-1.5"
|
||||||
|
@click="toggleOwnedTickets"
|
||||||
|
>
|
||||||
|
<Ticket class="w-3.5 h-3.5" />
|
||||||
|
{{ t('activities.filters.myTickets', 'My tickets') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:variant="onlyHosting ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="gap-1.5"
|
||||||
|
@click="toggleHosting"
|
||||||
|
>
|
||||||
|
<Megaphone class="w-3.5 h-3.5" />
|
||||||
|
{{ t('activities.filters.hosting', 'Hosting') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<Button
|
||||||
|
:variant="showPast ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="gap-1.5"
|
||||||
|
@click="togglePast"
|
||||||
|
>
|
||||||
|
<History class="w-3.5 h-3.5" />
|
||||||
|
{{ t('activities.filters.pastEvents', 'Past events') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Category filters (collapsible) -->
|
<!-- Category filters (collapsible) -->
|
||||||
<Collapsible v-model:open="filtersOpen" class="mb-6">
|
<Collapsible v-model:open="filtersOpen" class="mb-6">
|
||||||
<CollapsibleTrigger as-child>
|
<CollapsibleTrigger as-child>
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,18 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
Calendar, MapPin, ArrowLeft, Pencil,
|
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
import RSVPButton from '../components/RSVPButton.vue'
|
import RSVPButton from '../components/RSVPButton.vue'
|
||||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||||
|
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||||
import { NIP52_KINDS } from '../types/nip52'
|
import { NIP52_KINDS } from '../types/nip52'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
|
import { toastService } from '@/core/services/ToastService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
import type { TicketedEvent } from '../types/ticket'
|
||||||
|
|
@ -61,6 +64,10 @@ function openEditDialog() {
|
||||||
activitiesStore.showCreateDialog = true
|
activitiesStore.showCreateDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openScannerPage() {
|
||||||
|
router.push({ name: 'scan-tickets', params: { activityId } })
|
||||||
|
}
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
if (!activity.value) return ''
|
if (!activity.value) return ''
|
||||||
const a = activity.value
|
const a = activity.value
|
||||||
|
|
@ -94,6 +101,71 @@ const categoryLabel = computed(() => {
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ name: 'activities' })
|
router.push({ name: 'activities' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Ticket purchase + owned-tickets surface ----------------------
|
||||||
|
|
||||||
|
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||||
|
|
||||||
|
const ownedPaidCount = computed(() => paidCount(activityId))
|
||||||
|
|
||||||
|
const purchaseEvent = computed(() => {
|
||||||
|
const a = activity.value
|
||||||
|
if (!a || !a.ticketInfo) return null
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
name: a.title,
|
||||||
|
price_per_ticket: a.ticketInfo.price,
|
||||||
|
currency: a.ticketInfo.currency,
|
||||||
|
allow_fiat: a.ticketInfo.allowFiat,
|
||||||
|
fiat_currency: a.ticketInfo.fiatCurrency,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// available === undefined → unlimited capacity, button always shown
|
||||||
|
// available === 0 → sold out, button hidden
|
||||||
|
// available > 0 → button shown
|
||||||
|
const canBuyTicket = computed(() => {
|
||||||
|
const info = activity.value?.ticketInfo
|
||||||
|
if (!info) return false
|
||||||
|
return info.available === undefined || info.available > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Past events can't be bought into. The notice below replaces the
|
||||||
|
// buy CTA so the flow is unambiguous — date alone is easy to miss
|
||||||
|
// on a long detail page.
|
||||||
|
const isPast = computed(() => {
|
||||||
|
const a = activity.value
|
||||||
|
if (!a) return false
|
||||||
|
const end = a.endDate ?? a.startDate
|
||||||
|
if (!end || isNaN(end.getTime())) return false
|
||||||
|
return end.getTime() < Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
const showPurchaseDialog = ref(false)
|
||||||
|
|
||||||
|
function openPurchaseDialog() {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toastService.info(t('activities.detail.loginToBuyTickets'), {
|
||||||
|
action: {
|
||||||
|
label: t('activities.detail.logIn'),
|
||||||
|
onClick: () => router.push('/login'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showPurchaseDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch the user's tickets when the purchase dialog closes (the
|
||||||
|
// buyer may have just paid). The inventory side updates automatically
|
||||||
|
// via the relay republish from the events extension.
|
||||||
|
watch(showPurchaseDialog, (open) => {
|
||||||
|
if (!open) refreshOwnedTickets()
|
||||||
|
})
|
||||||
|
|
||||||
|
function goToMyTickets() {
|
||||||
|
router.push('/my-tickets')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -105,6 +177,17 @@ function goBack() {
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
v-if="ownedLnbitsEvent"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="gap-1.5"
|
||||||
|
@click="openScannerPage"
|
||||||
|
aria-label="Scan tickets"
|
||||||
|
>
|
||||||
|
<ScanLine class="w-4 h-4" />
|
||||||
|
Scan
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="ownedLnbitsEvent"
|
v-if="ownedLnbitsEvent"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -219,6 +302,79 @@ function goBack() {
|
||||||
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Tickets — gated on the activity carrying ticketInfo (set
|
||||||
|
by the 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 -->
|
<!-- Organizer -->
|
||||||
<div class="bg-muted/50 rounded-lg p-4">
|
<div class="bg-muted/50 rounded-lg p-4">
|
||||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ const selectedEvent = ref<{
|
||||||
name: string
|
name: string
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
currency: string
|
currency: string
|
||||||
|
allow_fiat?: boolean
|
||||||
|
fiat_currency?: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const showEventDialog = ref(false)
|
const showEventDialog = ref(false)
|
||||||
|
|
@ -56,6 +58,8 @@ function handlePurchaseClick(event: {
|
||||||
name: string
|
name: string
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
currency: string
|
currency: string
|
||||||
|
allow_fiat?: boolean
|
||||||
|
fiat_currency?: string
|
||||||
}) {
|
}) {
|
||||||
if (!isAuthenticated.value) return
|
if (!isAuthenticated.value) return
|
||||||
selectedEvent.value = event
|
selectedEvent.value = event
|
||||||
|
|
|
||||||
292
src/modules/activities/views/ScanTicketsPage.vue
Normal file
292
src/modules/activities/views/ScanTicketsPage.vue
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
<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,9 +2,7 @@
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
import { eventBus } from '@/core/event-bus'
|
import { eventBus } from '@/core/event-bus'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits'
|
import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits'
|
||||||
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
|
|
||||||
import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits'
|
import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits'
|
||||||
|
|
||||||
export class AuthService extends BaseService {
|
export class AuthService extends BaseService {
|
||||||
|
|
@ -114,9 +112,6 @@ export class AuthService extends BaseService {
|
||||||
|
|
||||||
eventBus.emit('auth:login', { user: userData }, 'auth-service')
|
eventBus.emit('auth:login', { user: userData }, 'auth-service')
|
||||||
|
|
||||||
// Auto-broadcast Nostr metadata on login
|
|
||||||
this.broadcastNostrMetadata()
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = this.handleError(error, 'login')
|
const err = this.handleError(error, 'login')
|
||||||
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
|
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
|
||||||
|
|
@ -138,9 +133,6 @@ export class AuthService extends BaseService {
|
||||||
|
|
||||||
eventBus.emit('auth:login', { user: userData }, 'auth-service')
|
eventBus.emit('auth:login', { user: userData }, 'auth-service')
|
||||||
|
|
||||||
// Auto-broadcast Nostr metadata on registration
|
|
||||||
this.broadcastNostrMetadata()
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = this.handleError(error, 'register')
|
const err = this.handleError(error, 'register')
|
||||||
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
|
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
|
||||||
|
|
@ -188,18 +180,14 @@ export class AuthService extends BaseService {
|
||||||
this.isLoading.value = true
|
this.isLoading.value = true
|
||||||
const updatedUser = await this.lnbitsAPI.updateProfile(data)
|
const updatedUser = await this.lnbitsAPI.updateProfile(data)
|
||||||
|
|
||||||
// Preserve prvkey and pubkey from existing user since /auth/update doesn't return them
|
// 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.
|
||||||
this.user.value = {
|
this.user.value = {
|
||||||
...updatedUser,
|
...updatedUser,
|
||||||
pubkey: this.user.value?.pubkey || updatedUser.pubkey,
|
pubkey: this.user.value?.pubkey || updatedUser.pubkey,
|
||||||
prvkey: this.user.value?.prvkey || updatedUser.prvkey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-broadcast Nostr metadata when profile is updated
|
|
||||||
// Note: ProfileSettings component will also manually broadcast,
|
|
||||||
// but this ensures metadata stays in sync even if updated elsewhere
|
|
||||||
this.broadcastNostrMetadata()
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = this.handleError(error, 'updateProfile')
|
const err = this.handleError(error, 'updateProfile')
|
||||||
throw err
|
throw err
|
||||||
|
|
@ -208,26 +196,6 @@ export class AuthService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast user metadata to Nostr relays (NIP-01 kind 0)
|
|
||||||
* Called automatically on login, registration, and profile updates
|
|
||||||
*/
|
|
||||||
private async broadcastNostrMetadata(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
|
|
||||||
if (metadataService && this.user.value?.pubkey) {
|
|
||||||
// Broadcast in background - don't block login/update
|
|
||||||
metadataService.publishMetadata().catch(error => {
|
|
||||||
console.warn('Failed to broadcast Nostr metadata:', error)
|
|
||||||
// Don't throw - this is a non-critical background operation
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If service isn't available yet, silently skip
|
|
||||||
console.debug('Nostr metadata service not yet available')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup when service is disposed
|
* Cleanup when service is disposed
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -122,32 +122,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex flex-col sm:flex-row gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isUpdating || !isFormValid"
|
:disabled="isUpdating || !isFormValid"
|
||||||
class="flex-1"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<span v-if="isUpdating">Updating...</span>
|
<span v-if="isUpdating">Updating...</span>
|
||||||
<span v-else>Update Profile</span>
|
<span v-else>Update Profile</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="isBroadcasting"
|
|
||||||
@click="broadcastMetadata"
|
|
||||||
class="flex-1"
|
|
||||||
>
|
|
||||||
<Radio class="mr-2 h-4 w-4" :class="{ 'animate-pulse': isBroadcasting }" />
|
|
||||||
<span v-if="isBroadcasting">Broadcasting...</span>
|
|
||||||
<span v-else>Broadcast to Nostr</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Your profile is automatically broadcast to Nostr when you update it or log in.
|
Your profile is broadcast to Nostr automatically when you save changes.
|
||||||
Use the "Broadcast to Nostr" button to manually re-broadcast your profile.
|
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -189,7 +174,7 @@ import * as z from 'zod'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { User, Zap, Hash, Radio } from 'lucide-vue-next'
|
import { User, Zap, Hash } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
|
|
@ -215,19 +200,16 @@ import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { ImageUploadService } from '../services/ImageUploadService'
|
import type { ImageUploadService } from '../services/ImageUploadService'
|
||||||
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
|
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const { user, updateProfile, logout } = useAuth()
|
const { user, updateProfile, logout } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||||
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const isUpdating = ref(false)
|
const isUpdating = ref(false)
|
||||||
const isBroadcasting = ref(false)
|
|
||||||
const updateError = ref<string | null>(null)
|
const updateError = ref<string | null>(null)
|
||||||
const updateSuccess = ref(false)
|
const updateSuccess = ref(false)
|
||||||
const uploadedPicture = ref<any[]>([])
|
const uploadedPicture = ref<any[]>([])
|
||||||
|
|
@ -323,18 +305,12 @@ const updateUserProfile = async (formData: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update profile via AuthService (which updates LNbits)
|
// Update profile via AuthService (which updates LNbits).
|
||||||
|
// Kind-0 metadata publishing happens server-side as part of the
|
||||||
|
// PATCH /api/v1/auth handler (aiolabs/lnbits 869f67c3).
|
||||||
await updateProfile(updateData)
|
await updateProfile(updateData)
|
||||||
|
|
||||||
// Broadcast to Nostr automatically
|
toast.success('Profile updated!')
|
||||||
try {
|
|
||||||
await metadataService.publishMetadata()
|
|
||||||
toast.success('Profile updated and broadcast to Nostr!')
|
|
||||||
} catch (nostrError) {
|
|
||||||
console.error('Failed to broadcast to Nostr:', nostrError)
|
|
||||||
toast.warning('Profile updated, but failed to broadcast to Nostr')
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSuccess.value = true
|
updateSuccess.value = true
|
||||||
|
|
||||||
// Clear success message after 3 seconds
|
// Clear success message after 3 seconds
|
||||||
|
|
@ -352,22 +328,6 @@ const updateUserProfile = async (formData: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually broadcast metadata to Nostr
|
|
||||||
const broadcastMetadata = async () => {
|
|
||||||
isBroadcasting.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await metadataService.publishMetadata()
|
|
||||||
toast.success(`Profile broadcast to ${result.success}/${result.total} relays!`)
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to broadcast metadata'
|
|
||||||
console.error('Error broadcasting metadata:', error)
|
|
||||||
toast.error(`Failed to broadcast: ${errorMessage}`)
|
|
||||||
} finally {
|
|
||||||
isBroadcasting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out + redirect to /login on this app's origin.
|
// Log out + redirect to /login on this app's origin.
|
||||||
const onLogout = async () => {
|
const onLogout = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
131
src/modules/base/components/payments/FiatToggleField.vue
Normal file
131
src/modules/base/components/payments/FiatToggleField.vue
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<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>
|
||||||
53
src/modules/base/composables/useFiatProviders.ts
Normal file
53
src/modules/base/composables/useFiatProviders.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
88
src/modules/base/composables/usePriceConversion.ts
Normal file
88
src/modules/base/composables/usePriceConversion.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
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 type { ModulePlugin } from '@/core/types'
|
||||||
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { relayHub } from './nostr/relay-hub'
|
import { relayHub } from './nostr/relay-hub'
|
||||||
import { NostrMetadataService } from './nostr/nostr-metadata-service'
|
|
||||||
import { ProfileService } from './nostr/ProfileService'
|
import { ProfileService } from './nostr/ProfileService'
|
||||||
import { ReactionService } from './nostr/ReactionService'
|
import { ReactionService } from './nostr/ReactionService'
|
||||||
|
import { NostrTransportService } from './services/NostrTransportService'
|
||||||
|
|
||||||
// Import auth services
|
// Import auth services
|
||||||
import { auth } from './auth/auth-service'
|
import { auth } from './auth/auth-service'
|
||||||
|
|
@ -29,9 +29,9 @@ import ProfileSettings from './components/ProfileSettings.vue'
|
||||||
const invoiceService = new InvoiceService()
|
const invoiceService = new InvoiceService()
|
||||||
const lnbitsAPI = new LnbitsAPI()
|
const lnbitsAPI = new LnbitsAPI()
|
||||||
const imageUploadService = new ImageUploadService()
|
const imageUploadService = new ImageUploadService()
|
||||||
const nostrMetadataService = new NostrMetadataService()
|
|
||||||
const profileService = new ProfileService()
|
const profileService = new ProfileService()
|
||||||
const reactionService = new ReactionService()
|
const reactionService = new ReactionService()
|
||||||
|
const nostrTransportService = new NostrTransportService()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base Module Plugin
|
* Base Module Plugin
|
||||||
|
|
@ -46,7 +46,6 @@ export const baseModule: ModulePlugin = {
|
||||||
|
|
||||||
// Register core Nostr services
|
// Register core Nostr services
|
||||||
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
|
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
|
||||||
container.provide(SERVICE_TOKENS.NOSTR_METADATA_SERVICE, nostrMetadataService)
|
|
||||||
|
|
||||||
// Register auth service
|
// Register auth service
|
||||||
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
|
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
|
||||||
|
|
@ -75,6 +74,7 @@ export const baseModule: ModulePlugin = {
|
||||||
// Register shared Nostr services (used by multiple modules)
|
// Register shared Nostr services (used by multiple modules)
|
||||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||||
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
||||||
|
container.provide(SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, nostrTransportService)
|
||||||
|
|
||||||
// Register PWA service
|
// Register PWA service
|
||||||
container.provide('pwaService', pwaService)
|
container.provide('pwaService', pwaService)
|
||||||
|
|
@ -110,10 +110,6 @@ export const baseModule: ModulePlugin = {
|
||||||
waitForDependencies: true, // ImageUploadService depends on ToastService
|
waitForDependencies: true, // ImageUploadService depends on ToastService
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
})
|
})
|
||||||
await nostrMetadataService.initialize({
|
|
||||||
waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub
|
|
||||||
maxRetries: 3
|
|
||||||
})
|
|
||||||
await profileService.initialize({
|
await profileService.initialize({
|
||||||
waitForDependencies: true, // ProfileService depends on RelayHub
|
waitForDependencies: true, // ProfileService depends on RelayHub
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
|
|
@ -122,6 +118,10 @@ export const baseModule: ModulePlugin = {
|
||||||
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
|
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
})
|
})
|
||||||
|
await nostrTransportService.initialize({
|
||||||
|
waitForDependencies: true, // NostrTransportService depends on RelayHub and AuthService
|
||||||
|
maxRetries: 3
|
||||||
|
})
|
||||||
// InvoiceService doesn't need initialization as it's not a BaseService
|
// InvoiceService doesn't need initialization as it's not a BaseService
|
||||||
|
|
||||||
console.log('✅ Base module installed successfully')
|
console.log('✅ Base module installed successfully')
|
||||||
|
|
@ -138,9 +138,9 @@ export const baseModule: ModulePlugin = {
|
||||||
await storageService.dispose()
|
await storageService.dispose()
|
||||||
await toastService.dispose()
|
await toastService.dispose()
|
||||||
await imageUploadService.dispose()
|
await imageUploadService.dispose()
|
||||||
await nostrMetadataService.dispose()
|
|
||||||
await profileService.dispose()
|
await profileService.dispose()
|
||||||
await reactionService.dispose()
|
await reactionService.dispose()
|
||||||
|
await nostrTransportService.dispose()
|
||||||
// InvoiceService doesn't need disposal as it's not a BaseService
|
// InvoiceService doesn't need disposal as it's not a BaseService
|
||||||
await lnbitsAPI.dispose()
|
await lnbitsAPI.dispose()
|
||||||
|
|
||||||
|
|
@ -148,7 +148,6 @@ export const baseModule: ModulePlugin = {
|
||||||
container.remove(SERVICE_TOKENS.LNBITS_API)
|
container.remove(SERVICE_TOKENS.LNBITS_API)
|
||||||
container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
|
container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
|
||||||
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||||
container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
|
|
||||||
container.remove(SERVICE_TOKENS.PROFILE_SERVICE)
|
container.remove(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||||
container.remove(SERVICE_TOKENS.REACTION_SERVICE)
|
container.remove(SERVICE_TOKENS.REACTION_SERVICE)
|
||||||
|
|
||||||
|
|
@ -165,7 +164,6 @@ export const baseModule: ModulePlugin = {
|
||||||
invoiceService,
|
invoiceService,
|
||||||
pwaService,
|
pwaService,
|
||||||
imageUploadService,
|
imageUploadService,
|
||||||
nostrMetadataService,
|
|
||||||
profileService,
|
profileService,
|
||||||
reactionService
|
reactionService
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
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,4 +1,5 @@
|
||||||
import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools'
|
import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools'
|
||||||
|
import type { SubscribeManyParams, SubCloser } from 'nostr-tools/abstract-pool'
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
|
@ -438,7 +439,7 @@ export class RelayHub extends BaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate the subscription
|
// Recreate the subscription
|
||||||
const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
|
const subscription = this.poolSubscribe(availableRelays, config.filters, {
|
||||||
onevent: (event: Event) => {
|
onevent: (event: Event) => {
|
||||||
config.onEvent?.(event)
|
config.onEvent?.(event)
|
||||||
this.emit('event', { subscriptionId: id, event, relay: 'unknown' })
|
this.emit('event', { subscriptionId: id, event, relay: 'unknown' })
|
||||||
|
|
@ -482,7 +483,7 @@ export class RelayHub extends BaseService {
|
||||||
|
|
||||||
|
|
||||||
// Create subscription using the pool
|
// Create subscription using the pool
|
||||||
const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
|
const subscription = this.poolSubscribe(availableRelays, config.filters, {
|
||||||
onevent: (event: Event) => {
|
onevent: (event: Event) => {
|
||||||
config.onEvent?.(event)
|
config.onEvent?.(event)
|
||||||
this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' })
|
this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' })
|
||||||
|
|
@ -550,6 +551,24 @@ export class RelayHub extends BaseService {
|
||||||
return { success: successful, total }
|
return { success: successful, total }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nostr-tools 2.23+ deprecated the Filter[] form on pool.subscribeMany; route
|
||||||
|
// single-filter through pool.subscribe and multi-filter through subscribeMap
|
||||||
|
// so a single REQ-per-relay still carries every filter.
|
||||||
|
private poolSubscribe(
|
||||||
|
relays: string[],
|
||||||
|
filters: Filter[],
|
||||||
|
params: SubscribeManyParams
|
||||||
|
): SubCloser {
|
||||||
|
if (filters.length === 0) {
|
||||||
|
throw new Error('Cannot subscribe with empty filters')
|
||||||
|
}
|
||||||
|
if (filters.length === 1) {
|
||||||
|
return this.pool.subscribe(relays, filters[0], params)
|
||||||
|
}
|
||||||
|
const requests = relays.flatMap(url => filters.map(filter => ({ url, filter })))
|
||||||
|
return this.pool.subscribeMap(requests, params)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query events from relays (one-time fetch)
|
* Query events from relays (one-time fetch)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
68
src/modules/base/services/NostrTransportService.ts
Normal file
68
src/modules/base/services/NostrTransportService.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,399 +0,0 @@
|
||||||
<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,8 +11,6 @@ import type {
|
||||||
IncomeEntry,
|
IncomeEntry,
|
||||||
AccountNode,
|
AccountNode,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
AccountPermission,
|
|
||||||
GrantPermissionRequest,
|
|
||||||
TransactionListResponse
|
TransactionListResponse
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { appConfig } from '@/app.config'
|
import { appConfig } from '@/app.config'
|
||||||
|
|
@ -343,93 +341,6 @@ export class ExpensesAPI extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List all account permissions (admin only)
|
|
||||||
*
|
|
||||||
* @param adminKey - Admin key for authentication
|
|
||||||
*/
|
|
||||||
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.getHeaders(adminKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to list permissions: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = await response.json()
|
|
||||||
return permissions as AccountPermission[]
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error listing permissions:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grant account permission to a user (admin only)
|
|
||||||
*
|
|
||||||
* @param adminKey - Admin key for authentication
|
|
||||||
* @param request - Permission grant request
|
|
||||||
*/
|
|
||||||
async grantPermission(
|
|
||||||
adminKey: string,
|
|
||||||
request: GrantPermissionRequest
|
|
||||||
): Promise<AccountPermission> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.getHeaders(adminKey),
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
const errorMessage =
|
|
||||||
errorData.detail || `Failed to grant permission: ${response.statusText}`
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
const permission = await response.json()
|
|
||||||
return permission as AccountPermission
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error granting permission:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke account permission (admin only)
|
|
||||||
*
|
|
||||||
* @param adminKey - Admin key for authentication
|
|
||||||
* @param permissionId - ID of the permission to revoke
|
|
||||||
*/
|
|
||||||
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${this.baseUrl}/libra/api/v1/permissions/${permissionId}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: this.getHeaders(adminKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
const errorMessage =
|
|
||||||
errorData.detail || `Failed to revoke permission: ${response.statusText}`
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error revoking permission:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user's transactions from journal
|
* Get user's transactions from journal
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -28,25 +28,6 @@ export interface Account {
|
||||||
has_children?: boolean
|
has_children?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Account with user-specific permission metadata
|
|
||||||
* (Will be available once libra API implements permissions)
|
|
||||||
*/
|
|
||||||
export interface AccountWithPermissions extends Account {
|
|
||||||
user_permissions?: PermissionType[]
|
|
||||||
inherited_from?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Permission types for account access control
|
|
||||||
*/
|
|
||||||
export enum PermissionType {
|
|
||||||
READ = 'read',
|
|
||||||
SUBMIT_EXPENSE = 'submit_expense',
|
|
||||||
SUBMIT_INCOME = 'submit_income',
|
|
||||||
MANAGE = 'manage'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expense entry request payload
|
* Expense entry request payload
|
||||||
*/
|
*/
|
||||||
|
|
@ -125,31 +106,6 @@ export interface UserInfo {
|
||||||
equity_account_name?: string
|
equity_account_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Account permission for user access control
|
|
||||||
*/
|
|
||||||
export interface AccountPermission {
|
|
||||||
id: string
|
|
||||||
user_id: string
|
|
||||||
account_id: string
|
|
||||||
permission_type: PermissionType
|
|
||||||
granted_at: string
|
|
||||||
granted_by: string
|
|
||||||
expires_at?: string
|
|
||||||
notes?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grant permission request payload
|
|
||||||
*/
|
|
||||||
export interface GrantPermissionRequest {
|
|
||||||
user_id: string
|
|
||||||
account_id: string
|
|
||||||
permission_type: PermissionType
|
|
||||||
expires_at?: string
|
|
||||||
notes?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transaction entry from journal (user view)
|
* Transaction entry from journal (user view)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,6 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
Flag,
|
|
||||||
XCircle,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Calendar,
|
Calendar,
|
||||||
Filter
|
Filter
|
||||||
|
|
@ -30,14 +26,26 @@ const isLoading = ref(false)
|
||||||
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
||||||
const customStartDate = ref<string>('')
|
const customStartDate = ref<string>('')
|
||||||
const customEndDate = ref<string>('')
|
const customEndDate = ref<string>('')
|
||||||
const typeFilter = ref<'all' | 'income' | 'expense'>('all')
|
// 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 typeFilterOptions = [
|
const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
|
||||||
{ label: 'All', value: 'all' as const },
|
|
||||||
{ label: 'Income', value: 'income' as const },
|
const categoryChips: { label: string; value: Category }[] = [
|
||||||
{ label: 'Expenses', value: 'expense' as const }
|
{ label: 'Income', value: 'income' },
|
||||||
|
{ label: 'Expenses', value: 'expense' },
|
||||||
|
{ label: 'Voided', value: 'voided' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function toggleCategory(cat: Category) {
|
||||||
|
const next = new Set(activeCategories.value)
|
||||||
|
if (next.has(cat)) next.delete(cat)
|
||||||
|
else next.add(cat)
|
||||||
|
activeCategories.value = next
|
||||||
|
}
|
||||||
|
|
||||||
function isIncome(t: Transaction): boolean {
|
function isIncome(t: Transaction): boolean {
|
||||||
return t.tags?.includes('income-entry') ?? false
|
return t.tags?.includes('income-entry') ?? false
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +54,22 @@ function isExpense(t: Transaction): boolean {
|
||||||
return t.tags?.includes('expense-entry') ?? false
|
return t.tags?.includes('expense-entry') ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVoided(t: Transaction): boolean {
|
||||||
|
return t.tags?.includes('voided') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPending(t: Transaction): boolean {
|
||||||
|
return t.flag === '!' && !isVoided(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which chip bucket a row falls into. Voided always wins over type.
|
||||||
|
function getBucket(t: Transaction): Category | null {
|
||||||
|
if (isVoided(t)) return 'voided'
|
||||||
|
if (isIncome(t)) return 'income'
|
||||||
|
if (isExpense(t)) return 'expense'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||||
|
|
||||||
// Fuzzy search state and configuration
|
// Fuzzy search state and configuration
|
||||||
|
|
@ -71,12 +95,13 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
|
||||||
matchAllWhenSearchEmpty: true
|
matchAllWhenSearchEmpty: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions to display (search results or all transactions), filtered by type
|
// Transactions to display: row passes if its bucket's chip is active.
|
||||||
const transactionsToDisplay = computed(() => {
|
const transactionsToDisplay = computed(() => {
|
||||||
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
|
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
|
||||||
if (typeFilter.value === 'income') return base.filter(isIncome)
|
return base.filter(t => {
|
||||||
if (typeFilter.value === 'expense') return base.filter(isExpense)
|
const bucket = getBucket(t)
|
||||||
return base
|
return bucket !== null && activeCategories.value.has(bucket)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle search results
|
// Handle search results
|
||||||
|
|
@ -108,20 +133,28 @@ function formatAmount(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US').format(amount)
|
return new Intl.NumberFormat('en-US').format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get status icon and color based on flag
|
// Income gets a leading '+', expense a leading '-'.
|
||||||
function getStatusInfo(flag?: string) {
|
function getAmountSign(t: Transaction): string {
|
||||||
switch (flag) {
|
if (isIncome(t)) return '+'
|
||||||
case '*':
|
if (isExpense(t)) return '-'
|
||||||
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
|
return ''
|
||||||
case '!':
|
}
|
||||||
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
|
|
||||||
case '#':
|
// Color tint for the amount text. Voided entries drop to muted regardless
|
||||||
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
|
// of type since the strike-through carries the "ignore this" signal.
|
||||||
case 'x':
|
function getAmountColorClass(t: Transaction): string {
|
||||||
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
|
if (isVoided(t)) return 'line-through text-muted-foreground'
|
||||||
default:
|
if (isIncome(t)) return 'text-green-600 dark:text-green-400'
|
||||||
return null
|
if (isExpense(t)) return 'text-red-600 dark:text-red-400'
|
||||||
}
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags that drive other visual channels (border / sign / strike-through) —
|
||||||
|
// suppressed from the badge row so it only carries user-added tags.
|
||||||
|
const TYPE_TAGS = new Set(['income-entry', 'expense-entry', 'voided'])
|
||||||
|
|
||||||
|
function getDisplayTags(t: Transaction): string[] {
|
||||||
|
return (t.tags ?? []).filter(tag => !TYPE_TAGS.has(tag))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load transactions
|
// Load transactions
|
||||||
|
|
@ -224,19 +257,20 @@ onMounted(() => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Type Filter (All / Income / Expenses) -->
|
<!-- Category chips: each chip toggles inclusion of one bucket
|
||||||
|
of rows. Defaults: Income + Expenses on, Voided off. -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<Filter class="h-4 w-4 text-muted-foreground" />
|
<Filter class="h-4 w-4 text-muted-foreground" />
|
||||||
<Button
|
<Button
|
||||||
v-for="option in typeFilterOptions"
|
v-for="chip in categoryChips"
|
||||||
:key="option.value"
|
:key="chip.value"
|
||||||
:variant="typeFilter === option.value ? 'default' : 'outline'"
|
:variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 md:h-8 px-3 text-xs"
|
class="h-7 md:h-8 px-3 text-xs"
|
||||||
@click="typeFilter = option.value"
|
@click="toggleCategory(chip.value)"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ chip.label }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -291,7 +325,7 @@ onMounted(() => {
|
||||||
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
|
{{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -307,7 +341,9 @@ onMounted(() => {
|
||||||
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
|
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
|
||||||
<p class="text-muted-foreground">No transactions found</p>
|
<p class="text-muted-foreground">No transactions found</p>
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
<p class="text-sm text-muted-foreground mt-2">
|
||||||
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
|
<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>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -316,29 +352,19 @@ onMounted(() => {
|
||||||
<div
|
<div
|
||||||
v-for="transaction in transactionsToDisplay"
|
v-for="transaction in transactionsToDisplay"
|
||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
:class="[
|
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
|
||||||
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
|
|
||||||
isIncome(transaction) && 'border-l-4 border-l-green-600',
|
|
||||||
isExpense(transaction) && 'border-l-4 border-l-red-600'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<!-- Transaction Header -->
|
<!-- Transaction Header -->
|
||||||
<div class="flex items-start justify-between gap-3 mb-2">
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<h3
|
||||||
<!-- Status Icon -->
|
|
||||||
<component
|
|
||||||
v-if="getStatusInfo(transaction.flag)"
|
|
||||||
:is="getStatusInfo(transaction.flag)!.icon"
|
|
||||||
:class="[
|
:class="[
|
||||||
'h-4 w-4 flex-shrink-0',
|
'font-medium text-sm sm:text-base truncate mb-1',
|
||||||
getStatusInfo(transaction.flag)!.color
|
isVoided(transaction) && 'line-through text-muted-foreground'
|
||||||
]"
|
]"
|
||||||
/>
|
>
|
||||||
<h3 class="font-medium text-sm sm:text-base truncate">
|
|
||||||
{{ transaction.description }}
|
{{ transaction.description }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
|
||||||
<p class="text-xs sm:text-sm text-muted-foreground">
|
<p class="text-xs sm:text-sm text-muted-foreground">
|
||||||
{{ formatDate(transaction.date) }}
|
{{ formatDate(transaction.date) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -346,11 +372,17 @@ onMounted(() => {
|
||||||
|
|
||||||
<!-- Amount -->
|
<!-- Amount -->
|
||||||
<div class="text-right flex-shrink-0">
|
<div class="text-right flex-shrink-0">
|
||||||
<p class="font-semibold text-sm sm:text-base">
|
<p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
|
||||||
{{ formatAmount(transaction.amount) }} sats
|
{{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
|
||||||
</p>
|
</p>
|
||||||
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
|
<p
|
||||||
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
v-if="transaction.fiat_amount"
|
||||||
|
:class="[
|
||||||
|
'text-xs',
|
||||||
|
getAmountColorClass(transaction) || 'text-muted-foreground'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -367,17 +399,42 @@ onMounted(() => {
|
||||||
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Badges: type (Income / Expense) + status (Voided / Pending,
|
||||||
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
|
mutually exclusive) + any user-added tags. -->
|
||||||
|
<div class="flex flex-wrap gap-1 mt-2">
|
||||||
<Badge
|
<Badge
|
||||||
v-for="tag in transaction.tags"
|
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)"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:class="[
|
class="text-xs"
|
||||||
'text-xs',
|
|
||||||
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
|
||||||
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||||
import { useMarketStore } from '../stores/market'
|
import { useMarketStore } from '../stores/market'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
import type { NostrmarketService } from '../services/nostrmarketService'
|
|
||||||
import { nip59 } from 'nostr-tools'
|
|
||||||
import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
|
import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
|
||||||
import { auth } from '@/composables/useAuthService'
|
import { auth } from '@/composables/useAuthService'
|
||||||
|
|
||||||
|
|
@ -44,7 +42,6 @@ export function useMarket() {
|
||||||
const marketStore = useMarketStore()
|
const marketStore = useMarketStore()
|
||||||
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
||||||
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
||||||
const nostrmarketService = injectService(SERVICE_TOKENS.NOSTRMARKET_SERVICE) as NostrmarketService
|
|
||||||
|
|
||||||
if (!relayHub) {
|
if (!relayHub) {
|
||||||
throw new Error('RelayHub not available. Make sure base module is installed.')
|
throw new Error('RelayHub not available. Make sure base module is installed.')
|
||||||
|
|
@ -433,56 +430,24 @@ export function useMarket() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert hex string to Uint8Array (browser-compatible)
|
|
||||||
const hexToUint8Array = (hex: string): Uint8Array => {
|
|
||||||
const bytes = new Uint8Array(hex.length / 2)
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle incoming order gift wraps (kind 1059) — payment requests, status updates.
|
// Handle incoming order gift wraps (kind 1059) — payment requests, status updates.
|
||||||
//
|
//
|
||||||
// The outer event's pubkey is an ephemeral key (NIP-59); the real merchant
|
// **Disabled in phase 1/2 per design-questions Q4.2 / Bucket C.** NIP-59
|
||||||
// pubkey is on the unwrapped rumor. Content is JSON with a `type` field
|
// gift-wrap unwrap requires a raw user prvkey for NIP-44 v2 decryption;
|
||||||
// (1 = payment request, 2 = order status update).
|
// 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.
|
||||||
const handleOrderDM = async (event: any) => {
|
const handleOrderDM = async (event: any) => {
|
||||||
try {
|
console.warn(
|
||||||
console.log('🎁 Received order gift wrap:', event.id, '(kind', event.kind + ')')
|
'[useMarket] Skipping order gift wrap (kind 1059) unwrap — phase 1/2 ' +
|
||||||
|
'has no prvkey access for NIP-44 decryption. Event id:',
|
||||||
const userPrivkey =
|
event?.id,
|
||||||
authService.user.value?.prvkey ?? auth.currentUser.value?.prvkey
|
)
|
||||||
|
|
||||||
if (!userPrivkey) {
|
|
||||||
console.warn('Cannot unwrap gift wrap: no user private key available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const prvkeyBytes = hexToUint8Array(userPrivkey)
|
|
||||||
const rumor = nip59.unwrapEvent(event, prvkeyBytes)
|
|
||||||
console.log('🔓 Unwrapped rumor from merchant:', rumor.pubkey.slice(0, 10) + '...')
|
|
||||||
|
|
||||||
const messageData = JSON.parse(rumor.content)
|
|
||||||
console.log('📨 Parsed message data:', messageData)
|
|
||||||
|
|
||||||
switch (messageData.type) {
|
|
||||||
case 1: // Payment request
|
|
||||||
console.log('💰 Processing payment request for order:', messageData.id)
|
|
||||||
await nostrmarketService.handlePaymentRequest(messageData)
|
|
||||||
console.log('✅ Payment request processed successfully')
|
|
||||||
break
|
|
||||||
case 2: // Order status update
|
|
||||||
console.log('📦 Processing order status update for order:', messageData.id)
|
|
||||||
await nostrmarketService.handleOrderStatusUpdate(messageData)
|
|
||||||
console.log('✅ Order status update processed successfully')
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
console.log('❓ Unknown message type:', messageData.type)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to handle order gift wrap:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle incoming market events
|
// Handle incoming market events
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,16 @@ import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lu
|
||||||
import { useFeed } from '../composables/useFeed'
|
import { useFeed } from '../composables/useFeed'
|
||||||
import { useProfiles } from '@/modules/base/composables/useProfiles'
|
import { useProfiles } from '@/modules/base/composables/useProfiles'
|
||||||
import { useReactions } from '@/modules/base/composables/useReactions'
|
import { useReactions } from '@/modules/base/composables/useReactions'
|
||||||
import { useScheduledEvents } from '../composables/useScheduledEvents'
|
import { useTasks } from '@/modules/tasks/composables/useTasks'
|
||||||
import ThreadedPost from './ThreadedPost.vue'
|
import ThreadedPost from './ThreadedPost.vue'
|
||||||
import ScheduledEventCard from './ScheduledEventCard.vue'
|
import ScheduledEventCard from './ScheduledEventCard.vue'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
||||||
import type { ScheduledEvent } from '../services/ScheduledEventService'
|
import type { ScheduledEvent } from '@/modules/tasks/services/TaskService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||||
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||||
import { finalizeEvent } from 'nostr-tools'
|
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
|
|
@ -98,7 +98,9 @@ const { getDisplayName, fetchProfiles } = useProfiles()
|
||||||
// Use reactions service for likes/hearts
|
// Use reactions service for likes/hearts
|
||||||
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
||||||
|
|
||||||
// Use scheduled events service
|
// 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.
|
||||||
const {
|
const {
|
||||||
getEventsForSpecificDate,
|
getEventsForSpecificDate,
|
||||||
getCompletion,
|
getCompletion,
|
||||||
|
|
@ -109,7 +111,7 @@ const {
|
||||||
unclaimTask,
|
unclaimTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
allCompletions
|
allCompletions
|
||||||
} = useScheduledEvents()
|
} = useTasks({ autoSubscribe: false })
|
||||||
|
|
||||||
// Selected date for viewing scheduled tasks (defaults to today)
|
// Selected date for viewing scheduled tasks (defaults to today)
|
||||||
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
|
@ -364,15 +366,6 @@ function onToggleLimited(postId: string) {
|
||||||
limitedReplyPosts.value = newLimited
|
limitedReplyPosts.value = newLimited
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to convert hex string to Uint8Array
|
|
||||||
const hexToUint8Array = (hex: string): Uint8Array => {
|
|
||||||
const bytes = new Uint8Array(hex.length / 2)
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle delete post button click - show confirmation dialog
|
// Handle delete post button click - show confirmation dialog
|
||||||
function onDeletePost(note: FeedPost) {
|
function onDeletePost(note: FeedPost) {
|
||||||
if (!authService?.isAuthenticated.value || !authService?.user.value) {
|
if (!authService?.isAuthenticated.value || !authService?.user.value) {
|
||||||
|
|
@ -403,9 +396,8 @@ async function confirmDeletePost() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPrivkey = authService?.user.value?.prvkey
|
if (!authService?.user.value?.pubkey) {
|
||||||
if (!userPrivkey) {
|
toast.error("Not signed in")
|
||||||
toast.error("User private key not available")
|
|
||||||
showDeleteDialog.value = false
|
showDeleteDialog.value = false
|
||||||
postToDelete.value = null
|
postToDelete.value = null
|
||||||
return
|
return
|
||||||
|
|
@ -423,9 +415,8 @@ async function confirmDeletePost() {
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the deletion event
|
// Sign the deletion event server-side via lnbits.
|
||||||
const privkeyBytes = hexToUint8Array(userPrivkey)
|
const signedEvent = await signEventViaLnbits(deletionEvent)
|
||||||
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
|
|
||||||
|
|
||||||
// Publish the deletion request
|
// Publish the deletion request
|
||||||
const result = await relayHub.publishEvent(signedEvent)
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next'
|
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next'
|
||||||
import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
|
import type { ScheduledEvent, EventCompletion, TaskStatus } from '@/modules/tasks/services/TaskService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
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 { ref, computed } from 'vue'
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { eventBus } from '@/core/event-bus'
|
import { eventBus } from '@/core/event-bus'
|
||||||
import type { Event as NostrEvent, Filter } from 'nostr-tools'
|
import type { Event as NostrEvent, Filter } from 'nostr-tools'
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ export class FeedService extends BaseService {
|
||||||
protected relayHub: any = null
|
protected relayHub: any = null
|
||||||
protected visibilityService: any = null
|
protected visibilityService: any = null
|
||||||
protected reactionService: any = null
|
protected reactionService: any = null
|
||||||
protected scheduledEventService: any = null
|
protected taskService: any = null
|
||||||
|
|
||||||
// Event ID tracking for deduplication
|
// Event ID tracking for deduplication
|
||||||
private seenEventIds = new Set<string>()
|
private seenEventIds = new Set<string>()
|
||||||
|
|
@ -73,13 +73,12 @@ export class FeedService extends BaseService {
|
||||||
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
||||||
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
|
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
|
||||||
// ScheduledEventService moved to tasks module - use tryInjectService for backward compat
|
this.taskService = injectService(SERVICE_TOKENS.TASK_SERVICE)
|
||||||
this.scheduledEventService = tryInjectService(SERVICE_TOKENS.TASK_SERVICE)
|
|
||||||
|
|
||||||
console.log('FeedService: RelayHub injected:', !!this.relayHub)
|
console.log('FeedService: RelayHub injected:', !!this.relayHub)
|
||||||
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
||||||
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
||||||
console.log('FeedService: TaskService injected:', !!this.scheduledEventService)
|
console.log('FeedService: TaskService injected:', !!this.taskService)
|
||||||
|
|
||||||
if (!this.relayHub) {
|
if (!this.relayHub) {
|
||||||
throw new Error('RelayHub service not available')
|
throw new Error('RelayHub service not available')
|
||||||
|
|
@ -261,28 +260,19 @@ export class FeedService extends BaseService {
|
||||||
|
|
||||||
// Route reaction events (kind 7) to ReactionService
|
// Route reaction events (kind 7) to ReactionService
|
||||||
if (event.kind === 7) {
|
if (event.kind === 7) {
|
||||||
if (this.reactionService) {
|
|
||||||
this.reactionService.handleReactionEvent(event)
|
this.reactionService.handleReactionEvent(event)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route scheduled events (kind 31922) to ScheduledEventService
|
// Route scheduled events (kind 31922) to TaskService
|
||||||
if (event.kind === 31922) {
|
if (event.kind === 31922) {
|
||||||
if (this.scheduledEventService) {
|
this.taskService.handleScheduledEvent(event)
|
||||||
this.scheduledEventService.handleScheduledEvent(event)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route RSVP/completion events (kind 31925) to ScheduledEventService
|
// Route RSVP/completion events (kind 31925) to TaskService
|
||||||
if (event.kind === 31925) {
|
if (event.kind === 31925) {
|
||||||
console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService')
|
this.taskService.handleCompletionEvent(event)
|
||||||
if (this.scheduledEventService) {
|
|
||||||
this.scheduledEventService.handleCompletionEvent(event)
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,31 +368,19 @@ export class FeedService extends BaseService {
|
||||||
|
|
||||||
// Route to ReactionService for reaction deletions (kind 7)
|
// Route to ReactionService for reaction deletions (kind 7)
|
||||||
if (deletedKind === '7') {
|
if (deletedKind === '7') {
|
||||||
if (this.reactionService) {
|
|
||||||
this.reactionService.handleDeletionEvent(event)
|
this.reactionService.handleDeletionEvent(event)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to ScheduledEventService for completion/RSVP deletions (kind 31925)
|
// Route to TaskService for completion/RSVP deletions (kind 31925)
|
||||||
if (deletedKind === '31925') {
|
if (deletedKind === '31925') {
|
||||||
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService')
|
this.taskService.handleDeletionEvent(event)
|
||||||
if (this.scheduledEventService) {
|
|
||||||
this.scheduledEventService.handleDeletionEvent(event)
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to ScheduledEventService for scheduled event deletions (kind 31922)
|
// Route to TaskService for scheduled event deletions (kind 31922)
|
||||||
if (deletedKind === '31922') {
|
if (deletedKind === '31922') {
|
||||||
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
|
this.taskService.handleTaskDeletion(event)
|
||||||
if (this.scheduledEventService) {
|
|
||||||
this.scheduledEventService.handleTaskDeletion(event)
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -623,17 +601,9 @@ export class FeedService extends BaseService {
|
||||||
* Get like count for a post from ReactionService
|
* Get like count for a post from ReactionService
|
||||||
*/
|
*/
|
||||||
private getLikeCount(postId: string): number {
|
private getLikeCount(postId: string): number {
|
||||||
try {
|
|
||||||
if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') {
|
|
||||||
const reactions = this.reactionService.getEventReactions(postId)
|
const reactions = this.reactionService.getEventReactions(postId)
|
||||||
return reactions?.likes || 0
|
return reactions?.likes || 0
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
// Silently fail if reaction service is not available
|
|
||||||
console.debug('FeedService: Could not get like count for post', postId, error)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get filtered posts for specific feed type
|
* Get filtered posts for specific feed type
|
||||||
|
|
|
||||||
|
|
@ -1,585 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,678 +0,0 @@
|
||||||
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