feat(activities): restructure bottom nav around Home/MyTickets/Hosting

The bottom-nav tabs become Home, My tickets, Hosting, Map, Favorites.
- Feed is relabeled "Home" (en/fr; es was already "Inicio").
- My tickets and Hosting move out of the sidebar menu back into the
  bottom nav. Hosting is a synthetic tab — no path of its own; it
  toggles the existing onlyHosting feed filter and lands on
  /activities, with Home as the inverse (clears the filter on tap).
- Calendar leaves the bottom nav. The week strip now ends with a
  small calendar icon button that routes to /activities/calendar,
  so the entry point sits adjacent to the date UI instead of
  competing for a tab slot.
- Create activity leaves the bottom nav too. A full-width "+ Create
  activity" CTA appears at the top of the feed only when the Hosting
  tab is active, so the Create entry point lives inside the section
  it belongs to.

BottomTab gains an optional `isActive()` predicate so tabs whose
active condition doesn't reduce to "current path starts with x"
(e.g. Hosting) can compute their own state.
This commit is contained in:
Padreug 2026-06-04 22:21:41 +02:00 committed by padreug
commit 78e3d56d76
5 changed files with 115 additions and 64 deletions

View file

@ -3,10 +3,9 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { CalendarDays, Map, Heart, Search, Plus, Ticket, Megaphone } from 'lucide-vue-next'
import { Home, Map, Heart, Ticket, Megaphone } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
import type { SidebarNavItem } from '@/components/layout/StandaloneMenu.vue'
import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '@/modules/activities/stores/activities'
import { useActivities } from '@/modules/activities/composables/useActivities'
@ -24,38 +23,71 @@ const activitiesStore = useActivitiesStore()
const { isAdmin, autoApprove } = useApprovalState()
// Used to merge own LNbits drafts into the activities feed right after
// the user creates or edits an event otherwise the new draft only
// surfaces on the next ActivitiesPage subscribe cycle.
// The hosting filter also lives on the activities composable; the
// sidebar entry below mirrors what the old in-page chip used to do.
// surfaces on the next ActivitiesPage subscribe cycle. `onlyHosting`
// is the feed filter that backs the Hosting bottom-nav tab tapping
// it toggles the filter on; Home tab toggles it off.
const { loadOwnEvents, onlyHosting, toggleHosting } = useActivities()
// Settings dropped theme/lang/currency now live in the shared profile sheet.
// Create lives in the bottom nav: when logged out, tapping it shows an
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
// opening the dialog. Per-app placement deliberation tracked at #53.
// True for /activities and its sub-routes (incl. detail pages) but
// not for the routes owned by other tabs (map/favorites). Used by
// both Home and Hosting active-state predicates so the highlight
// only shifts based on the onlyHosting flag while you're in the feed.
function inFeedRoute(): boolean {
if (route.path.startsWith('/activities/map')) return false
if (route.path.startsWith('/activities/favorites')) return false
return route.path === '/activities' || route.path.startsWith('/activities/')
}
const tabs = computed<BottomTab[]>(() => [
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
{
name: t('activities.createNew'),
icon: Plus,
name: t('activities.nav.feed'),
icon: Home,
onClick: () => {
// Tapping Home clears the hosting filter so the feed always
// returns to the unfiltered view, regardless of where the
// user just came from.
if (onlyHosting.value) toggleHosting()
if (route.path !== '/activities') router.push('/activities')
},
isActive: () => inFeedRoute() && !onlyHosting.value,
},
{
name: t('activities.filters.myTickets'),
icon: Ticket,
path: '/my-tickets',
onClick: () => {
if (!isAuthenticated.value) {
toast.info('Log in to create an activity', {
toast.info(t('activities.detail.loginToBuyTickets'), {
action: {
label: 'Log in',
label: t('activities.detail.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
// Defensively clear any lingering edit selection so the Create
// tap always opens in Create mode regardless of a prior Edit.
activitiesStore.editingEvent = null
activitiesStore.showCreateDialog = true
router.push('/my-tickets')
},
disabled: !isAuthenticated.value,
},
{
name: t('activities.filters.hosting'),
icon: Megaphone,
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('activities.hosting.loginPrompt', 'Log in to manage your hosted activities'), {
action: {
label: t('activities.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
if (!onlyHosting.value) toggleHosting()
if (route.path !== '/activities') router.push('/activities')
},
isActive: () => inFeedRoute() && onlyHosting.value,
disabled: !isAuthenticated.value,
},
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
{
name: t('activities.nav.favorites'),
@ -80,43 +112,8 @@ const tabs = computed<BottomTab[]>(() => [
},
])
// Sidebar entries shown to authed users only. "My tickets" routes to
// the dedicated /my-tickets page; "Hosting" toggles the feed filter
// (no dedicated page yet) and lands on /activities so the user can
// see the filtered list.
const sidebarNav = computed<SidebarNavItem[]>(() => {
if (!isAuthenticated.value) return []
return [
{
name: t('activities.filters.myTickets', 'My tickets'),
icon: Ticket,
path: '/my-tickets',
isActive: () => route.path.startsWith('/my-tickets'),
},
{
name: t('activities.filters.hosting', 'Hosting'),
icon: Megaphone,
onClick: () => {
if (!onlyHosting.value) toggleHosting()
if (!route.path.startsWith('/activities')) router.push('/activities')
},
isActive: () => onlyHosting.value,
},
]
})
// Feed tab is active for the bare /activities route AND all sub-paths that
// aren't owned by another tab (e.g. /activities/<id> detail pages).
// Path-based fallback for tabs that don't carry their own `isActive`.
function isActive(path: string): boolean {
if (path === '/activities') {
return (
route.path === '/activities' ||
(route.path.startsWith('/activities/') &&
!route.path.startsWith('/activities/calendar') &&
!route.path.startsWith('/activities/map') &&
!route.path.startsWith('/activities/favorites'))
)
}
return route.path.startsWith(path)
}
@ -151,7 +148,7 @@ function handleDialogOpenChange(open: boolean) {
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" :sidebar-nav="sidebarNav">
<AppShell :tabs="tabs" :is-active="isActive">
<CreateEventDialog
:open="activitiesStore.showCreateDialog"
:event="activitiesStore.editingEvent"

View file

@ -17,6 +17,11 @@ export interface BottomTab {
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
* for auth-required tabs when the user is logged out. */
disabled?: boolean
/** Per-tab active-state override for entries whose active condition
* doesn't reduce to "current route starts with this.path" e.g. a
* "Hosting" tab that is active when a feed-filter ref is on. When
* set it wins over the App-level `isActive(path)` matcher. */
isActive?: () => boolean
}
interface Props {
@ -37,6 +42,11 @@ function onTabClick(tab: BottomTab) {
}
if (tab.path) router.push(tab.path)
}
function isTabActive(tab: BottomTab): boolean {
if (tab.isActive) return tab.isActive()
return !!tab.path && props.isActive(tab.path)
}
</script>
<template>
@ -51,12 +61,12 @@ function onTabClick(tab: BottomTab) {
:key="tab.name"
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="[
tab.path && props.isActive(tab.path)
isTabActive(tab)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
tab.disabled ? 'opacity-50' : '',
]"
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
:aria-current="isTabActive(tab) ? 'page' : undefined"
@click="onTabClick(tab)"
>
<component :is="tab.icon" class="w-5 h-5" />

View file

@ -131,7 +131,7 @@ const messages: LocaleMessages = {
registered: 'Registered',
},
nav: {
feed: 'Feed',
feed: 'Home',
calendar: 'Calendar',
map: 'Map',
favorites: 'Favorites',

View file

@ -131,7 +131,7 @@ const messages: LocaleMessages = {
registered: 'Enregistré',
},
nav: {
feed: 'Fil',
feed: 'Accueil',
calendar: 'Calendrier',
map: 'Carte',
favorites: 'Favoris',

View file

@ -9,8 +9,9 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Separator } from '@/components/ui/separator'
import { SlidersHorizontal, History } from 'lucide-vue-next'
import { SlidersHorizontal, History, CalendarDays, Plus } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import { useActivitiesStore } from '../stores/activities'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
@ -20,6 +21,7 @@ import type { Activity } from '../types/activity'
const router = useRouter()
const { t } = useI18n()
const activitiesStore = useActivitiesStore()
const {
activities,
@ -30,6 +32,7 @@ const {
hasActiveFilters,
selectedDate,
showPast,
onlyHosting,
selectDate,
setTemporal,
toggleCategory,
@ -55,6 +58,19 @@ onMounted(() => {
function handleSelectActivity(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
}
// Create-activity CTA in the Hosting view. Calendar-tab page lives
// on /activities/calendar; the icon button at the end of the date
// strip is the only entry point now that the bottom-nav Calendar
// tab is gone.
function openCreate() {
activitiesStore.editingEvent = null
activitiesStore.showCreateDialog = true
}
function openCalendar() {
router.push('/activities/calendar')
}
</script>
<template>
@ -72,9 +88,24 @@ function handleSelectActivity(activity: Activity) {
/>
</div>
<!-- Date picker strip (p'a semana style) -->
<div class="mb-3">
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
<!-- Date picker strip + calendar shortcut. The calendar icon used
to be a bottom-nav tab; it now lives on the right of the week
strip so the tabs row stays focused on the primary views. -->
<div class="mb-3 flex items-center gap-2">
<DatePickerStrip
class="flex-1 min-w-0"
:selected-date="selectedDate"
@select="selectDate"
/>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:aria-label="t('activities.nav.calendar')"
@click="openCalendar"
>
<CalendarDays class="h-4 w-4" />
</Button>
</div>
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
@ -136,6 +167,19 @@ function handleSelectActivity(activity: Activity) {
</CollapsibleContent>
</Collapsible>
<!-- Create-activity CTA shown when the Hosting bottom-nav tab is
active. Replaces the dedicated Create entry that used to live
in the bottom nav; lives here so it shows up exactly when the
user is in the "events I'm running" view. -->
<Button
v-if="onlyHosting"
class="w-full mb-3 gap-1.5"
@click="openCreate"
>
<Plus class="w-4 h-4" />
{{ t('activities.createNew') }}
</Button>
<!-- Error state -->
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
{{ error }}