feat(layout): adopt unified AppShell across hub + 7 standalones (Phase B) #52
13 changed files with 142 additions and 576 deletions
feat(layout): adopt unified AppShell across hub + 7 standalones (Phase B)
Refactor every entry point to consume the Phase A primitives. Each App.vue collapses from 47-127 lines of shell boilerplate into a thin AppShell consumer that declares its own BottomTab[] and active-path matcher; Hub.vue now reuses ProfileSheetTrigger + PreferencesRow instead of inlining its own bottom row. The Settings tab is dropped from activities and libra (theme/lang/currency now live in the shared profile sheet — see #50 for cleanup of orphaned SettingsPage.vue routes). The redundant top-right LogIn icon is dropped from every standalone (the bottom-nav profile slot covers that affordance). BottomNav.vue gains optional `onClick`, `disabled`, and an optional `path` on BottomTab so consumers can express coming-soon toasts (forum's Spaces/ Search/Alerts) and auth-gated tabs (market's "My Store" → toast when logged out) without reaching back into shell internals. While in here: remove the page-level Refresh buttons in ActivitiesPage, EventsPage, MyTicketsPage, and NostrFeed. The relay-subscription + VisibilityService reconnect path keeps these views fresh; manual refresh was redundant and was now visually colliding with the new top-right HubPill. Net: -434 lines.
commit
c80a8461ac
|
|
@ -1,94 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
PlusCircle, List, Scale, Wallet, Settings, LogIn,
|
||||
} from 'lucide-vue-next'
|
||||
import { PlusCircle, List, Scale, Wallet } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
// Bottom navigation tabs
|
||||
const bottomTabs = computed(() => [
|
||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||
const tabs = computed<BottomTab[]>(() => [
|
||||
{ name: t('libra.nav.record'), icon: PlusCircle, path: '/record' },
|
||||
{ name: t('libra.nav.transactions'), icon: List, path: '/expenses/transactions' },
|
||||
{ name: t('libra.nav.balance'), icon: Scale, path: '/balance' },
|
||||
{ name: t('libra.nav.wallet'), icon: Wallet, path: '/wallet' },
|
||||
{ name: t('libra.nav.settings'), icon: Settings, path: '/settings' },
|
||||
])
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
function isActiveTab(path: string): boolean {
|
||||
if (path === '/record') {
|
||||
return route.path === '/record'
|
||||
}
|
||||
if (path === '/expenses/transactions') {
|
||||
return route.path === '/expenses/transactions'
|
||||
}
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/record') return route.path === '/record'
|
||||
if (path === '/expenses/transactions') return route.path === '/expenses/transactions'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Top bar with login -->
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Main content (with bottom padding for nav bar) -->
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Bottom navigation bar -->
|
||||
<nav
|
||||
v-if="!isLoginPage"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.path"
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="isActiveTab(tab.path)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
@click="router.push(tab.path)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,84 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
CalendarDays, Map, Heart, Settings, Search,
|
||||
} from 'lucide-vue-next'
|
||||
import { CalendarDays, Map, Heart, Search } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
useTheme()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
// Bottom navigation tabs (p'a semana style)
|
||||
const bottomTabs = computed(() => [
|
||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||
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.nav.map'), icon: Map, path: '/activities/map' },
|
||||
{ name: t('activities.nav.favorites'), icon: Heart, path: '/activities/favorites' },
|
||||
{ name: t('activities.nav.settings'), icon: Settings, path: '/settings' },
|
||||
])
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
function isActiveTab(path: string): boolean {
|
||||
// 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).
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/activities') {
|
||||
return route.path === '/activities' || route.path.startsWith('/activities/') &&
|
||||
return (
|
||||
route.path === '/activities' ||
|
||||
(route.path.startsWith('/activities/') &&
|
||||
!route.path.startsWith('/activities/calendar') &&
|
||||
!route.path.startsWith('/activities/map') &&
|
||||
!route.path.startsWith('/activities/favorites')
|
||||
!route.path.startsWith('/activities/favorites'))
|
||||
)
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Main content (with bottom padding for nav bar) -->
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Bottom navigation bar (p'a semana style) -->
|
||||
<nav
|
||||
v-if="!isLoginPage"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.path"
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="isActiveTab(tab.path)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
@click="router.push(tab.path)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,47 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
// Chat owns its in-page navigation today; the shell only contributes the
|
||||
// always-on Profile entry on the right of the bottom row.
|
||||
const tabs: BottomTab[] = []
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@ export interface BottomTab {
|
|||
name: string
|
||||
/** lucide-vue-next icon component. */
|
||||
icon: Component
|
||||
/** Router path to push on click. */
|
||||
path: string
|
||||
/** Router path to push on click. Optional — coming-soon entries omit it. */
|
||||
path?: string
|
||||
/** Optional unread/cart badge count. Falsy values hide the badge. */
|
||||
badge?: number | null
|
||||
/** Click override. When provided, replaces the default router.push(path).
|
||||
* Consumers use this for coming-soon toasts or auth-gated CTAs. */
|
||||
onClick?: () => void
|
||||
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
|
||||
* for auth-required tabs when the user is logged out. */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -28,6 +34,14 @@ interface Props {
|
|||
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function onTabClick(tab: BottomTab) {
|
||||
if (tab.onClick) {
|
||||
tab.onClick()
|
||||
return
|
||||
}
|
||||
if (tab.path) router.push(tab.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -39,13 +53,16 @@ const router = useRouter()
|
|||
<!-- App-specific tabs (consumer-provided) -->
|
||||
<button
|
||||
v-for="tab in props.tabs"
|
||||
:key="tab.path"
|
||||
:key="tab.name"
|
||||
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="props.isActive(tab.path)
|
||||
:class="[
|
||||
tab.path && props.isActive(tab.path)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-current="props.isActive(tab.path) ? 'page' : undefined"
|
||||
@click="router.push(tab.path)"
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
tab.disabled ? 'opacity-50' : '',
|
||||
]"
|
||||
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
|
||||
@click="onTabClick(tab)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
|
|
|
|||
|
|
@ -1,104 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LogIn, Newspaper, Hash, SquarePen, Search, Bell,
|
||||
} from 'lucide-vue-next'
|
||||
import { Newspaper, Hash, SquarePen, Search, Bell } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
interface Tab {
|
||||
name: string
|
||||
icon: any
|
||||
path?: string
|
||||
comingSoon?: { issue: number; label: string }
|
||||
}
|
||||
|
||||
const bottomTabs: Tab[] = [
|
||||
{ name: 'Posts', icon: Newspaper, path: '/forum' },
|
||||
{ name: 'Spaces', icon: Hash, comingSoon: { issue: 31, label: 'Spaces' } },
|
||||
{ name: 'Submit', icon: SquarePen, path: '/submit' },
|
||||
{ name: 'Search', icon: Search, comingSoon: { issue: 15, label: 'Search' } },
|
||||
{ name: 'Alerts', icon: Bell, comingSoon: { issue: 32, label: 'Notifications' } },
|
||||
]
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
function isActiveTab(tab: Tab): boolean {
|
||||
if (!tab.path) return false
|
||||
if (tab.path === '/forum') return route.path === '/forum' || route.path.startsWith('/submission/')
|
||||
return route.path.startsWith(tab.path)
|
||||
}
|
||||
|
||||
function onTabClick(tab: Tab) {
|
||||
if (tab.path) {
|
||||
router.push(tab.path)
|
||||
} else if (tab.comingSoon) {
|
||||
toast.info(`${tab.comingSoon.label} — coming soon`, {
|
||||
description: `Tracked on issue #${tab.comingSoon.issue}`,
|
||||
function comingSoon(label: string, issue: number) {
|
||||
toast.info(`${label} — coming soon`, {
|
||||
description: `Tracked on issue #${issue}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
const tabs = computed<BottomTab[]>(() => [
|
||||
{ name: 'Posts', icon: Newspaper, path: '/forum' },
|
||||
{ name: 'Spaces', icon: Hash, disabled: true, onClick: () => comingSoon('Spaces', 31) },
|
||||
{ name: 'Submit', icon: SquarePen, path: '/submit' },
|
||||
{ name: 'Search', icon: Search, disabled: true, onClick: () => comingSoon('Search', 15) },
|
||||
{ name: 'Alerts', icon: Bell, disabled: true, onClick: () => comingSoon('Notifications', 32) },
|
||||
])
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/forum') {
|
||||
return route.path === '/forum' || route.path.startsWith('/submission/')
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<nav
|
||||
v-if="!isLoginPage"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.name"
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="[
|
||||
isActiveTab(tab)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
tab.comingSoon ? 'opacity-50' : '',
|
||||
]"
|
||||
@click="onTabClick(tab)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,114 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Store, ShoppingCart, Package } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import {
|
||||
Store, ShoppingCart, Package, LogIn, User as UserIcon,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
interface Tab {
|
||||
name: string
|
||||
icon: any
|
||||
path?: string
|
||||
authRequired?: boolean
|
||||
badge?: () => number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const bottomTabs = computed<Tab[]>(() => [
|
||||
const tabs = computed<BottomTab[]>(() => [
|
||||
{ name: 'Browse', icon: Store, path: '/market' },
|
||||
{ name: 'Cart', icon: ShoppingCart, path: '/cart', badge: () => marketStore.totalCartItems },
|
||||
{ name: 'My Store', icon: Package, path: '/market/dashboard', authRequired: true },
|
||||
isAuthenticated.value
|
||||
? { name: 'Profile', icon: UserIcon, path: '/profile' }
|
||||
: { name: 'Log in', icon: LogIn, path: '/login' },
|
||||
{
|
||||
name: 'Cart',
|
||||
icon: ShoppingCart,
|
||||
path: '/cart',
|
||||
badge: marketStore.totalCartItems || null,
|
||||
},
|
||||
{
|
||||
name: 'My Store',
|
||||
icon: Package,
|
||||
path: isAuthenticated.value ? '/market/dashboard' : undefined,
|
||||
disabled: !isAuthenticated.value,
|
||||
onClick: !isAuthenticated.value
|
||||
? () =>
|
||||
toast.info('My Store requires login', {
|
||||
action: { label: 'Log in', onClick: () => router.push('/login') },
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
function isActiveTab(tab: Tab): boolean {
|
||||
if (!tab.path) return false
|
||||
if (tab.path === '/market') {
|
||||
return route.path === '/market' || route.path.startsWith('/market/stall/') || route.path.startsWith('/market/product/')
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/market') {
|
||||
return (
|
||||
route.path === '/market' ||
|
||||
route.path.startsWith('/market/stall/') ||
|
||||
route.path.startsWith('/market/product/')
|
||||
)
|
||||
}
|
||||
if (tab.path === '/cart') return route.path === '/cart' || route.path.startsWith('/checkout/')
|
||||
return route.path.startsWith(tab.path)
|
||||
}
|
||||
|
||||
function onTabClick(tab: Tab) {
|
||||
if (tab.authRequired && !isAuthenticated.value) {
|
||||
toast.info(`${tab.name} requires login`, {
|
||||
action: {
|
||||
label: 'Log in',
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
return
|
||||
if (path === '/cart') {
|
||||
return route.path === '/cart' || route.path.startsWith('/checkout/')
|
||||
}
|
||||
if (tab.path) router.push(tab.path)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<nav
|
||||
v-if="!isLoginPage"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.name"
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="[
|
||||
isActiveTab(tab)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
tab.authRequired && !isAuthenticated ? 'opacity-50' : '',
|
||||
]"
|
||||
@click="onTabClick(tab)"
|
||||
>
|
||||
<span class="relative inline-flex">
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span
|
||||
v-if="tab.badge && tab.badge() > 0"
|
||||
class="absolute -top-1.5 -right-2 min-w-[16px] h-4 px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold leading-4 text-center"
|
||||
>{{ tab.badge() }}</span>
|
||||
</span>
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { RefreshCw, SlidersHorizontal, ChevronDown, Plus, LogIn } from 'lucide-vue-next'
|
||||
import { SlidersHorizontal, ChevronDown, Plus } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useActivities } from '../composables/useActivities'
|
||||
import CreateEventDialog from '../components/CreateEventDialog.vue'
|
||||
|
|
@ -86,18 +86,6 @@ async function handleCreateEvent(eventData: CreateEventRequest) {
|
|||
<Plus class="w-4 h-4 mr-1.5" />
|
||||
<span class="hidden sm:inline">{{ t('activities.createNew') }}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="handleRefresh" :disabled="isLoading">
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isAuthenticated"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="$router.push('/login')"
|
||||
>
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Badge } from '@/components/ui/badge'
|
|||
import { format } from 'date-fns'
|
||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||
import CreateEventDialog from '../components/CreateEventDialog.vue'
|
||||
import { RefreshCw, User, LogIn, Plus } from 'lucide-vue-next'
|
||||
import { User, LogIn, Plus } from 'lucide-vue-next'
|
||||
import { formatEventPrice } from '@/lib/utils/formatting'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
|
|
@ -84,10 +84,6 @@ function handleEventCreated() {
|
|||
<Plus class="w-4 h-4" />
|
||||
<span class="ml-2">Create Event</span>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading" class="flex-1 sm:flex-none">
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||
<span class="ml-2">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -131,10 +131,6 @@ onMounted(async () => {
|
|||
<span>Please log in to view your tickets</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
|
||||
<span v-if="isLoading" class="animate-spin mr-2">⚡</span>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAuthenticated" class="text-center py-12">
|
||||
|
|
|
|||
|
|
@ -465,16 +465,6 @@ function cancelDelete() {
|
|||
<p class="text-xs md:text-sm text-muted-foreground">{{ feedDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshFeed"
|
||||
:disabled="isLoading"
|
||||
class="gap-2 md:h-10 md:px-4 hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
||||
<span class="hidden md:inline">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { useLocale } from '@/composables/useLocale'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
Scale, ListTodo, Newspaper, MessageCircle, Wallet, CalendarDays,
|
||||
Store, UtensilsCrossed,
|
||||
User as UserIcon, LogIn, Sun, Moon, Monitor, Globe, Coins,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuRadioGroup, DropdownMenuRadioItem,
|
||||
DropdownMenuLabel, DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
|
||||
import ProfileSheetTrigger from '@/components/layout/ProfileSheetTrigger.vue'
|
||||
import PreferencesRow from '@/components/layout/PreferencesRow.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { theme, setTheme, currentTheme } = useTheme()
|
||||
const { currentLocale, locales, setLocale } = useLocale()
|
||||
|
||||
interface Module {
|
||||
label: string
|
||||
|
|
@ -85,14 +73,6 @@ function onTileClick(m: Module, event: Event) {
|
|||
}
|
||||
// "Coming soon" tiles (no envKey, no authRequired) silently do nothing.
|
||||
}
|
||||
|
||||
const showProfile = ref(false)
|
||||
|
||||
function notImplemented() {
|
||||
toast.info('Currency picker — coming soon', {
|
||||
description: 'A preferred-currency setting (sats/USD/EUR) is on the roadmap.',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -154,85 +134,17 @@ function notImplemented() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar: profile & user preferences -->
|
||||
<!-- Bottom bar: profile + user preferences. Profile entry is identical to
|
||||
every standalone (avatar from kind-0, sheet has back-to-hub stub
|
||||
removed since this IS the hub). Logged-out users still get the
|
||||
sheet so they can change theme/lang without signing in. -->
|
||||
<nav
|
||||
class="relative z-10 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<!-- Profile (when logged in) / Log in (when not) -->
|
||||
<Sheet v-if="isAuthenticated" v-model:open="showProfile">
|
||||
<SheetTrigger as-child>
|
||||
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
|
||||
<UserIcon class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Profile</span>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Profile</SheetTitle>
|
||||
<SheetDescription>Your Nostr identity and display name.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="mt-4">
|
||||
<ProfileSettings />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<button
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="router.push('/login')"
|
||||
>
|
||||
<LogIn class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Log in</span>
|
||||
</button>
|
||||
|
||||
<!-- Theme -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
|
||||
<component :is="currentTheme === 'dark' ? Moon : Sun" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Theme</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" class="w-40">
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup :model-value="theme" @update:model-value="(v: string) => setTheme(v as 'dark' | 'light' | 'system')">
|
||||
<DropdownMenuRadioItem value="light"><Sun class="w-4 h-4 mr-2" />Light</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark"><Moon class="w-4 h-4 mr-2" />Dark</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system"><Monitor class="w-4 h-4 mr-2" />System</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Language -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Globe class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Language</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" class="w-44">
|
||||
<DropdownMenuLabel>Language</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup :model-value="currentLocale" @update:model-value="(v: string) => setLocale(v)">
|
||||
<DropdownMenuRadioItem v-for="l in locales" :key="l.code" :value="l.code">
|
||||
<span class="mr-2">{{ l.flag }}</span>{{ l.name }}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Currency (placeholder) -->
|
||||
<button
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors opacity-50"
|
||||
@click="notImplemented"
|
||||
>
|
||||
<Coins class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Currency</span>
|
||||
</button>
|
||||
<ProfileSheetTrigger :logged-out-opens-sheet="true" />
|
||||
<PreferencesRow layout="row" />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,47 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
// Tasks owns its in-page navigation; the shell only contributes the
|
||||
// always-on Profile entry on the right of the bottom row.
|
||||
const tabs: BottomTab[] = []
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,48 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
// Wallet owns its in-page navigation; the shell only contributes the
|
||||
// always-on Profile entry on the right of the bottom row.
|
||||
const tabs: BottomTab[] = []
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Top bar with login -->
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue