Compare commits
26 commits
f3c8b1cf95
...
cd2b1f2020
| Author | SHA1 | Date | |
|---|---|---|---|
| cd2b1f2020 | |||
| 4415e01083 | |||
| 8fcad853ff | |||
| 509954e05f | |||
| 9c5f55d5f7 | |||
| e3e31e16f5 | |||
| 9a14eaa401 | |||
| e861abfcbc | |||
| 41d0d27b6f | |||
| f9b5fe886b | |||
| fc2af7ef71 | |||
| 10f4813d76 | |||
| 97354526a3 | |||
| 0bdc6c8767 | |||
| 663e32e7a4 | |||
| b7b5a08594 | |||
| 99667add65 | |||
| 61665790b3 | |||
| a0087b6bf3 | |||
| f94ad30ac7 | |||
| 620919da58 | |||
| c556d28587 | |||
| dd2365f0f1 | |||
| 4122cb0223 | |||
| 451eedec03 | |||
| 4ef667d89a |
31 changed files with 11788 additions and 15181 deletions
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**
|
||||||
|
|
|
||||||
15013
package-lock.json
generated
15013
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -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.10.4",
|
"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",
|
||||||
|
|
@ -106,7 +106,8 @@
|
||||||
"vite-plugin-inspect": "^0.8.3",
|
"vite-plugin-inspect": "^0.8.3",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vue-tsc": "^2.2.0",
|
"vue-tsc": "^2.2.0",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.yourdomain.aio-shadcn-vite",
|
"appId": "com.yourdomain.aio-shadcn-vite",
|
||||||
|
|
@ -138,5 +139,15 @@
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist_electron"
|
"output": "dist_electron"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"electron",
|
||||||
|
"electron-winstaller",
|
||||||
|
"esbuild",
|
||||||
|
"sharp",
|
||||||
|
"vue-demi"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9938
pnpm-lock.yaml
generated
Normal file
9938
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ const messages: LocaleMessages = {
|
||||||
tomorrow: 'Tomorrow',
|
tomorrow: 'Tomorrow',
|
||||||
thisWeek: 'This Week',
|
thisWeek: 'This Week',
|
||||||
thisMonth: 'This Month',
|
thisMonth: 'This Month',
|
||||||
|
myTickets: 'My tickets',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -96,6 +97,11 @@ 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',
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ const messages: LocaleMessages = {
|
||||||
tomorrow: 'Mañana',
|
tomorrow: 'Mañana',
|
||||||
thisWeek: 'Esta semana',
|
thisWeek: 'Esta semana',
|
||||||
thisMonth: 'Este mes',
|
thisMonth: 'Este mes',
|
||||||
|
myTickets: 'Mis boletos',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concierto',
|
concert: 'Concierto',
|
||||||
|
|
@ -96,6 +97,11 @@ 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',
|
||||||
free: 'Gratis',
|
free: 'Gratis',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ const messages: LocaleMessages = {
|
||||||
tomorrow: 'Demain',
|
tomorrow: 'Demain',
|
||||||
thisWeek: 'Cette semaine',
|
thisWeek: 'Cette semaine',
|
||||||
thisMonth: 'Ce mois-ci',
|
thisMonth: 'Ce mois-ci',
|
||||||
|
myTickets: 'Mes billets',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -96,6 +97,11 @@ 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é',
|
||||||
free: 'Gratuit',
|
free: 'Gratuit',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export interface LocaleMessages {
|
||||||
tomorrow: string
|
tomorrow: string
|
||||||
thisWeek: string
|
thisWeek: string
|
||||||
thisMonth: string
|
thisMonth: string
|
||||||
|
myTickets: string
|
||||||
}
|
}
|
||||||
categories: Record<string, string>
|
categories: Record<string, string>
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -71,6 +72,11 @@ 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
|
||||||
free: string
|
free: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ interface User {
|
||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
prvkey?: string // Nostr private key for user
|
// pragma: allowlist secret
|
||||||
|
prvkey?: string // Nostr signing key for user
|
||||||
external_id?: string
|
external_id?: string
|
||||||
extensions: string[]
|
extensions: string[]
|
||||||
wallets: Wallet[]
|
wallets: Wallet[]
|
||||||
|
|
@ -191,6 +192,13 @@ export class LnbitsAPI extends BaseService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,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 } 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
|
||||||
|
|
@ -155,19 +159,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>
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ 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)
|
||||||
|
|
||||||
const filters = computed<ActivityFilters>(() => ({
|
const filters = computed<ActivityFilters>(() => ({
|
||||||
temporal: temporal.value,
|
temporal: temporal.value,
|
||||||
|
|
@ -81,12 +88,18 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOwnedTickets() {
|
||||||
|
onlyOwnedTickets.value = !onlyOwnedTickets.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
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -94,6 +107,7 @@ export function useActivityFilters() {
|
||||||
temporal,
|
temporal,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
|
onlyOwnedTickets,
|
||||||
filters,
|
filters,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
|
|
||||||
|
|
@ -103,6 +117,7 @@ export function useActivityFilters() {
|
||||||
selectDate,
|
selectDate,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
|
toggleOwnedTickets,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,39 @@ 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 } 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,18 @@ const {
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
|
onlyOwnedTickets,
|
||||||
selectDate,
|
selectDate,
|
||||||
setTemporal,
|
setTemporal,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
|
toggleOwnedTickets,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
subscribe,
|
subscribe,
|
||||||
} = useActivities()
|
} = useActivities()
|
||||||
|
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
const filtersOpen = ref(false)
|
const filtersOpen = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
@ -74,6 +79,21 @@ function handleSelectActivity(activity: Activity) {
|
||||||
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- "My tickets" filter chip — narrows the feed to activities
|
||||||
|
the user holds at least one paid ticket for. Hidden when
|
||||||
|
logged out (no tickets to filter on). -->
|
||||||
|
<div v-if="isAuthenticated" class="mb-4">
|
||||||
|
<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>
|
||||||
|
</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,
|
||||||
} 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'
|
||||||
|
|
@ -94,6 +97,55 @@ 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
|
||||||
|
})
|
||||||
|
|
||||||
|
const showPurchaseDialog = ref(false)
|
||||||
|
|
||||||
|
function openPurchaseDialog() {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toastService.info('Log in to buy tickets')
|
||||||
|
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>
|
||||||
|
|
@ -219,6 +271,72 @@ 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="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
|
||||||
|
|
|
||||||
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 }
|
||||||
|
}
|
||||||
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 }
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue