Compare commits

..

No commits in common. "ef042fed71cc95de2c086ca394f4d5927d25eabf" and "0a0769115bc2967f69154ab12c8043d738599953" have entirely different histories.

28 changed files with 628 additions and 724 deletions

View file

@ -87,25 +87,6 @@ VITE_HUB_FORUM_URL=
VITE_HUB_MARKET_URL=
VITE_HUB_TASKS_URL=
# ───────────────────────────────────────────────────────────────────────
# VITE_HUB_ROOT_URL — standalone → hub (the inverse of the URLs above)
#
# Read by the standalone shell's <HubPill> (top-right "back to hub" link)
# and the "Back to Hub" item inside the profile sheet. Each standalone's
# bundle gets this value baked in at build time.
#
# In PATH-MODE deployment the standalone and hub share an origin, so the
# default ('/' if unset) is correct — the link is same-origin.
#
# In SUBDOMAIN-MODE production the standalone is on a different origin
# than the hub; set this to the full hub URL:
# VITE_HUB_ROOT_URL=https://app.example.com/
#
# In LOCAL DEV with `npm run dev:all`, the hub is on :5173:
# VITE_HUB_ROOT_URL=http://localhost:5173/
# ───────────────────────────────────────────────────────────────────────
VITE_HUB_ROOT_URL=
# ───────────────────────────────────────────────────────────────────────
# VITE_BASE_PATH — build-time only, NOT per .env
#

View file

@ -1,29 +1,94 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { PlusCircle, List, Scale, Wallet } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
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'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
// Settings dropped theme/lang/currency now live in the shared profile sheet.
const tabs = computed<BottomTab[]>(() => [
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
// Bottom navigation tabs
const bottomTabs = computed(() => [
{ 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' },
])
function isActive(path: string): boolean {
if (path === '/record') return route.path === '/record'
if (path === '/expenses/transactions') return route.path === '/expenses/transactions'
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'
}
return route.path.startsWith(path)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
<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>
</template>

View file

@ -6,7 +6,8 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useToast } from '@/core/composables/useToast'
import type { ExpensesAPI } from '@/modules/expenses/services/ExpensesAPI'
import type { Transaction } from '@/modules/expenses/types'
import { ArrowDown, ArrowUp, Clock, Loader2, PieChart } from 'lucide-vue-next'
import { ArrowDown, ArrowUp, Clock, RefreshCw, Loader2, PieChart } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const { t } = useI18n()
@ -19,6 +20,7 @@ const balance = ref<number | null>(null)
const balanceCurrency = ref<string>('sats')
const pendingTransactions = ref<Transaction[]>([])
const isLoading = ref(true)
const isRefreshing = ref(false)
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
const budgetsEnabled = computed(() => import.meta.env.VITE_LIBRA_BUDGETS_ENABLED === 'true')
@ -64,6 +66,12 @@ async function loadData() {
}
}
async function refresh() {
isRefreshing.value = true
await loadData()
isRefreshing.value = false
}
onMounted(async () => {
isLoading.value = true
await loadData()
@ -86,8 +94,18 @@ function formatFiat(amount: number, currency: string): string {
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<div class="mb-6">
<!-- Header with refresh -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-foreground">{{ t('libra.balance.title') }}</h1>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="isRefreshing"
@click="refresh"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isRefreshing }" />
</Button>
</div>
<!-- Loading state -->

View file

@ -1,38 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CalendarDays, Map, Heart, Search } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
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'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
// Settings dropped theme/lang/currency now live in the shared profile sheet.
const tabs = computed<BottomTab[]>(() => [
useTheme()
const showLoginDialog = ref(false)
// Bottom navigation tabs (p'a semana style)
const bottomTabs = computed(() => [
{ 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' },
])
// 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 {
const isLoginPage = computed(() => route.path === '/login')
function isActiveTab(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 === '/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)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
<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>
</template>

View file

@ -1,16 +1,47 @@
<script setup lang="ts">
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
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'
// 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[] = []
const route = useRoute()
const router = useRouter()
function isActive(_path: string): boolean {
return false
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
const isLoginPage = computed(() => route.path === '/login')
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
<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>
</template>

View file

@ -1,55 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import { useTheme } from '@/components/theme-provider'
import BottomNav, { type BottomTab } from './BottomNav.vue'
import HubPill from './HubPill.vue'
interface Props {
/** App-specific tabs displayed before the constant Profile entry. */
tabs: BottomTab[]
/** Active-tab matcher. Forwarded to BottomNav. */
isActive: (path: string) => boolean
/** Hide the top-right HubPill only true when this shell is rendering
* the hub itself. Standalones leave this false (default). */
hideHub?: boolean
/** Forwarded to BottomNav. Hub passes true so logged-out users can still
* reach prefs from the sheet. Standalones leave it false. */
loggedOutOpensSheet?: boolean
}
const props = withDefaults(defineProps<Props>(), {
hideHub: false,
loggedOutOpensSheet: false,
})
const route = useRoute()
useTheme()
/** Login page renders without nav chrome to avoid competing with the form. */
const isLoginPage = computed(() => route.path === '/login')
</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>
<BottomNav
v-if="!isLoginPage"
:tabs="props.tabs"
:is-active="props.isActive"
:logged-out-opens-sheet="props.loggedOutOpensSheet"
/>
</div>
<HubPill v-if="!props.hideHub && !isLoginPage" />
<Toaster />
</div>
</template>

View file

@ -1,82 +0,0 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import type { Component } from 'vue'
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
export interface BottomTab {
/** Translated label shown under the icon. */
name: string
/** lucide-vue-next icon component. */
icon: Component
/** 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 {
tabs: BottomTab[]
/** Active-tab matcher. Each app has its own nesting rules so we don't try
* to derive a one-size-fits-all default consumer supplies the function. */
isActive: (path: string) => boolean
/** When true (Hub), the unauthenticated profile button still opens the
* sheet so logged-out users can change theme/lang. When false (standalones),
* unauth profile button routes straight to /login. */
loggedOutOpensSheet?: boolean
}
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>
<nav
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">
<!-- App-specific tabs (consumer-provided) -->
<button
v-for="tab in props.tabs"
: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)
? 'text-primary'
: '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>
<span
v-if="tab.badge"
class="absolute top-1.5 right-3 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-semibold flex items-center justify-center shadow ring-1 ring-background/60"
>
{{ tab.badge > 99 ? '99+' : tab.badge }}
</span>
</button>
<!-- Always-on Profile entry, appended on the right. Consumers don't
pass it; the shell owns it so it's identical across every app. -->
<ProfileSheetTrigger :logged-out-opens-sheet="props.loggedOutOpensSheet" />
</div>
</nav>
</template>

View file

@ -1,24 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Home } from 'lucide-vue-next'
const { t } = useI18n()
/** Falls back to '/' for path-mount deployments where the hub root is the
* same origin. Set VITE_HUB_ROOT_URL to a full URL for subdomain
* deployments where the hub lives on a sibling origin. */
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
</script>
<template>
<a
:href="hubRootUrl"
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center gap-1.5 rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
:aria-label="t('common.nav.backToHub')"
>
<Home class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{ t('common.nav.hub') }}</span>
</a>
</template>

View file

@ -1,152 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Sun, Moon, Monitor, Globe, Coins } from 'lucide-vue-next'
import { ChevronRight } from 'lucide-vue-next'
import { useTheme } from '@/components/theme-provider'
import { useLocale } from '@/composables/useLocale'
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuRadioGroup, DropdownMenuRadioItem,
DropdownMenuLabel, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
interface Props {
/** 'row' = three icon-stacked buttons side-by-side (used by Hub bottom nav).
* 'list' = three full-width list rows (used inside the profile sheet). */
layout?: 'row' | 'list'
}
const props = withDefaults(defineProps<Props>(), { layout: 'row' })
const { t } = useI18n()
const { theme, setTheme, currentTheme } = useTheme()
const { currentLocale, locales, setLocale } = useLocale()
const ThemeIcon = computed(() => (currentTheme.value === 'dark' ? Moon : Sun))
const currentLocaleLabel = computed(
() => locales.value.find(l => l.code === currentLocale.value)?.name ?? currentLocale.value
)
// Currency picker is intentionally still a placeholder until #45 lands
// the row UX is what we're building here, not the underlying preference.
function notImplemented() {
toast.info(t('common.nav.currencyComingSoon'), {
description: t('common.nav.currencyComingSoonDescription'),
})
}
</script>
<template>
<!-- Row layout: three icon-stacked buttons side-by-side (Hub bottom nav). -->
<div v-if="props.layout === 'row'" class="contents">
<!-- 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="ThemeIcon" class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ t('common.nav.theme') }}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="w-40">
<DropdownMenuLabel>{{ t('common.nav.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" />{{ t('common.nav.themeLight') }}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark"><Moon class="w-4 h-4 mr-2" />{{ t('common.nav.themeDark') }}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system"><Monitor class="w-4 h-4 mr-2" />{{ t('common.nav.themeSystem') }}</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">{{ t('common.nav.language') }}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="w-44">
<DropdownMenuLabel>{{ t('common.nav.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, gated on #45) -->
<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">{{ t('common.nav.currency') }}</span>
</button>
</div>
<!-- List layout: three full-width rows (profile sheet). -->
<div v-else class="flex flex-col">
<!-- Theme -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left">
<div class="flex items-center gap-3">
<component :is="ThemeIcon" class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('common.nav.theme') }}</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<span>{{ t(`common.nav.theme${theme.charAt(0).toUpperCase()}${theme.slice(1)}`) }}</span>
<ChevronRight class="w-4 h-4" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-40">
<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" />{{ t('common.nav.themeLight') }}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark"><Moon class="w-4 h-4 mr-2" />{{ t('common.nav.themeDark') }}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system"><Monitor class="w-4 h-4 mr-2" />{{ t('common.nav.themeSystem') }}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<!-- Language -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left">
<div class="flex items-center gap-3">
<Globe class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('common.nav.language') }}</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<span>{{ currentLocaleLabel }}</span>
<ChevronRight class="w-4 h-4" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<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, gated on #45) -->
<button
class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left opacity-60"
@click="notImplemented"
>
<div class="flex items-center gap-3">
<Coins class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('common.nav.currency') }}</span>
</div>
<ChevronRight class="w-4 h-4 text-muted-foreground" />
</button>
</div>
</template>

View file

@ -1,86 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Home, LogIn } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { useAuth } from '@/composables/useAuthService'
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
import PreferencesRow from './PreferencesRow.vue'
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const { isAuthenticated, user } = useAuth()
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
const npubPreview = computed(() => {
const pubkey = user.value?.pubkey
if (!pubkey) return ''
return `${pubkey.slice(0, 8)}${pubkey.slice(-8)}`
})
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
function goLogin() {
router.push('/login')
}
</script>
<template>
<SheetHeader>
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<SheetDescription v-if="isAuthenticated">
{{ t('common.nav.profileDescription') }}
</SheetDescription>
<SheetDescription v-else>
{{ t('common.nav.profileLoggedOutDescription') }}
</SheetDescription>
</SheetHeader>
<!-- Identity card (logged in) -->
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
<Avatar class="h-12 w-12">
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
</div>
</div>
<!-- Cross-app links + global preferences (always visible, auth or not) -->
<div class="mt-4">
<a
:href="hubRootUrl"
class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors"
:aria-label="t('common.nav.backToHub')"
>
<div class="flex items-center gap-3">
<Home class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
</div>
</a>
<PreferencesRow layout="list" />
</div>
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings -->
<div v-if="!isAuthenticated" class="mt-6">
<Separator class="mb-4" />
<Button class="w-full" @click="goLogin">
<LogIn class="mr-2 h-4 w-4" />
{{ t('common.nav.login') }}
</Button>
</div>
<!-- Logged-in: full profile management form -->
<div v-else class="mt-6">
<Separator class="mb-4" />
<ProfileSettings />
</div>
</template>

View file

@ -1,68 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { LogIn, User as UserIcon } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Sheet, SheetContent, SheetTrigger,
} from '@/components/ui/sheet'
import ProfileSheetContent from './ProfileSheetContent.vue'
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
interface Props {
/** When true (Hub bottom row), the unauthenticated state still opens the
* sheet so logged-out users can change theme/lang/currency. When false
* (standalone bottom rows), the unauth state routes to /login directly
* the standalone has nothing useful to show until you're authed. */
loggedOutOpensSheet?: boolean
}
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: true })
const { t } = useI18n()
const router = useRouter()
const { isAuthenticated, pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
const open = ref(false)
</script>
<template>
<!-- Authed OR (unauth and we want sheet open) wrap button in <Sheet>. -->
<Sheet v-if="isAuthenticated || props.loggedOutOpensSheet" v-model:open="open">
<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"
:aria-label="isAuthenticated ? t('common.nav.profile') : t('common.nav.preferences')"
>
<template v-if="isAuthenticated">
<Avatar class="w-6 h-6">
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<AvatarFallback class="text-[10px]">
<template v-if="fallbackInitial">{{ fallbackInitial }}</template>
<UserIcon v-else class="w-4 h-4" />
</AvatarFallback>
</Avatar>
<span class="text-[10px] font-medium">{{ t('common.nav.profile') }}</span>
</template>
<template v-else>
<UserIcon class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ t('common.nav.profile') }}</span>
</template>
</button>
</SheetTrigger>
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
<ProfileSheetContent />
</SheetContent>
</Sheet>
<!-- Unauth + standalone shell bypass sheet, jump straight to /login. -->
<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"
:aria-label="t('common.nav.login')"
@click="router.push('/login')"
>
<LogIn class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ t('common.nav.login') }}</span>
</button>
</template>

View file

@ -1,42 +0,0 @@
import { computed } from 'vue'
import { useAuth } from '@/composables/useAuthService'
/**
* Surface the current user's display identity in the form needed by avatar
* components: a `picture` URL (Nostr kind-0 metadata, mirrored into LNbits
* `extra.picture` by ProfileSettings on save), a friendly display name, and
* a single-character fallback for `<AvatarFallback>` when no picture loads.
*
* Returns null-ish values when unauthenticated so consumers can render a
* generic icon (LogIn, UserIcon) without optional-chain noise.
*/
export function useCurrentUserAvatar() {
const { user, isAuthenticated } = useAuth()
const pictureUrl = computed<string | null>(() => {
if (!isAuthenticated.value) return null
return user.value?.extra?.picture || null
})
const displayName = computed<string | null>(() => {
if (!isAuthenticated.value) return null
return user.value?.extra?.display_name || user.value?.username || null
})
/** First non-whitespace character of display name, uppercased. Empty when
* unauthenticated or no name available consumer should render a fallback
* icon in that case. */
const fallbackInitial = computed<string>(() => {
const name = displayName.value
if (!name) return ''
const trimmed = name.trim()
return trimmed.length > 0 ? trimmed.charAt(0).toUpperCase() : ''
})
return {
isAuthenticated,
pictureUrl,
displayName,
fallbackInitial,
}
}

View file

@ -1,35 +1,104 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
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 { Newspaper, Hash, SquarePen, Search, Bell } 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 { Button } from '@/components/ui/button'
import {
LogIn, Newspaper, Hash, SquarePen, Search, Bell,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
function comingSoon(label: string, issue: number) {
toast.info(`${label} — coming soon`, {
description: `Tracked on issue #${issue}`,
})
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
interface Tab {
name: string
icon: any
path?: string
comingSoon?: { issue: number; label: string }
}
const tabs = computed<BottomTab[]>(() => [
const bottomTabs: Tab[] = [
{ name: 'Posts', icon: Newspaper, path: '/forum' },
{ name: 'Spaces', icon: Hash, disabled: true, onClick: () => comingSoon('Spaces', 31) },
{ name: 'Spaces', icon: Hash, comingSoon: { issue: 31, label: 'Spaces' } },
{ 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) },
])
{ name: 'Search', icon: Search, comingSoon: { issue: 15, label: 'Search' } },
{ name: 'Alerts', icon: Bell, comingSoon: { issue: 32, label: 'Notifications' } },
]
function isActive(path: string): boolean {
if (path === '/forum') {
return route.path === '/forum' || route.path.startsWith('/submission/')
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}`,
})
}
return route.path.startsWith(path)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
<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>
</template>

View file

@ -16,24 +16,7 @@ const messages: LocaleMessages = {
common: {
loading: 'Loading...',
error: 'An error occurred',
success: 'Operation successful',
nav: {
profile: 'Profile',
preferences: 'Preferences',
profileDescription: 'Your Nostr identity and display name.',
profileLoggedOutDescription: 'Sign in or change your preferences.',
login: 'Log in',
backToHub: 'Back to hub',
hub: 'Hub',
theme: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
language: 'Language',
currency: 'Currency',
currencyComingSoon: 'Currency picker — coming soon',
currencyComingSoonDescription: 'A preferred-currency setting (sats/USD/EUR) is on the roadmap.'
}
success: 'Operation successful'
},
errors: {
notFound: 'Page not found',

View file

@ -16,24 +16,7 @@ const messages: LocaleMessages = {
common: {
loading: 'Cargando...',
error: 'Ha ocurrido un error',
success: 'Operación exitosa',
nav: {
profile: 'Perfil',
preferences: 'Preferencias',
profileDescription: 'Tu identidad Nostr y nombre de visualización.',
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
login: 'Iniciar sesión',
backToHub: 'Volver al hub',
hub: 'Hub',
theme: 'Tema',
themeLight: 'Claro',
themeDark: 'Oscuro',
themeSystem: 'Sistema',
language: 'Idioma',
currency: 'Moneda',
currencyComingSoon: 'Selector de moneda — próximamente',
currencyComingSoonDescription: 'Un ajuste de moneda preferida (sats/USD/EUR) está en la hoja de ruta.'
}
success: 'Operación exitosa'
},
errors: {
notFound: 'Página no encontrada',

View file

@ -16,24 +16,7 @@ const messages: LocaleMessages = {
common: {
loading: 'Chargement...',
error: 'Une erreur est survenue',
success: 'Opération réussie',
nav: {
profile: 'Profil',
preferences: 'Préférences',
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
login: 'Se connecter',
backToHub: 'Retour au hub',
hub: 'Hub',
theme: 'Thème',
themeLight: 'Clair',
themeDark: 'Sombre',
themeSystem: 'Système',
language: 'Langue',
currency: 'Devise',
currencyComingSoon: 'Sélecteur de devise — bientôt disponible',
currencyComingSoonDescription: 'Un réglage de devise préférée (sats/USD/EUR) est prévu.'
}
success: 'Opération réussie'
},
errors: {
notFound: 'Page non trouvée',

View file

@ -16,23 +16,6 @@ export interface LocaleMessages {
loading: string
error: string
success: string
nav: {
profile: string
preferences: string
profileDescription: string
profileLoggedOutDescription: string
login: string
backToHub: string
hub: string
theme: string
themeLight: string
themeDark: string
themeSystem: string
language: string
currency: string
currencyComingSoon: string
currencyComingSoonDescription: string
}
}
errors: {
notFound: string

View file

@ -1,55 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
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 { 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 tabs = computed<BottomTab[]>(() => [
const showLoginDialog = ref(false)
interface Tab {
name: string
icon: any
path?: string
authRequired?: boolean
badge?: () => number
onClick?: () => void
}
const bottomTabs = computed<Tab[]>(() => [
{ name: 'Browse', icon: Store, path: '/market' },
{
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,
},
{ 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' },
])
function isActive(path: string): boolean {
if (path === '/market') {
return (
route.path === '/market' ||
route.path.startsWith('/market/stall/') ||
route.path.startsWith('/market/product/')
)
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/')
}
if (path === '/cart') {
return route.path === '/cart' || route.path.startsWith('/checkout/')
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
}
return route.path.startsWith(path)
if (tab.path) router.push(tab.path)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
<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>
</template>

View file

@ -8,7 +8,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Plus } from 'lucide-vue-next'
import { RefreshCw, SlidersHorizontal, ChevronDown, Plus, LogIn } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService'
import { useActivities } from '../composables/useActivities'
import CreateEventDialog from '../components/CreateEventDialog.vue'
@ -86,6 +86,18 @@ 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>

View file

@ -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 { User, LogIn, Plus } from 'lucide-vue-next'
import { RefreshCw, 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,6 +84,10 @@ 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>

View file

@ -131,6 +131,10 @@ 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">

View file

@ -17,6 +17,11 @@
{{ totalUnreadCount }} unread
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
<span class="hidden sm:inline ml-2">Refresh</span>
</Button>
</div>
<!-- Peer List -->
@ -197,6 +202,11 @@
{{ totalUnreadCount }} unread
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
Refresh Peers
</Button>
</div>
<!-- Main Content -->
@ -359,7 +369,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Send, MessageSquare, ArrowLeft, Search, X } from 'lucide-vue-next'
import { Send, RefreshCw, MessageSquare, ArrowLeft, Search, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
@ -381,6 +391,7 @@ const peers = computed(() => chat.peers.value)
const selectedPeer = ref<ChatPeer | null>(null)
const messageInput = ref('')
const isLoading = ref(false)
const showChat = ref(false)
const messagesScrollArea = ref<HTMLElement | null>(null)
const messagesContainer = ref<HTMLElement | null>(null)
@ -453,6 +464,10 @@ const goBackToPeers = () => {
const refreshPeers = async () => {
await chat.refreshPeers()
}
const selectPeer = async (peer: ChatPeer) => {
selectedPeer.value = peer
messageInput.value = ''

View file

@ -183,8 +183,18 @@ onMounted(() => {
<!-- Compact Header -->
<div class="flex flex-col gap-3 p-4 md:p-6 border-b md:bg-card/50 md:backdrop-blur-sm">
<div class="w-full max-w-3xl mx-auto">
<div class="mb-3">
<div class="flex items-center justify-between mb-3">
<h1 class="text-lg md:text-xl font-bold">Transaction History</h1>
<Button
variant="outline"
size="sm"
@click="loadTransactions"
: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>
<!-- Date Range Controls -->

View file

@ -465,6 +465,16 @@ 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>

View file

@ -30,6 +30,7 @@ const copiedField = ref<string | null>(null)
// Computed
const transactions = computed(() => walletService?.transactions?.value || [])
const isLoading = computed(() => walletService?.isLoading?.value || false)
const error = computed(() => walletService?.error?.value)
// Use PaymentService for centralized balance calculation
const totalBalance = computed(() => paymentService?.totalBalance || 0)
@ -175,10 +176,15 @@ onMounted(async () => {
<div class="container mx-auto py-4 sm:py-8 px-3 sm:px-4 max-w-6xl">
<!-- Header -->
<div class="mb-4 sm:mb-6">
<h1 class="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<Wallet class="h-6 w-6 sm:h-8 sm:w-8" />
Wallet
</h1>
<div class="flex items-center justify-between">
<h1 class="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<Wallet class="h-6 w-6 sm:h-8 sm:w-8" />
Wallet
</h1>
<Button variant="ghost" size="sm" @click="refresh" :disabled="isLoading">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': isLoading }" />
</Button>
</div>
<p class="text-sm sm:text-base text-muted-foreground mt-1">Manage your Bitcoin transactions</p>
</div>

View file

@ -1,17 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } 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 ProfileSheetTrigger from '@/components/layout/ProfileSheetTrigger.vue'
import PreferencesRow from '@/components/layout/PreferencesRow.vue'
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'
const router = useRouter()
const { isAuthenticated } = useAuth()
const { theme, setTheme, currentTheme } = useTheme()
const { currentLocale, locales, setLocale } = useLocale()
interface Module {
label: string
@ -73,6 +85,14 @@ 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>
@ -134,17 +154,85 @@ function onTileClick(m: Module, event: Event) {
</div>
</div>
<!-- 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. -->
<!-- Bottom bar: profile & user preferences -->
<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">
<ProfileSheetTrigger :logged-out-opens-sheet="true" />
<PreferencesRow layout="row" />
<!-- 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>
</div>
</nav>
</div>

View file

@ -1,16 +1,47 @@
<script setup lang="ts">
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
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'
// 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[] = []
const route = useRoute()
const router = useRouter()
function isActive(_path: string): boolean {
return false
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
const isLoginPage = computed(() => route.path === '/login')
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
<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>
</template>

View file

@ -1,16 +1,48 @@
<script setup lang="ts">
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
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'
// 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[] = []
const route = useRoute()
const router = useRouter()
function isActive(_path: string): boolean {
return false
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
const isLoginPage = computed(() => route.path === '/login')
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
<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>
</template>