Compare commits
No commits in common. "2498fbe51856e6f87a7ca22f5b7d8745aef8fc3b" and "0f8f98d4c57b7fb8b02201b878114a47276e8c6b" have entirely different histories.
2498fbe518
...
0f8f98d4c5
8 changed files with 25 additions and 96 deletions
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) -->
|
||||||
|
|
|
||||||
|
|
@ -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…
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue