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