diff --git a/.env.example b/.env.example index 99327b7..ecae912 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,13 @@ VITE_API_KEY=your-api-key-here VITE_LNBITS_DEBUG=false VITE_WEBSOCKET_ENABLED=true +# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint). +# Logged by the LNbits server at startup: +# `Nostr transport: starting with pubkey ... on N relay(s)` +# Required for the activities ticket scanner; legacy HTTP path still +# works without it. +VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY= + # Lightning Address Domain (optional) # Override the domain used for Lightning Addresses # If not set, domain will be extracted from VITE_LNBITS_BASE_URL diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 15aa084..f6c87cd 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -149,6 +149,9 @@ export const SERVICE_TOKENS = { // Nostr metadata services NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), + // Nostr transport (kind-21000 RPC over relays — LNbits backend) + NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'), + // Activities services (Nostr-native events + ticketing module) ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'), ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'), diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 38bb64a..0c529a2 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -58,6 +58,7 @@ const messages: LocaleMessages = { thisWeek: 'This Week', thisMonth: 'This Month', myTickets: 'My tickets', + hosting: 'Hosting', }, categories: { concert: 'Concert', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index b2603e9..e72be71 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -58,6 +58,7 @@ const messages: LocaleMessages = { thisWeek: 'Esta semana', thisMonth: 'Este mes', myTickets: 'Mis boletos', + hosting: 'Organizo', }, categories: { concert: 'Concierto', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 388f602..3b6ed76 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -58,6 +58,7 @@ const messages: LocaleMessages = { thisWeek: 'Cette semaine', thisMonth: 'Ce mois-ci', myTickets: 'Mes billets', + hosting: 'J\'organise', }, categories: { concert: 'Concert', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index d44f236..4e5584d 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -59,6 +59,7 @@ export interface LocaleMessages { thisWeek: string thisMonth: string myTickets: string + hosting: string } categories: Record detail: { diff --git a/src/lib/config/lnbits.ts b/src/lib/config/lnbits.ts index dec6c8e..cc33aff 100644 --- a/src/lib/config/lnbits.ts +++ b/src/lib/config/lnbits.ts @@ -4,6 +4,12 @@ export const LNBITS_CONFIG = { // This should point to your LNBits instance API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`, + // LNbits Nostr-transport server pubkey. The webapp encrypts its + // signed kind-21000 RPC events to this pubkey and listens for + // signed responses from it. Logged by the LNbits server at startup + // (`Nostr transport: starting with pubkey ...`). + NOSTR_TRANSPORT_PUBKEY: import.meta.env.VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY || '', + // Whether to enable debug logging DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true', diff --git a/src/modules/activities/composables/useActivityFilters.ts b/src/modules/activities/composables/useActivityFilters.ts index 60bcb5e..4aba255 100644 --- a/src/modules/activities/composables/useActivityFilters.ts +++ b/src/modules/activities/composables/useActivityFilters.ts @@ -22,6 +22,13 @@ export function useActivityFilters() { * (this composable stays free of ticket fetching). */ const onlyOwnedTickets = ref(false) + /** + * When true, the feed is narrowed to activities the current user + * is hosting (organizer pubkey matches the signed-in user, or the + * row is a local LNbits draft of theirs). Reads `activity.isMine` + * which `useActivities.tagOwnership()` populates. + */ + const onlyHosting = ref(false) const filters = computed(() => ({ temporal: temporal.value, @@ -54,6 +61,13 @@ export function useActivityFilters() { ) } + // Hosting filter — activities the signed-in user organizes. + // Read off `activity.isMine` which `useActivities.tagOwnership()` + // populates from organizer-pubkey match + LNbits drafts. + if (onlyHosting.value) { + result = result.filter(a => a.isMine === true) + } + return result } @@ -89,17 +103,23 @@ export function useActivityFilters() { selectedCategories.value = [] selectedDate.value = undefined onlyOwnedTickets.value = false + onlyHosting.value = false } function toggleOwnedTickets() { onlyOwnedTickets.value = !onlyOwnedTickets.value } + function toggleHosting() { + onlyHosting.value = !onlyHosting.value + } + const hasActiveFilters = computed(() => temporal.value !== 'all' || selectedCategories.value.length > 0 || selectedDate.value !== undefined || - onlyOwnedTickets.value + onlyOwnedTickets.value || + onlyHosting.value ) return { @@ -108,6 +128,7 @@ export function useActivityFilters() { selectedCategories, selectedDate, onlyOwnedTickets, + onlyHosting, filters, hasActiveFilters, @@ -118,6 +139,7 @@ export function useActivityFilters() { toggleCategory, clearCategories, toggleOwnedTickets, + toggleHosting, resetFilters, } } diff --git a/src/modules/activities/composables/useTicketScanner.ts b/src/modules/activities/composables/useTicketScanner.ts new file mode 100644 index 0000000..50006a1 --- /dev/null +++ b/src/modules/activities/composables/useTicketScanner.ts @@ -0,0 +1,133 @@ +import { ref, type Ref } from 'vue' +import { useLocalStorage } from '@vueuse/core' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { NostrTransportService } from '@/modules/base/services/NostrTransportService' + +export type ScanStatus = 'ok' | 'duplicate-session' | 'error' + +export interface ScanResult { + status: ScanStatus + ticketId: string + /** Backend response payload on OK. */ + ticket?: Record + /** Error string from the backend or local validation. */ + message?: string +} + +export interface ScanRecord { + ticketId: string + /** Holder display name from the backend, if any. */ + name?: string | null + registeredAt: string +} + +/** + * Stateful scanner driver. Owns the camera lifecycle (delegated to + * useQRScanner upstream), the QR decode, the + * `events_ticket_register` RPC call, and a session-local scanned + * list persisted to localStorage so a page refresh doesn't ask the + * organizer to rescan tickets they already counted. + * + * Mirrors the LNbits admin Quasar register page's + * `events_scanned_` localStorage key with the + * `activities_scanned_` prefix. + */ +export function useTicketScanner(activityId: Ref) { + const transport = injectService( + SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, + ) + + const isProcessing = ref(false) + const lastScan = ref(null) + /** + * Set to `true` immediately after a decode resolves (success or + * failure) and stays true until the operator dismisses or taps + * "Scan next". While paused, further `onDecode` calls are dropped + * — the camera keeps streaming for instant resume but the result + * banner sticks so the door can confirm the outcome before + * moving on. Without this, the 5-fps decode loop instantly + * fires "already scanned this session" on the very ticket that + * just succeeded. + */ + const isPaused = ref(false) + const scanned = useLocalStorage( + () => `activities_scanned_${activityId.value}`, + [], + ) + + function parseTicketId(qrText: string): string { + return qrText.startsWith('ticket://') + ? qrText.slice('ticket://'.length) + : qrText + } + + async function onDecode(qrText: string): Promise { + if (isProcessing.value || isPaused.value) return + const ticketId = parseTicketId(qrText).trim() + if (!ticketId) return + + // Session-local de-dup. Distinct from the backend's "already + // registered" — this guards against the QR being held in front + // of the camera for multiple decode frames. + if (scanned.value.some(r => r.ticketId === ticketId)) { + lastScan.value = { status: 'duplicate-session', ticketId } + isPaused.value = true + return + } + + isProcessing.value = true + try { + const ticket = await transport.call>( + 'events_ticket_register', + { + event_id: activityId.value, + ticket_id: ticketId, + }, + ) + const name = (ticket?.name as string | null | undefined) ?? null + scanned.value = [ + { ticketId, name, registeredAt: new Date().toISOString() }, + ...scanned.value, + ] + lastScan.value = { status: 'ok', ticketId, ticket } + } catch (e) { + // Backend RPC errors arrive as NostrRpcError with the + // string in `.message`: "Ticket not paid for", "Ticket + // already registered", "Ticket does not exist on this + // event", "You do not own this event", etc. + lastScan.value = { + status: 'error', + ticketId, + message: e instanceof Error ? e.message : String(e), + } + } finally { + isProcessing.value = false + // Pause the decode loop regardless of outcome. The operator + // taps "Scan next" to resume; this guarantees they see the + // banner and can correct (let the attendee in, deny entry, + // etc.) before the next QR comes into frame. + isPaused.value = true + } + } + + function resume() { + lastScan.value = null + isPaused.value = false + } + + function clearScanned() { + scanned.value = [] + lastScan.value = null + isPaused.value = false + } + + return { + isProcessing, + isPaused, + lastScan, + scanned, + onDecode, + resume, + clearScanned, + } +} diff --git a/src/modules/activities/index.ts b/src/modules/activities/index.ts index ef0b957..4d7bc9c 100644 --- a/src/modules/activities/index.ts +++ b/src/modules/activities/index.ts @@ -78,6 +78,15 @@ export const activitiesModule = createModulePlugin({ requiresAuth: true, }, }, + { + path: '/scan/:activityId', + name: 'scan-tickets', + component: () => import('./views/ScanTicketsPage.vue'), + meta: { + title: 'Scan Tickets', + requiresAuth: true, + }, + }, { path: '/events', name: 'events', diff --git a/src/modules/activities/views/ActivitiesPage.vue b/src/modules/activities/views/ActivitiesPage.vue index d685a11..ccfc08e 100644 --- a/src/modules/activities/views/ActivitiesPage.vue +++ b/src/modules/activities/views/ActivitiesPage.vue @@ -8,7 +8,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' -import { SlidersHorizontal, ChevronDown, Ticket } from 'lucide-vue-next' +import { SlidersHorizontal, ChevronDown, Ticket, Megaphone } from 'lucide-vue-next' import { useActivities } from '../composables/useActivities' import { useAuth } from '@/composables/useAuthService' import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue' @@ -30,11 +30,13 @@ const { hasActiveFilters, selectedDate, onlyOwnedTickets, + onlyHosting, selectDate, setTemporal, toggleCategory, clearCategories, toggleOwnedTickets, + toggleHosting, resetFilters, subscribe, } = useActivities() @@ -79,10 +81,10 @@ function handleSelectActivity(activity: Activity) { - -
+ +
+
diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index 84a0c64..fe9d322 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { - Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, + Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, } from 'lucide-vue-next' import { useActivityDetail } from '../composables/useActivityDetail' import BookmarkButton from '../components/BookmarkButton.vue' @@ -64,6 +64,10 @@ function openEditDialog() { activitiesStore.showCreateDialog = true } +function openScannerPage() { + router.push({ name: 'scan-tickets', params: { activityId } }) +} + const dateDisplay = computed(() => { if (!activity.value) return '' const a = activity.value @@ -157,6 +161,17 @@ function goToMyTickets() { Back
+