Compare commits

..

No commits in common. "2498fbe51856e6f87a7ca22f5b7d8745aef8fc3b" and "0f8f98d4c57b7fb8b02201b878114a47276e8c6b" have entirely different histories.

8 changed files with 25 additions and 96 deletions

View file

@ -58,7 +58,6 @@ const messages: LocaleMessages = {
thisWeek: 'This Week',
thisMonth: 'This Month',
myTickets: 'My tickets',
hosting: 'Hosting',
},
categories: {
concert: 'Concert',

View file

@ -58,7 +58,6 @@ const messages: LocaleMessages = {
thisWeek: 'Esta semana',
thisMonth: 'Este mes',
myTickets: 'Mis boletos',
hosting: 'Organizo',
},
categories: {
concert: 'Concierto',

View file

@ -58,7 +58,6 @@ const messages: LocaleMessages = {
thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
hosting: 'J\'organise',
},
categories: {
concert: 'Concert',

View file

@ -59,7 +59,6 @@ export interface LocaleMessages {
thisWeek: string
thisMonth: string
myTickets: string
hosting: string
}
categories: Record<string, string>
detail: {

View file

@ -22,13 +22,6 @@ 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<ActivityFilters>(() => ({
temporal: temporal.value,
@ -61,13 +54,6 @@ export function useActivityFilters() {
)
}
// Hosting filter — activities the signed-in user organizes.
// Read off `activity.isMine` which `useActivities.tagOwnership()`
// populates from organizer-pubkey match + LNbits drafts.
if (onlyHosting.value) {
result = result.filter(a => a.isMine === true)
}
return result
}
@ -103,23 +89,17 @@ 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 ||
onlyHosting.value
onlyOwnedTickets.value
)
return {
@ -128,7 +108,6 @@ export function useActivityFilters() {
selectedCategories,
selectedDate,
onlyOwnedTickets,
onlyHosting,
filters,
hasActiveFilters,
@ -139,7 +118,6 @@ export function useActivityFilters() {
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
resetFilters,
}
}

View file

@ -39,17 +39,6 @@ export function useTicketScanner(activityId: Ref<string>) {
const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null)
/**
* Set to `true` immediately after a decode resolves (success or
* failure) and stays true until the operator dismisses or taps
* "Scan next". While paused, further `onDecode` calls are dropped
* the camera keeps streaming for instant resume but the result
* banner sticks so the door can confirm the outcome before
* moving on. Without this, the 5-fps decode loop instantly
* fires "already scanned this session" on the very ticket that
* just succeeded.
*/
const isPaused = ref(false)
const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`,
[],
@ -62,7 +51,7 @@ export function useTicketScanner(activityId: Ref<string>) {
}
async function onDecode(qrText: string): Promise<void> {
if (isProcessing.value || isPaused.value) return
if (isProcessing.value) return
const ticketId = parseTicketId(qrText).trim()
if (!ticketId) return
@ -71,7 +60,6 @@ export function useTicketScanner(activityId: Ref<string>) {
// 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
}
@ -102,32 +90,24 @@ export function useTicketScanner(activityId: Ref<string>) {
}
} 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
}
function dismissLastScan() {
lastScan.value = null
}
return {
isProcessing,
isPaused,
lastScan,
scanned,
onDecode,
resume,
clearScanned,
dismissLastScan,
}
}

View file

@ -8,7 +8,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone } from 'lucide-vue-next'
import { SlidersHorizontal, ChevronDown, Ticket } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
@ -30,13 +30,11 @@ const {
hasActiveFilters,
selectedDate,
onlyOwnedTickets,
onlyHosting,
selectDate,
setTemporal,
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
resetFilters,
subscribe,
} = useActivities()
@ -81,10 +79,10 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div>
<!-- Role filter chips narrow the feed to activities the user
has skin in. Hidden when logged out (nothing to filter on).
"My tickets" = attending; "Hosting" = organizing. -->
<div v-if="isAuthenticated" class="mb-4 flex flex-wrap gap-2">
<!-- "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"
@ -94,15 +92,6 @@ function handleSelectActivity(activity: Activity) {
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }}
</Button>
</div>
<!-- Category filters (collapsible) -->

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2, ScanLine } from 'lucide-vue-next'
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2 } from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -19,12 +19,11 @@ const { activity } = useActivityDetail(activityId.value)
const {
isProcessing,
isPaused,
lastScan,
scanned,
onDecode,
resume,
clearScanned,
dismissLastScan,
} = useTicketScanner(activityId)
const scannerOpen = ref(true)
@ -93,10 +92,8 @@ function fmtTime(iso: string) {
</Button>
</div>
<!-- Last-scan result. Sticks until the operator taps "Scan
next" gives them time to verify the outcome and act on
it (let the attendee in, deny entry, etc.) before the
decode loop picks up the next QR. -->
<!-- Last-scan banner single line, dismissable. Keeps the
scanner viewport clean while still surfacing the result. -->
<div
v-if="lastScan"
class="mt-4 p-4 rounded-lg border flex items-start gap-3"
@ -108,18 +105,18 @@ function fmtTime(iso: string) {
>
<CheckCircle2
v-if="lastScanVariant === 'success'"
class="w-6 h-6 text-emerald-500 shrink-0 mt-0.5"
class="w-5 h-5 text-emerald-500 shrink-0 mt-0.5"
/>
<Clock
v-else-if="lastScanVariant === 'warning'"
class="w-6 h-6 text-amber-500 shrink-0 mt-0.5"
class="w-5 h-5 text-amber-500 shrink-0 mt-0.5"
/>
<AlertCircle
v-else
class="w-6 h-6 text-destructive shrink-0 mt-0.5"
class="w-5 h-5 text-destructive shrink-0 mt-0.5"
/>
<div class="flex-1 min-w-0">
<p class="text-base font-semibold text-foreground">
<p class="text-sm font-medium text-foreground">
<template v-if="lastScan.status === 'ok'">
Registered
<span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground">
@ -133,29 +130,18 @@ function fmtTime(iso: string) {
{{ lastScan.message || 'Scan failed' }}
</template>
</p>
<p class="text-xs font-mono text-muted-foreground break-all mt-1">
<p class="text-xs font-mono text-muted-foreground break-all mt-0.5">
{{ lastScan.ticketId }}
</p>
</div>
<Button variant="ghost" size="sm" class="shrink-0" @click="dismissLastScan">
Dismiss
</Button>
</div>
<!-- "Scan next" CTA primary action when a result is pending,
so the operator's hand goes to the same place every time
(full-width button below the result). Disabled while the
RPC is still in-flight. -->
<Button
v-if="isPaused"
class="w-full mt-3 gap-1.5"
size="lg"
:disabled="isProcessing"
@click="resume"
>
<ScanLine class="w-4 h-4" />
<span v-if="isProcessing">Sending</span>
<span v-else>Scan next</span>
</Button>
<!-- Processing hint -->
<p
v-else-if="isProcessing"
v-if="isProcessing"
class="text-xs text-center text-muted-foreground mt-3"
>
Sending registration over Nostr