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', thisWeek: 'This Week',
thisMonth: 'This Month', thisMonth: 'This Month',
myTickets: 'My tickets', myTickets: 'My tickets',
hosting: 'Hosting',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',

View file

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

View file

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

View file

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

View file

@ -22,13 +22,6 @@ export function useActivityFilters() {
* (this composable stays free of ticket fetching). * (this composable stays free of ticket fetching).
*/ */
const onlyOwnedTickets = ref(false) 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>(() => ({ const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value, 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 return result
} }
@ -103,23 +89,17 @@ export function useActivityFilters() {
selectedCategories.value = [] selectedCategories.value = []
selectedDate.value = undefined selectedDate.value = undefined
onlyOwnedTickets.value = false onlyOwnedTickets.value = false
onlyHosting.value = false
} }
function toggleOwnedTickets() { function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value onlyOwnedTickets.value = !onlyOwnedTickets.value
} }
function toggleHosting() {
onlyHosting.value = !onlyHosting.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 || onlyOwnedTickets.value
onlyHosting.value
) )
return { return {
@ -128,7 +108,6 @@ export function useActivityFilters() {
selectedCategories, selectedCategories,
selectedDate, selectedDate,
onlyOwnedTickets, onlyOwnedTickets,
onlyHosting,
filters, filters,
hasActiveFilters, hasActiveFilters,
@ -139,7 +118,6 @@ export function useActivityFilters() {
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets, toggleOwnedTickets,
toggleHosting,
resetFilters, resetFilters,
} }
} }

View file

@ -39,17 +39,6 @@ export function useTicketScanner(activityId: Ref<string>) {
const isProcessing = ref(false) const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null) 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[]>( const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`, () => `activities_scanned_${activityId.value}`,
[], [],
@ -62,7 +51,7 @@ export function useTicketScanner(activityId: Ref<string>) {
} }
async function onDecode(qrText: string): Promise<void> { async function onDecode(qrText: string): Promise<void> {
if (isProcessing.value || isPaused.value) return if (isProcessing.value) return
const ticketId = parseTicketId(qrText).trim() const ticketId = parseTicketId(qrText).trim()
if (!ticketId) return if (!ticketId) return
@ -71,7 +60,6 @@ export function useTicketScanner(activityId: Ref<string>) {
// of the camera for multiple decode frames. // of the camera for multiple decode frames.
if (scanned.value.some(r => r.ticketId === ticketId)) { if (scanned.value.some(r => r.ticketId === ticketId)) {
lastScan.value = { status: 'duplicate-session', ticketId } lastScan.value = { status: 'duplicate-session', ticketId }
isPaused.value = true
return return
} }
@ -102,32 +90,24 @@ export function useTicketScanner(activityId: Ref<string>) {
} }
} finally { } finally {
isProcessing.value = false 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() { function clearScanned() {
scanned.value = [] scanned.value = []
lastScan.value = null lastScan.value = null
isPaused.value = false }
function dismissLastScan() {
lastScan.value = null
} }
return { return {
isProcessing, isProcessing,
isPaused,
lastScan, lastScan,
scanned, scanned,
onDecode, onDecode,
resume,
clearScanned, clearScanned,
dismissLastScan,
} }
} }

View file

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

View file

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