feat(activities): hide past events by default + "Past events" filter chip #77
8 changed files with 118 additions and 23 deletions
feat(activities): hide past events by default + "Past events" filter chip
Closes aiolabs/webapp#72. useActivityFilters gains `showPast` (default false) and `togglePast`. applyFilters drops activities whose end-or-start date is before now unless the chip is toggled on. Sits next to the existing temporal filter inside the no-selectedDate branch, so picking a specific past date in the DatePickerStrip still surfaces that day's activities — mirroring how date-pick already bypasses the temporal pills. Counts as an active filter and resets cleanly. ActivitiesPage adds the chip in the role-filter row, outside the auth gate so logged-out users can still browse past events. Uses the lucide `History` icon. ActivityCard gains an `isPast` computed and renders a small Past badge bottom-left when applicable, suppressed when a Pending / Rejected status badge is already taking that slot (creator's own past draft — vanishingly rare, status hint is more actionable). ActivityDetailPage replaces the Buy ticket CTA with a muted "This event has already happened" notice when the event is past, so the buy flow stays unambiguous even when the user lands on a past event by direct link. The owned-tickets pill above still renders so past attendees can still see their tickets. i18n: pastEvents (chip) + past (badge) + pastEvent (detail notice) added to en/es/fr.
commit
f6c15beb81
|
|
@ -59,6 +59,8 @@ const messages: LocaleMessages = {
|
||||||
thisMonth: 'This Month',
|
thisMonth: 'This Month',
|
||||||
myTickets: 'My tickets',
|
myTickets: 'My tickets',
|
||||||
hosting: 'Hosting',
|
hosting: 'Hosting',
|
||||||
|
pastEvents: 'Past events',
|
||||||
|
past: 'Past',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
|
||||||
buyAnotherTicket: 'Buy another ticket',
|
buyAnotherTicket: 'Buy another ticket',
|
||||||
viewMyTickets: 'View in My Tickets',
|
viewMyTickets: 'View in My Tickets',
|
||||||
soldOut: 'Sold Out',
|
soldOut: 'Sold Out',
|
||||||
|
pastEvent: 'This event has already happened',
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ const messages: LocaleMessages = {
|
||||||
thisMonth: 'Este mes',
|
thisMonth: 'Este mes',
|
||||||
myTickets: 'Mis boletos',
|
myTickets: 'Mis boletos',
|
||||||
hosting: 'Organizo',
|
hosting: 'Organizo',
|
||||||
|
pastEvents: 'Eventos pasados',
|
||||||
|
past: 'Pasado',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concierto',
|
concert: 'Concierto',
|
||||||
|
|
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
|
||||||
buyAnotherTicket: 'Comprar otro boleto',
|
buyAnotherTicket: 'Comprar otro boleto',
|
||||||
viewMyTickets: 'Ver en Mis boletos',
|
viewMyTickets: 'Ver en Mis boletos',
|
||||||
soldOut: 'Agotado',
|
soldOut: 'Agotado',
|
||||||
|
pastEvent: 'Este evento ya pasó',
|
||||||
free: 'Gratis',
|
free: 'Gratis',
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ const messages: LocaleMessages = {
|
||||||
thisMonth: 'Ce mois-ci',
|
thisMonth: 'Ce mois-ci',
|
||||||
myTickets: 'Mes billets',
|
myTickets: 'Mes billets',
|
||||||
hosting: 'J\'organise',
|
hosting: 'J\'organise',
|
||||||
|
pastEvents: 'Événements passés',
|
||||||
|
past: 'Passé',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
|
||||||
buyAnotherTicket: 'Acheter un autre billet',
|
buyAnotherTicket: 'Acheter un autre billet',
|
||||||
viewMyTickets: 'Voir dans Mes billets',
|
viewMyTickets: 'Voir dans Mes billets',
|
||||||
soldOut: 'Épuisé',
|
soldOut: 'Épuisé',
|
||||||
|
pastEvent: 'Cet événement est déjà passé',
|
||||||
free: 'Gratuit',
|
free: 'Gratuit',
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ export interface LocaleMessages {
|
||||||
thisMonth: string
|
thisMonth: string
|
||||||
myTickets: string
|
myTickets: string
|
||||||
hosting: string
|
hosting: string
|
||||||
|
pastEvents: string
|
||||||
|
past: string
|
||||||
}
|
}
|
||||||
categories: Record<string, string>
|
categories: Record<string, string>
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -79,6 +81,7 @@ export interface LocaleMessages {
|
||||||
buyAnotherTicket: string
|
buyAnotherTicket: string
|
||||||
viewMyTickets: string
|
viewMyTickets: string
|
||||||
soldOut: string
|
soldOut: string
|
||||||
|
pastEvent: string
|
||||||
free: string
|
free: string
|
||||||
}
|
}
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { MapPin, Calendar, Ticket, User, CheckCircle2 } from 'lucide-vue-next'
|
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
|
||||||
import BookmarkButton from './BookmarkButton.vue'
|
import BookmarkButton from './BookmarkButton.vue'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
|
|
@ -58,6 +58,13 @@ const placeholderBg = computed(() => {
|
||||||
const hue = hash % 360
|
const hue = hash % 360
|
||||||
return `hsl(${hue}, 40%, 85%)`
|
return `hsl(${hue}, 40%, 85%)`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isPast = computed(() => {
|
||||||
|
const a = props.activity
|
||||||
|
const end = a.endDate ?? a.startDate
|
||||||
|
if (!end || isNaN(end.getTime())) return false
|
||||||
|
return end.getTime() < Date.now()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -121,6 +128,22 @@ const placeholderBg = computed(() => {
|
||||||
>
|
>
|
||||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
|
<!-- Past badge — shown when the activity has already ended.
|
||||||
|
Only relevant on the feed when the "Past events" filter
|
||||||
|
chip is toggled on (otherwise these cards aren't rendered);
|
||||||
|
on the detail page the card view isn't used. Suppressed
|
||||||
|
when a pending/rejected status badge is taking the same
|
||||||
|
slot — that case is the creator's own past draft, which is
|
||||||
|
vanishingly rare and the status hint is more actionable. -->
|
||||||
|
<Badge
|
||||||
|
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
|
||||||
|
variant="outline"
|
||||||
|
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||||
|
>
|
||||||
|
<History class="w-3 h-3" />
|
||||||
|
{{ t('activities.filters.past', 'Past') }}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ export function useActivityFilters() {
|
||||||
* which `useActivities.tagOwnership()` populates.
|
* which `useActivities.tagOwnership()` populates.
|
||||||
*/
|
*/
|
||||||
const onlyHosting = ref(false)
|
const onlyHosting = ref(false)
|
||||||
|
/**
|
||||||
|
* When false (default), activities that have already ended are
|
||||||
|
* hidden from the feed. Toggling on includes them so the user can
|
||||||
|
* browse past events. The date-picker overrides this — picking a
|
||||||
|
* specific past date shows that day's activities regardless,
|
||||||
|
* mirroring how it overrides the temporal pills.
|
||||||
|
*/
|
||||||
|
const showPast = ref(false)
|
||||||
|
|
||||||
const filters = computed<ActivityFilters>(() => ({
|
const filters = computed<ActivityFilters>(() => ({
|
||||||
temporal: temporal.value,
|
temporal: temporal.value,
|
||||||
|
|
@ -41,7 +49,9 @@ export function useActivityFilters() {
|
||||||
function applyFilters(activities: Activity[]): Activity[] {
|
function applyFilters(activities: Activity[]): Activity[] {
|
||||||
let result = activities
|
let result = activities
|
||||||
|
|
||||||
// Specific date filter (from DatePickerStrip) takes priority over temporal
|
// Specific date filter (from DatePickerStrip) takes priority over
|
||||||
|
// temporal. Picking a date also bypasses the past-events hide so
|
||||||
|
// the user can browse activities for any day they choose.
|
||||||
if (selectedDate.value) {
|
if (selectedDate.value) {
|
||||||
const dayStart = startOfDay(selectedDate.value)
|
const dayStart = startOfDay(selectedDate.value)
|
||||||
const dayEnd = endOfDay(selectedDate.value)
|
const dayEnd = endOfDay(selectedDate.value)
|
||||||
|
|
@ -52,6 +62,15 @@ export function useActivityFilters() {
|
||||||
} else {
|
} else {
|
||||||
// Temporal filter
|
// Temporal filter
|
||||||
result = applyTemporalFilter(result, temporal.value)
|
result = applyTemporalFilter(result, temporal.value)
|
||||||
|
// Past-events hide (default ON). Composes with temporal pills:
|
||||||
|
// e.g. "This Week" + showPast=false drops days already passed.
|
||||||
|
if (!showPast.value) {
|
||||||
|
const now = new Date()
|
||||||
|
result = result.filter(a => {
|
||||||
|
const activityEnd = a.endDate ?? a.startDate
|
||||||
|
return activityEnd >= now
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category filter
|
// Category filter
|
||||||
|
|
@ -104,6 +123,7 @@ export function useActivityFilters() {
|
||||||
selectedDate.value = undefined
|
selectedDate.value = undefined
|
||||||
onlyOwnedTickets.value = false
|
onlyOwnedTickets.value = false
|
||||||
onlyHosting.value = false
|
onlyHosting.value = false
|
||||||
|
showPast.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOwnedTickets() {
|
function toggleOwnedTickets() {
|
||||||
|
|
@ -114,12 +134,17 @@ export function useActivityFilters() {
|
||||||
onlyHosting.value = !onlyHosting.value
|
onlyHosting.value = !onlyHosting.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePast() {
|
||||||
|
showPast.value = !showPast.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
|
onlyHosting.value ||
|
||||||
|
showPast.value
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -129,6 +154,7 @@ export function useActivityFilters() {
|
||||||
selectedDate,
|
selectedDate,
|
||||||
onlyOwnedTickets,
|
onlyOwnedTickets,
|
||||||
onlyHosting,
|
onlyHosting,
|
||||||
|
showPast,
|
||||||
filters,
|
filters,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
|
|
||||||
|
|
@ -140,6 +166,7 @@ export function useActivityFilters() {
|
||||||
clearCategories,
|
clearCategories,
|
||||||
toggleOwnedTickets,
|
toggleOwnedTickets,
|
||||||
toggleHosting,
|
toggleHosting,
|
||||||
|
togglePast,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, Megaphone, History } 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'
|
||||||
|
|
@ -31,12 +31,14 @@ const {
|
||||||
selectedDate,
|
selectedDate,
|
||||||
onlyOwnedTickets,
|
onlyOwnedTickets,
|
||||||
onlyHosting,
|
onlyHosting,
|
||||||
|
showPast,
|
||||||
selectDate,
|
selectDate,
|
||||||
setTemporal,
|
setTemporal,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
toggleOwnedTickets,
|
toggleOwnedTickets,
|
||||||
toggleHosting,
|
toggleHosting,
|
||||||
|
togglePast,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
subscribe,
|
subscribe,
|
||||||
} = useActivities()
|
} = useActivities()
|
||||||
|
|
@ -81,27 +83,40 @@ 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
|
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
||||||
has skin in. Hidden when logged out (nothing to filter on).
|
"Hosting") narrow the feed to activities the signed-in user
|
||||||
"My tickets" = attending; "Hosting" = organizing. -->
|
has skin in and are hidden when logged out. The "Past events"
|
||||||
<div v-if="isAuthenticated" class="mb-4 flex flex-wrap gap-2">
|
chip is always visible since past-browsing doesn't require an
|
||||||
|
account. -->
|
||||||
|
<div class="mb-4 flex flex-wrap gap-2">
|
||||||
|
<template v-if="isAuthenticated">
|
||||||
|
<Button
|
||||||
|
:variant="onlyOwnedTickets ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="gap-1.5"
|
||||||
|
@click="toggleOwnedTickets"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
<Button
|
<Button
|
||||||
:variant="onlyOwnedTickets ? 'default' : 'outline'"
|
:variant="showPast ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="gap-1.5"
|
class="gap-1.5"
|
||||||
@click="toggleOwnedTickets"
|
@click="togglePast"
|
||||||
>
|
>
|
||||||
<Ticket class="w-3.5 h-3.5" />
|
<History class="w-3.5 h-3.5" />
|
||||||
{{ t('activities.filters.myTickets', 'My tickets') }}
|
{{ t('activities.filters.pastEvents', 'Past events') }}
|
||||||
</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine,
|
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
|
|
@ -130,6 +130,17 @@ const canBuyTicket = computed(() => {
|
||||||
return info.available === undefined || info.available > 0
|
return info.available === undefined || info.available > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Past events can't be bought into. The notice below replaces the
|
||||||
|
// buy CTA so the flow is unambiguous — date alone is easy to miss
|
||||||
|
// on a long detail page.
|
||||||
|
const isPast = computed(() => {
|
||||||
|
const a = activity.value
|
||||||
|
if (!a) return false
|
||||||
|
const end = a.endDate ?? a.startDate
|
||||||
|
if (!end || isNaN(end.getTime())) return false
|
||||||
|
return end.getTime() < Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
const showPurchaseDialog = ref(false)
|
const showPurchaseDialog = ref(false)
|
||||||
|
|
||||||
function openPurchaseDialog() {
|
function openPurchaseDialog() {
|
||||||
|
|
@ -320,7 +331,14 @@ function goToMyTickets() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canBuyTicket">
|
<div
|
||||||
|
v-if="isPast"
|
||||||
|
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<History class="w-4 h-4 shrink-0" />
|
||||||
|
{{ t('activities.detail.pastEvent', 'This event has already happened') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="canBuyTicket">
|
||||||
<Button
|
<Button
|
||||||
class="w-full gap-1.5"
|
class="w-full gap-1.5"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue