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:
parent
520bbf46a7
commit
89b3dfc03d
5 changed files with 115 additions and 64 deletions
|
|
@ -17,6 +17,11 @@ export interface BottomTab {
|
||||||
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
|
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
|
||||||
* for auth-required tabs when the user is logged out. */
|
* for auth-required tabs when the user is logged out. */
|
||||||
disabled?: boolean
|
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 {
|
interface Props {
|
||||||
|
|
@ -37,6 +42,11 @@ function onTabClick(tab: BottomTab) {
|
||||||
}
|
}
|
||||||
if (tab.path) router.push(tab.path)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -51,12 +61,12 @@ function onTabClick(tab: BottomTab) {
|
||||||
:key="tab.name"
|
:key="tab.name"
|
||||||
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
tab.path && props.isActive(tab.path)
|
isTabActive(tab)
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
tab.disabled ? 'opacity-50' : '',
|
tab.disabled ? 'opacity-50' : '',
|
||||||
]"
|
]"
|
||||||
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
|
:aria-current="isTabActive(tab) ? 'page' : undefined"
|
||||||
@click="onTabClick(tab)"
|
@click="onTabClick(tab)"
|
||||||
>
|
>
|
||||||
<component :is="tab.icon" class="w-5 h-5" />
|
<component :is="tab.icon" class="w-5 h-5" />
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
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 AppShell from '@/components/layout/AppShell.vue'
|
||||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||||
import type { SidebarNavItem } from '@/components/layout/StandaloneMenu.vue'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useEventsStore } from '@/modules/events/stores/events'
|
import { useEventsStore } from '@/modules/events/stores/events'
|
||||||
import { useEvents } from '@/modules/events/composables/useEvents'
|
import { useEvents } from '@/modules/events/composables/useEvents'
|
||||||
|
|
@ -24,38 +23,71 @@ const eventsStore = useEventsStore()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
const { isAdmin, autoApprove } = useApprovalState()
|
||||||
// Used to merge own LNbits drafts into the events feed right after
|
// Used to merge own LNbits drafts into the events feed right after
|
||||||
// the user creates or edits an event — otherwise the new draft only
|
// the user creates or edits an event — otherwise the new draft only
|
||||||
// surfaces on the next EventsPage subscribe cycle.
|
// surfaces on the next EventsPage subscribe cycle. `onlyHosting`
|
||||||
// The hosting filter also lives on the events composable; the
|
// is the feed filter that backs the Hosting bottom-nav tab — tapping
|
||||||
// sidebar entry below mirrors what the old in-page chip used to do.
|
// it toggles the filter on; Home tab toggles it off.
|
||||||
const { loadOwnEvents, onlyHosting, toggleHosting } = useEvents()
|
const { loadOwnEvents, onlyHosting, toggleHosting } = useEvents()
|
||||||
|
|
||||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
// True for /events and its sub-routes (incl. detail pages) but
|
||||||
// Create lives in the bottom nav: when logged out, tapping it shows an
|
// not for the routes owned by other tabs (map/favorites). Used by
|
||||||
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
|
// both Home and Hosting active-state predicates so the highlight
|
||||||
// opening the dialog. Per-app placement deliberation tracked at #53.
|
// only shifts based on the onlyHosting flag while you're in the feed.
|
||||||
|
function inFeedRoute(): boolean {
|
||||||
|
if (route.path.startsWith('/events/map')) return false
|
||||||
|
if (route.path.startsWith('/events/favorites')) return false
|
||||||
|
return route.path === '/events' || route.path.startsWith('/events/')
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = computed<BottomTab[]>(() => [
|
const tabs = computed<BottomTab[]>(() => [
|
||||||
{ name: t('events.nav.feed'), icon: Search, path: '/events' },
|
|
||||||
{ name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' },
|
|
||||||
{
|
{
|
||||||
name: t('events.createNew'),
|
name: t('events.nav.feed'),
|
||||||
icon: Plus,
|
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 !== '/events') router.push('/events')
|
||||||
|
},
|
||||||
|
isActive: () => inFeedRoute() && !onlyHosting.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('events.filters.myTickets'),
|
||||||
|
icon: Ticket,
|
||||||
|
path: '/my-tickets',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
toast.info('Log in to create an event', {
|
toast.info(t('events.detail.loginToBuyTickets'), {
|
||||||
action: {
|
action: {
|
||||||
label: 'Log in',
|
label: t('events.detail.logIn'),
|
||||||
onClick: () => router.push('/login'),
|
onClick: () => router.push('/login'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Defensively clear any lingering edit selection so the Create
|
router.push('/my-tickets')
|
||||||
// tap always opens in Create mode regardless of a prior Edit.
|
|
||||||
eventsStore.editingEvent = null
|
|
||||||
eventsStore.showCreateDialog = true
|
|
||||||
},
|
},
|
||||||
disabled: !isAuthenticated.value,
|
disabled: !isAuthenticated.value,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: t('events.filters.hosting'),
|
||||||
|
icon: Megaphone,
|
||||||
|
onClick: () => {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toast.info(t('events.hosting.loginPrompt', 'Log in to manage your hosted events'), {
|
||||||
|
action: {
|
||||||
|
label: t('events.favorites.logIn'),
|
||||||
|
onClick: () => router.push('/login'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!onlyHosting.value) toggleHosting()
|
||||||
|
if (route.path !== '/events') router.push('/events')
|
||||||
|
},
|
||||||
|
isActive: () => inFeedRoute() && onlyHosting.value,
|
||||||
|
disabled: !isAuthenticated.value,
|
||||||
|
},
|
||||||
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
||||||
{
|
{
|
||||||
name: t('events.nav.favorites'),
|
name: t('events.nav.favorites'),
|
||||||
|
|
@ -80,43 +112,8 @@ const tabs = computed<BottomTab[]>(() => [
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
// Sidebar entries shown to authed users only. "My tickets" routes to
|
// Path-based fallback for tabs that don't carry their own `isActive`.
|
||||||
// the dedicated /my-tickets page; "Hosting" toggles the feed filter
|
|
||||||
// (no dedicated page yet) and lands on /events so the user can
|
|
||||||
// see the filtered list.
|
|
||||||
const sidebarNav = computed<SidebarNavItem[]>(() => {
|
|
||||||
if (!isAuthenticated.value) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: t('events.filters.myTickets', 'My tickets'),
|
|
||||||
icon: Ticket,
|
|
||||||
path: '/my-tickets',
|
|
||||||
isActive: () => route.path.startsWith('/my-tickets'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t('events.filters.hosting', 'Hosting'),
|
|
||||||
icon: Megaphone,
|
|
||||||
onClick: () => {
|
|
||||||
if (!onlyHosting.value) toggleHosting()
|
|
||||||
if (!route.path.startsWith('/events')) router.push('/events')
|
|
||||||
},
|
|
||||||
isActive: () => onlyHosting.value,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Feed tab is active for the bare /events route AND all sub-paths that
|
|
||||||
// aren't owned by another tab (e.g. /events/<id> detail pages).
|
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
if (path === '/events') {
|
|
||||||
return (
|
|
||||||
route.path === '/events' ||
|
|
||||||
(route.path.startsWith('/events/') &&
|
|
||||||
!route.path.startsWith('/events/calendar') &&
|
|
||||||
!route.path.startsWith('/events/map') &&
|
|
||||||
!route.path.startsWith('/events/favorites'))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +148,7 @@ function handleDialogOpenChange(open: boolean) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell :tabs="tabs" :is-active="isActive" :sidebar-nav="sidebarNav">
|
<AppShell :tabs="tabs" :is-active="isActive">
|
||||||
<CreateEventDialog
|
<CreateEventDialog
|
||||||
:open="eventsStore.showCreateDialog"
|
:open="eventsStore.showCreateDialog"
|
||||||
:event="eventsStore.editingEvent"
|
:event="eventsStore.editingEvent"
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ const messages: LocaleMessages = {
|
||||||
registered: 'Registered',
|
registered: 'Registered',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
feed: 'Feed',
|
feed: 'Home',
|
||||||
calendar: 'Calendar',
|
calendar: 'Calendar',
|
||||||
map: 'Map',
|
map: 'Map',
|
||||||
favorites: 'Favorites',
|
favorites: 'Favorites',
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ const messages: LocaleMessages = {
|
||||||
registered: 'Enregistré',
|
registered: 'Enregistré',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
feed: 'Fil',
|
feed: 'Accueil',
|
||||||
calendar: 'Calendrier',
|
calendar: 'Calendrier',
|
||||||
map: 'Carte',
|
map: 'Carte',
|
||||||
favorites: 'Favoris',
|
favorites: 'Favoris',
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ import {
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { SlidersHorizontal, History } from 'lucide-vue-next'
|
import { SlidersHorizontal, History, CalendarDays, Plus } from 'lucide-vue-next'
|
||||||
import { useEvents } from '../composables/useEvents'
|
import { useEvents } from '../composables/useEvents'
|
||||||
|
import { useEventsStore } from '../stores/events'
|
||||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||||
|
|
@ -20,6 +21,7 @@ import type { Event } from '../types/event'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const eventsStore = useEventsStore()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
events,
|
events,
|
||||||
|
|
@ -30,6 +32,7 @@ const {
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
showPast,
|
showPast,
|
||||||
|
onlyHosting,
|
||||||
selectDate,
|
selectDate,
|
||||||
setTemporal,
|
setTemporal,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
|
|
@ -55,6 +58,19 @@ onMounted(() => {
|
||||||
function handleSelectEvent(event: Event) {
|
function handleSelectEvent(event: Event) {
|
||||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create-activity CTA in the Hosting view. Calendar-tab → page lives
|
||||||
|
// on /events/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() {
|
||||||
|
eventsStore.editingEvent = null
|
||||||
|
eventsStore.showCreateDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCalendar() {
|
||||||
|
router.push('/events/calendar')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -72,9 +88,24 @@ function handleSelectEvent(event: Event) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date picker strip (p'a semana style) -->
|
<!-- Date picker strip + calendar shortcut. The calendar icon used
|
||||||
<div class="mb-3">
|
to be a bottom-nav tab; it now lives on the right of the week
|
||||||
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
|
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('events.nav.calendar')"
|
||||||
|
@click="openCalendar"
|
||||||
|
>
|
||||||
|
<CalendarDays class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
|
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
|
||||||
|
|
@ -136,6 +167,19 @@ function handleSelectEvent(event: Event) {
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</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('events.createNew') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
|
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue