From 5ebf0582e035bb43de448db633300edc4318d5dd Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 24 May 2026 17:39:31 +0200 Subject: [PATCH 1/2] feat(activities): "Hosting" filter chip on the activities feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the "My tickets" chip from #71. Where "My tickets" narrows the feed to events you're attending, "Hosting" narrows it to events you're organizing — reading `activity.isMine` which useActivities.tagOwnership() already populates from organizer pubkey match + own LNbits drafts. Naming rationale: "My events" would have been ambiguous with favorited / bookmarked. "Hosting" is short, role-oriented, and pairs as the natural counterpart to "My tickets" (attending vs. organizing). Spanish/French translations lean on the verb form ("Organizo" / "J'organise") since those languages don't have a clean noun equivalent. - useActivityFilters: onlyHosting flag, toggleHosting action, resetFilters clears it, hasActiveFilters lights up. - applyFilters filters by `a.isMine === true` when the flag is on. Composes with category / temporal / "My tickets" via the same intersection chain. - ActivitiesPage: chip rendered alongside "My tickets" with the Megaphone icon (lucide). Hidden when logged out. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/i18n/locales/en.ts | 1 + src/i18n/locales/es.ts | 1 + src/i18n/locales/fr.ts | 1 + src/i18n/types.ts | 1 + .../composables/useActivityFilters.ts | 24 ++++++++++++++++++- .../activities/views/ActivitiesPage.vue | 21 ++++++++++++---- 6 files changed, 43 insertions(+), 6 deletions(-) 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/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/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) { - -
+ +
+
From 2498fbe51856e6f87a7ca22f5b7d8745aef8fc3b Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 24 May 2026 18:00:21 +0200 Subject: [PATCH 2/2] fix(activities): pause scanner after each decode, require tap to scan next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a pause gate the qr-scanner's 5-fps decode loop instantly fires another scan on whatever QR is still in frame — most visibly, the ticket that just registered immediately re-fires as "already scanned this session". The operator at the door never gets a beat to confirm the result or act on it (let the attendee in, deny entry, redirect to manual lookup, etc.). useTicketScanner gains an `isPaused` ref that flips to true the moment a decode resolves (success, error, or duplicate-session de-dup) and gates further `onDecode` calls. The camera keeps streaming so resumption is instant — only the decode handler is muted. The page replaces the small "Dismiss" button with a full-width "Scan next" CTA below the result banner. Same place every time so the operator's hand can stay in muscle memory; disabled while the in-flight RPC is still sending. Result banner upgrades to a slightly larger icon + label so the success/failure is readable at arm's length over the venue. `clearScanned` also resets `isPaused` so the operator can recover from a stuck state via the "Clear list" affordance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/useTicketScanner.ts | 32 +++++++++++--- .../activities/views/ScanTicketsPage.vue | 42 ++++++++++++------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/modules/activities/composables/useTicketScanner.ts b/src/modules/activities/composables/useTicketScanner.ts index 638b8bc..50006a1 100644 --- a/src/modules/activities/composables/useTicketScanner.ts +++ b/src/modules/activities/composables/useTicketScanner.ts @@ -39,6 +39,17 @@ export function useTicketScanner(activityId: Ref) { 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}`, [], @@ -51,7 +62,7 @@ export function useTicketScanner(activityId: Ref) { } async function onDecode(qrText: string): Promise { - if (isProcessing.value) return + if (isProcessing.value || isPaused.value) return const ticketId = parseTicketId(qrText).trim() if (!ticketId) return @@ -60,6 +71,7 @@ export function useTicketScanner(activityId: Ref) { // 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 } @@ -90,24 +102,32 @@ export function useTicketScanner(activityId: Ref) { } } 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 - } - - function dismissLastScan() { - lastScan.value = null + isPaused.value = false } return { isProcessing, + isPaused, lastScan, scanned, onDecode, + resume, clearScanned, - dismissLastScan, } } diff --git a/src/modules/activities/views/ScanTicketsPage.vue b/src/modules/activities/views/ScanTicketsPage.vue index 4a2d74e..67c51f5 100644 --- a/src/modules/activities/views/ScanTicketsPage.vue +++ b/src/modules/activities/views/ScanTicketsPage.vue @@ -1,7 +1,7 @@