feat(layout): unified app-shell primitives (Phase A) #51

Merged
padreug merged 1 commit from feat/unified-app-shell into main 2026-05-07 10:07:33 +00:00
12 changed files with 582 additions and 3 deletions

View file

@ -87,6 +87,25 @@ 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

@ -0,0 +1,55 @@
<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

@ -0,0 +1,65 @@
<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. */
path: string
/** Optional unread/cart badge count. Falsy values hide the badge. */
badge?: number | null
}
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()
</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.path"
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="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)"
>
<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

@ -0,0 +1,24 @@
<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

@ -0,0 +1,152 @@
<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

@ -0,0 +1,86 @@
<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

@ -0,0 +1,68 @@
<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

@ -0,0 +1,42 @@
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

@ -16,7 +16,24 @@ const messages: LocaleMessages = {
common: {
loading: 'Loading...',
error: 'An error occurred',
success: 'Operation successful'
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.'
}
},
errors: {
notFound: 'Page not found',

View file

@ -16,7 +16,24 @@ const messages: LocaleMessages = {
common: {
loading: 'Cargando...',
error: 'Ha ocurrido un error',
success: 'Operación exitosa'
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.'
}
},
errors: {
notFound: 'Página no encontrada',

View file

@ -16,7 +16,24 @@ const messages: LocaleMessages = {
common: {
loading: 'Chargement...',
error: 'Une erreur est survenue',
success: 'Opération réussie'
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.'
}
},
errors: {
notFound: 'Page non trouvée',

View file

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