feat(activities): hide past events by default + "Past events" filter chip #77
8 changed files with 119 additions and 23 deletions
|
|
@ -59,6 +59,8 @@ const messages: LocaleMessages = {
|
|||
thisMonth: 'This Month',
|
||||
myTickets: 'My tickets',
|
||||
hosting: 'Hosting',
|
||||
pastEvents: 'Past events',
|
||||
past: 'Past',
|
||||
},
|
||||
categories: {
|
||||
concert: 'Concert',
|
||||
|
|
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
|
|||
buyAnotherTicket: 'Buy another ticket',
|
||||
viewMyTickets: 'View in My Tickets',
|
||||
soldOut: 'Sold Out',
|
||||
pastEvent: 'This event has already happened',
|
||||
free: 'Free',
|
||||
},
|
||||
tickets: {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ const messages: LocaleMessages = {
|
|||
thisMonth: 'Este mes',
|
||||
myTickets: 'Mis boletos',
|
||||
hosting: 'Organizo',
|
||||
pastEvents: 'Eventos pasados',
|
||||
past: 'Pasado',
|
||||
},
|
||||
categories: {
|
||||
concert: 'Concierto',
|
||||
|
|
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
|
|||
buyAnotherTicket: 'Comprar otro boleto',
|
||||
viewMyTickets: 'Ver en Mis boletos',
|
||||
soldOut: 'Agotado',
|
||||
pastEvent: 'Este evento ya pasó',
|
||||
free: 'Gratis',
|
||||
},
|
||||
tickets: {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ const messages: LocaleMessages = {
|
|||
thisMonth: 'Ce mois-ci',
|
||||
myTickets: 'Mes billets',
|
||||
hosting: 'J\'organise',
|
||||
pastEvents: 'Événements passés',
|
||||
past: 'Passé',
|
||||
},
|
||||
categories: {
|
||||
concert: 'Concert',
|
||||
|
|
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
|
|||
buyAnotherTicket: 'Acheter un autre billet',
|
||||
viewMyTickets: 'Voir dans Mes billets',
|
||||
soldOut: 'Épuisé',
|
||||
pastEvent: 'Cet événement est déjà passé',
|
||||
free: 'Gratuit',
|
||||
},
|
||||
tickets: {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ export interface LocaleMessages {
|
|||
thisMonth: string
|
||||
myTickets: string
|
||||
hosting: string
|
||||
pastEvents: string
|
||||
past: string
|
||||
}
|
||||
categories: Record<string, string>
|
||||
detail: {
|
||||
|
|
@ -79,6 +81,7 @@ export interface LocaleMessages {
|
|||
buyAnotherTicket: string
|
||||
viewMyTickets: string
|
||||
soldOut: string
|
||||
pastEvent: string
|
||||
free: string
|
||||
}
|
||||
tickets: {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { format } from 'date-fns'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
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 { useDateLocale } from '../composables/useDateLocale'
|
||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||
|
|
@ -58,6 +58,13 @@ const placeholderBg = computed(() => {
|
|||
const hue = hash % 360
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -121,6 +128,22 @@ const placeholderBg = computed(() => {
|
|||
>
|
||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</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>
|
||||
|
||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ export function useActivityFilters() {
|
|||
* which `useActivities.tagOwnership()` populates.
|
||||
*/
|
||||
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>(() => ({
|
||||
temporal: temporal.value,
|
||||
|
|
@ -41,7 +49,9 @@ export function useActivityFilters() {
|
|||
function applyFilters(activities: Activity[]): Activity[] {
|
||||
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/upcoming split
|
||||
// so the user can browse activities for any day they choose.
|
||||
if (selectedDate.value) {
|
||||
const dayStart = startOfDay(selectedDate.value)
|
||||
const dayEnd = endOfDay(selectedDate.value)
|
||||
|
|
@ -52,6 +62,16 @@ export function useActivityFilters() {
|
|||
} else {
|
||||
// Temporal filter
|
||||
result = applyTemporalFilter(result, temporal.value)
|
||||
// Past/upcoming split — the chip narrows to one side of "now",
|
||||
// mirroring the "My tickets" / "Hosting" mental model. Default
|
||||
// (showPast=false) is upcoming-only; toggling on flips to
|
||||
// past-only. Composes with temporal pills: "This Week" +
|
||||
// showPast=true shows only the days already passed this week.
|
||||
const now = new Date()
|
||||
result = result.filter(a => {
|
||||
const activityEnd = a.endDate ?? a.startDate
|
||||
return showPast.value ? activityEnd < now : activityEnd >= now
|
||||
})
|
||||
}
|
||||
|
||||
// Category filter
|
||||
|
|
@ -104,6 +124,7 @@ export function useActivityFilters() {
|
|||
selectedDate.value = undefined
|
||||
onlyOwnedTickets.value = false
|
||||
onlyHosting.value = false
|
||||
showPast.value = false
|
||||
}
|
||||
|
||||
function toggleOwnedTickets() {
|
||||
|
|
@ -114,12 +135,17 @@ export function useActivityFilters() {
|
|||
onlyHosting.value = !onlyHosting.value
|
||||
}
|
||||
|
||||
function togglePast() {
|
||||
showPast.value = !showPast.value
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
temporal.value !== 'all' ||
|
||||
selectedCategories.value.length > 0 ||
|
||||
selectedDate.value !== undefined ||
|
||||
onlyOwnedTickets.value ||
|
||||
onlyHosting.value
|
||||
onlyHosting.value ||
|
||||
showPast.value
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
@ -129,6 +155,7 @@ export function useActivityFilters() {
|
|||
selectedDate,
|
||||
onlyOwnedTickets,
|
||||
onlyHosting,
|
||||
showPast,
|
||||
filters,
|
||||
hasActiveFilters,
|
||||
|
||||
|
|
@ -140,6 +167,7 @@ export function useActivityFilters() {
|
|||
clearCategories,
|
||||
toggleOwnedTickets,
|
||||
toggleHosting,
|
||||
togglePast,
|
||||
resetFilters,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} 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 { useAuth } from '@/composables/useAuthService'
|
||||
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
|
||||
|
|
@ -31,12 +31,14 @@ const {
|
|||
selectedDate,
|
||||
onlyOwnedTickets,
|
||||
onlyHosting,
|
||||
showPast,
|
||||
selectDate,
|
||||
setTemporal,
|
||||
toggleCategory,
|
||||
clearCategories,
|
||||
toggleOwnedTickets,
|
||||
toggleHosting,
|
||||
togglePast,
|
||||
resetFilters,
|
||||
subscribe,
|
||||
} = useActivities()
|
||||
|
|
@ -81,27 +83,40 @@ function handleSelectActivity(activity: Activity) {
|
|||
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||
</div>
|
||||
|
||||
<!-- Role filter chips — narrow the feed to activities the user
|
||||
has skin in. Hidden when logged out (nothing to filter on).
|
||||
"My tickets" = attending; "Hosting" = organizing. -->
|
||||
<div v-if="isAuthenticated" class="mb-4 flex flex-wrap gap-2">
|
||||
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
||||
"Hosting") narrow the feed to activities the signed-in user
|
||||
has skin in and are hidden when logged out. The "Past events"
|
||||
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
|
||||
:variant="onlyOwnedTickets ? 'default' : 'outline'"
|
||||
:variant="showPast ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="toggleOwnedTickets"
|
||||
@click="togglePast"
|
||||
>
|
||||
<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') }}
|
||||
<History class="w-3.5 h-3.5" />
|
||||
{{ t('activities.filters.pastEvents', 'Past events') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine,
|
||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
|
||||
} from 'lucide-vue-next'
|
||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||
|
|
@ -130,6 +130,17 @@ const canBuyTicket = computed(() => {
|
|||
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)
|
||||
|
||||
function openPurchaseDialog() {
|
||||
|
|
@ -320,7 +331,14 @@ function goToMyTickets() {
|
|||
</Button>
|
||||
</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
|
||||
class="w-full gap-1.5"
|
||||
size="lg"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue