@nuxt-customer-portal/ui 0.8.9 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @nuxt-customer-portal/ui
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1235d0f: Let portal administrators upload separate desktop and mobile authentication backgrounds in Appearance settings. Add responsive image selection, single-image fallback, preview and removal controls, validated image uploads, and English/Dutch descriptions. Keep existing settings compatible. Position authentication forms near the top on mobile while preserving desktop positioning, with or without a background image.
8
+
9
+ ### Patch Changes
10
+
11
+ - 1235d0f: Redirect the portal home route to `/dashboard` instead of showing the public landing page. Preserve dashboard authentication, onboarding, and saved homepage content. Send portal logout directly to `/login` instead of the home route, and remove the home back button from authentication pages.
12
+
13
+ Use a neutral welcome heading on the sign-in page in English and Dutch so it also welcomes first-time visitors.
14
+
15
+ Show translated email and password prompts instead of technical validation errors when sign-in fields are empty or missing.
16
+
17
+ - Updated dependencies [f115496]
18
+ - @nuxt-customer-portal/core@0.10.0
19
+
20
+ ## 0.9.1
21
+
22
+ ### Patch Changes
23
+
24
+ - @nuxt-customer-portal/core@0.9.1
25
+
26
+ ## 0.9.0
27
+
28
+ ### Minor Changes
29
+
30
+ - 6696fd0: Add an opt-in unified sidebar with nested module navigation and keyboard-accessible collapsed flyouts. Include module subpages in global search, preserve standalone destinations, and place sidebar collapse controls in the footer. Enable the unified layout for SaaS Portal.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [936f0a2]
35
+ - Updated dependencies [6696fd0]
36
+ - @nuxt-customer-portal/core@0.9.0
37
+
3
38
  ## 0.8.9
4
39
 
5
40
  ### Patch Changes
package/app/app.config.ts CHANGED
@@ -3,6 +3,7 @@ const selectItem =
3
3
  'data-highlighted:not-data-disabled:before:bg-accented data-[state=checked]:before:bg-elevated data-[state=checked]:font-medium'
4
4
 
5
5
  export default defineAppConfig({
6
+ portal: { sidebarNavigation: false },
6
7
  ui: {
7
8
  button: {
8
9
  compoundVariants: [
@@ -5,11 +5,13 @@ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
5
5
  const props = withDefaults(
6
6
  defineProps<{
7
7
  showNavigation?: boolean
8
+ sidebarNavigation?: boolean
8
9
  brandName?: string
9
10
  brandTagline?: string
10
11
  }>(),
11
12
  {
12
13
  showNavigation: true,
14
+ sidebarNavigation: false,
13
15
  brandName: 'Nuxt Customer Portal',
14
16
  brandTagline: 'Customer workspace'
15
17
  }
@@ -66,7 +68,21 @@ const { currentUser, isAuthenticated, currentSession, myOrganizations, activeOrg
66
68
 
67
69
  const showOrgSwitcherModal = ref(false)
68
70
  const searchOpen = ref(false)
71
+ // Register on the header so search remains available while its modal is unmounted.
72
+ defineShortcuts({
73
+ meta_k: {
74
+ usingInput: true,
75
+ handler: () => {
76
+ headerMenuOpen.value = false
77
+ searchOpen.value = !searchOpen.value
78
+ }
79
+ }
80
+ })
69
81
  const headerMenuOpen = ref(false)
82
+ const openSearch = () => {
83
+ headerMenuOpen.value = false
84
+ searchOpen.value = true
85
+ }
70
86
 
71
87
  const hasMultipleOrganizations = computed(() => {
72
88
  return myOrganizations.value && myOrganizations.value.length > 1
@@ -254,7 +270,7 @@ const stopImpersonating = async () => {
254
270
  </div>
255
271
  </template>
256
272
 
257
- <nav v-if="showNavigation" class="hidden lg:flex flex-1 items-center justify-center gap-6">
273
+ <nav v-if="showNavigation && !sidebarNavigation" class="hidden lg:flex flex-1 items-center justify-center gap-6">
258
274
  <template v-for="module in moduleItems" :key="module.id">
259
275
  <NuxtLink
260
276
  :to="module.to"
@@ -272,7 +288,15 @@ const stopImpersonating = async () => {
272
288
 
273
289
  <template #right>
274
290
  <div class="hidden lg:flex items-center gap-3 ml-auto">
275
- <UButton icon="i-lucide-search" color="neutral" variant="ghost" size="sm" square @click="searchOpen = true" />
291
+ <UButton
292
+ :aria-label="t('menu.search')"
293
+ icon="i-lucide-search"
294
+ color="neutral"
295
+ variant="ghost"
296
+ size="sm"
297
+ square
298
+ @click="openSearch"
299
+ />
276
300
  <ULocaleSelect
277
301
  v-if="availableLanguages.length > 1"
278
302
  v-model="currentLocale"
@@ -315,7 +339,7 @@ const stopImpersonating = async () => {
315
339
  variant="outline"
316
340
  size="md"
317
341
  class="min-h-11 justify-start"
318
- @click="searchOpen = true"
342
+ @click="openSearch"
319
343
  />
320
344
  <ULocaleSelect
321
345
  v-if="availableLanguages.length > 1"
@@ -328,7 +352,13 @@ const stopImpersonating = async () => {
328
352
  </div>
329
353
  </div>
330
354
 
331
- <nav v-if="showNavigation" :aria-label="t('menu.moduleNavigation')" class="space-y-5 p-5 pt-4">
355
+ <PortalSidebarNavigation
356
+ v-if="showNavigation && sidebarNavigation"
357
+ :collapsed="false"
358
+ class="p-4"
359
+ @navigate="headerMenuOpen = false"
360
+ />
361
+ <nav v-else-if="showNavigation" :aria-label="t('menu.moduleNavigation')" class="space-y-5 p-5 pt-4">
332
362
  <section v-for="module in moduleNavigationGroups" :key="module.id" class="space-y-1">
333
363
  <button
334
364
  v-if="module.menuItems.length > 1"
@@ -425,8 +455,13 @@ const stopImpersonating = async () => {
425
455
 
426
456
  <!-- Dashboard Search Modal -->
427
457
  <UDashboardSearch
458
+ v-if="searchOpen"
428
459
  v-model:open="searchOpen"
460
+ shortcut=""
429
461
  :groups="searchGroups"
462
+ :title="t('menu.search')"
463
+ :description="t('menu.searchDescription')"
464
+ :placeholder="t('menu.searchPages')"
430
465
  :fuse="{
431
466
  fuseOptions: {
432
467
  ignoreLocation: true,
@@ -26,7 +26,7 @@ const signOut = async () => {
26
26
  await authClient.signOut()
27
27
  userStore.clearUserData()
28
28
  emit('navigate')
29
- await navigateTo('/')
29
+ await navigateTo('/login', { replace: true })
30
30
  }
31
31
 
32
32
  const userMenuItems = computed(() => {
@@ -4,7 +4,9 @@ import type { Notification } from '@nuxt-customer-portal/core/app/types'
4
4
 
5
5
  const { isNotificationsSlideoverOpen } = useDashboard()
6
6
 
7
- const { data: notifications } = await useFetch<Notification[]>('/api/notifications')
7
+ const { data: notifications } = await useFetch<Notification[], import('#app').NuxtError<unknown>, string>(
8
+ '/api/notifications'
9
+ )
8
10
  </script>
9
11
 
10
12
  <template>
@@ -0,0 +1,206 @@
1
+ <!-- Hallmark · component review: P4 H4 E4 S4 R4 V4 -->
2
+ <script setup lang="ts">
3
+ const props = defineProps<{ collapsed: boolean }>()
4
+ const emit = defineEmits<{ navigate: [] }>()
5
+ const { t } = useI18n()
6
+ const route = useRoute()
7
+ const mobileOpen = ref(false)
8
+ const { moduleNavigationGroups, activeModuleId } = useModuleNavigation(mobileOpen)
9
+ const { links } = useNavigationLinks(mobileOpen)
10
+ const expanded = ref<string[]>([])
11
+ const flyout = ref('')
12
+ const panel = ref<HTMLElement[]>([])
13
+ let returnFocus: HTMLElement | undefined
14
+ let leaveTimer: ReturnType<typeof setTimeout> | undefined
15
+
16
+ const standalone = computed(() => {
17
+ const destinations = new Set(
18
+ moduleNavigationGroups.value.flatMap((module) => [module.to, ...module.menuItems.map((item) => item.to)])
19
+ )
20
+ return links.value.flat().filter((item) => !destinations.has(String(item.to)))
21
+ })
22
+ watch(
23
+ activeModuleId,
24
+ (id) => {
25
+ if (id && !expanded.value.includes(id)) {
26
+ expanded.value.push(id)
27
+ }
28
+ },
29
+ { immediate: true }
30
+ )
31
+ watch(
32
+ () => route.fullPath,
33
+ () => {
34
+ flyout.value = ''
35
+ if (activeModuleId.value && !expanded.value.includes(activeModuleId.value)) {
36
+ expanded.value.push(activeModuleId.value)
37
+ }
38
+ emit('navigate')
39
+ }
40
+ )
41
+ watch(
42
+ () => props.collapsed,
43
+ () => {
44
+ flyout.value = ''
45
+ }
46
+ )
47
+ function toggle(id: string) {
48
+ expanded.value = expanded.value.includes(id)
49
+ ? expanded.value.filter((value) => value !== id)
50
+ : [...expanded.value, id]
51
+ }
52
+ function show(id: string) {
53
+ clearTimeout(leaveTimer)
54
+ flyout.value = id
55
+ }
56
+ function leave() {
57
+ clearTimeout(leaveTimer)
58
+ leaveTimer = setTimeout(() => {
59
+ if (!panel.value.some((element) => element?.contains(document.activeElement))) {
60
+ flyout.value = ''
61
+ }
62
+ }, 180)
63
+ }
64
+ function closeFlyout() {
65
+ flyout.value = ''
66
+ returnFocus?.focus()
67
+ returnFocus = undefined
68
+ }
69
+ async function enter(id: string, event: Event) {
70
+ returnFocus = event.currentTarget instanceof HTMLElement ? event.currentTarget : undefined
71
+ show(id)
72
+ await nextTick()
73
+ panel.value
74
+ .find((element) => element?.dataset.module === id)
75
+ ?.querySelector<HTMLElement>('a[href]')
76
+ ?.focus()
77
+ }
78
+ onBeforeUnmount(() => clearTimeout(leaveTimer))
79
+ const badgeProps = (badge: (typeof moduleNavigationGroups.value)[number]['badge']) =>
80
+ typeof badge === 'object' ? badge : { label: badge }
81
+ </script>
82
+
83
+ <template>
84
+ <nav :aria-label="t('menu.moduleNavigation')" class="space-y-1" data-portal-sidebar>
85
+ <div v-for="module in moduleNavigationGroups" :key="module.id">
86
+ <template
87
+ v-if="
88
+ module.menuItems.length > 1 || (module.menuItems.length === 1 && module.menuItems[0]?.label !== module.label)
89
+ "
90
+ >
91
+ <UPopover
92
+ v-if="collapsed"
93
+ :open="flyout === module.id"
94
+ :content="{
95
+ side: 'right',
96
+ align: 'start',
97
+ sideOffset: 12,
98
+ collisionPadding: 12,
99
+ onOpenAutoFocus: (event: Event) => event.preventDefault()
100
+ }"
101
+ :ui="{ content: 'bg-default ring-1 ring-default shadow-lg' }"
102
+ @update:open="flyout = $event ? module.id : ''"
103
+ >
104
+ <template #anchor>
105
+ <UButton
106
+ :icon="module.icon"
107
+ :aria-label="module.label"
108
+ aria-haspopup="dialog"
109
+ :aria-expanded="flyout === module.id"
110
+ :color="module.id === activeModuleId ? 'primary' : 'neutral'"
111
+ variant="ghost"
112
+ class="min-h-11 w-full justify-center rounded-md"
113
+ @click="enter(module.id, $event)"
114
+ @pointerenter="show(module.id)"
115
+ @pointerleave="leave"
116
+ @keydown.down.prevent="enter(module.id, $event)"
117
+ @keydown.right.prevent="enter(module.id, $event)"
118
+ />
119
+ </template>
120
+ <template #content>
121
+ <div
122
+ ref="panel"
123
+ :data-module="module.id"
124
+ class="w-64 max-w-[calc(100vw-6rem)] p-2"
125
+ @pointerenter="show(module.id)"
126
+ @pointerleave="leave"
127
+ @focusout="leave"
128
+ @keydown.esc.prevent.stop="closeFlyout"
129
+ >
130
+ <div class="px-3 py-2 text-sm font-semibold text-highlighted">{{ module.label }}</div>
131
+ <UNavigationMenu
132
+ :items="module.menuItems"
133
+ orientation="vertical"
134
+ :ui="{ link: 'min-h-11 rounded-md' }"
135
+ @click="flyout = ''"
136
+ />
137
+ </div>
138
+ </template>
139
+ </UPopover>
140
+ <template v-else>
141
+ <UButton
142
+ :icon="module.icon"
143
+ :color="module.id === activeModuleId ? 'primary' : 'neutral'"
144
+ variant="ghost"
145
+ class="min-h-11 w-full justify-start rounded-md px-3"
146
+ :aria-expanded="expanded.includes(module.id)"
147
+ :aria-controls="`sidebar-module-${module.id}`"
148
+ @click="toggle(module.id)"
149
+ >
150
+ <span class="min-w-0 flex-1 truncate text-left">{{ module.label }}</span>
151
+ <UBadge v-if="module.badge" v-bind="badgeProps(module.badge)" />
152
+ <UIcon
153
+ name="i-lucide-chevron-down"
154
+ class="size-4 shrink-0"
155
+ :class="expanded.includes(module.id) ? 'rotate-180' : ''"
156
+ />
157
+ </UButton>
158
+ <UNavigationMenu
159
+ v-if="expanded.includes(module.id)"
160
+ :id="`sidebar-module-${module.id}`"
161
+ :items="module.menuItems"
162
+ orientation="vertical"
163
+ class="ml-5 my-1 border-l border-default pl-2"
164
+ :ui="{ link: 'min-h-10 rounded-md' }"
165
+ />
166
+ </template>
167
+ </template>
168
+ <UTooltip v-else :text="module.label" :disabled="!collapsed" :content="{ side: 'right' }">
169
+ <UButton
170
+ :to="module.menuItems[0]?.to ?? module.to"
171
+ :icon="module.icon"
172
+ :label="collapsed ? undefined : module.label"
173
+ :aria-label="module.label"
174
+ :color="module.id === activeModuleId ? 'primary' : 'neutral'"
175
+ variant="ghost"
176
+ class="min-h-11 w-full rounded-md px-3"
177
+ :class="collapsed ? 'justify-center' : 'justify-start'"
178
+ @click="emit('navigate')"
179
+ >
180
+ <template v-if="!collapsed && (module.menuItems[0]?.badge ?? module.badge)" #trailing>
181
+ <UBadge v-bind="badgeProps(module.menuItems[0]?.badge ?? module.badge)" />
182
+ </template>
183
+ </UButton>
184
+ </UTooltip>
185
+ </div>
186
+ <UTooltip
187
+ v-for="item in standalone"
188
+ :key="String(item.to)"
189
+ :text="item.label"
190
+ :disabled="!collapsed"
191
+ :content="{ side: 'right' }"
192
+ >
193
+ <UButton
194
+ :to="item.to"
195
+ :icon="item.icon"
196
+ :label="collapsed ? undefined : item.label"
197
+ :aria-label="item.label"
198
+ :color="route.path === item.to ? 'primary' : 'neutral'"
199
+ variant="ghost"
200
+ class="min-h-11 w-full rounded-md px-3"
201
+ :class="collapsed ? 'justify-center' : 'justify-start'"
202
+ @click="emit('navigate')"
203
+ />
204
+ </UTooltip>
205
+ </nav>
206
+ </template>
@@ -1,5 +1,6 @@
1
1
  export const useInvitationManagement = () => ({
2
- changeRole: (endpoint: string, role: string) => $fetch(endpoint, { method: 'PATCH', body: { role } }),
3
- resend: (endpoint: string) => $fetch(`${endpoint}/resend`, { method: 'POST' }),
4
- revoke: (endpoint: string) => $fetch(endpoint, { method: 'PATCH', body: { status: 'canceled' } })
2
+ changeRole: (endpoint: string, role: string) =>
3
+ $fetch<unknown, string>(endpoint, { method: 'PATCH', body: { role } }),
4
+ resend: (endpoint: string) => $fetch<unknown, string>(`${endpoint}/resend`, { method: 'POST' }),
5
+ revoke: (endpoint: string) => $fetch<unknown, string>(endpoint, { method: 'PATCH', body: { status: 'canceled' } })
5
6
  })
@@ -111,6 +111,7 @@ export const useModuleNavigation = (sidebarOpen?: Ref<boolean>) => {
111
111
  )
112
112
  .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
113
113
  .map((item) => ({
114
+ id: item.id,
114
115
  label: t(item.labelKey),
115
116
  icon: item.icon,
116
117
  to: item.to,
@@ -1,4 +1,5 @@
1
1
  import type { CommandPaletteGroup, CommandPaletteItem, NavigationMenuItem } from '@nuxt/ui'
2
+ import { buildNavigationSearchGroups } from '../utils/navigation-search'
2
3
  import type { PortalAudience } from '@nuxt-customer-portal/core/shared/types/feature'
3
4
 
4
5
  const hasAudience = (
@@ -45,6 +46,7 @@ export const useNavigationLinks = (sidebarOpen: Ref<boolean>) => {
45
46
  const { t } = useI18n()
46
47
  const route = useRoute()
47
48
  const { navigation } = usePortalFeatures()
49
+ const { moduleNavigationGroups } = useModuleNavigation(sidebarOpen)
48
50
  const {
49
51
  isAuthenticated,
50
52
  isSystemAdmin,
@@ -81,18 +83,9 @@ export const useNavigationLinks = (sidebarOpen: Ref<boolean>) => {
81
83
  }))
82
84
  ])
83
85
 
84
- const searchGroups = computed<CommandPaletteGroup<CommandPaletteItem>[]>(() => [
85
- {
86
- id: 'navigation',
87
- label: t('menu.search'),
88
- items: visibleItems.value.map((item) => ({
89
- id: item.id,
90
- label: t(item.labelKey),
91
- icon: item.icon,
92
- to: item.to
93
- }))
94
- }
95
- ])
86
+ const searchGroups = computed<CommandPaletteGroup<CommandPaletteItem>[]>(() =>
87
+ buildNavigationSearchGroups(moduleNavigationGroups.value, links.value.flat(), t('menu.search'))
88
+ )
96
89
 
97
90
  return { links, searchGroups }
98
91
  }
@@ -1,22 +1,21 @@
1
1
  <!-- eslint-disable vue/multi-word-component-names -->
2
2
  <script setup lang="ts">
3
+ import { resolveAuthBackgrounds, type AuthBackgroundBranding } from '../utils/auth-background'
4
+
3
5
  const route = useRoute()
4
6
  const { t } = useI18n()
5
7
  const bootstrap = useState<{ adminExists?: boolean; completed?: boolean } | null>('portal-bootstrap-state', () => null)
8
+ const settings = useState<{ branding?: AuthBackgroundBranding } | null>('portal-runtime-settings', () => null)
9
+ const backgrounds = computed(() => resolveAuthBackgrounds(settings.value?.branding))
6
10
  </script>
7
11
 
8
12
  <template>
9
- <div class="auth-shell h-screen flex items-center justify-center px-4">
10
- <UButton
11
- icon="i-lucide-chevron-left"
12
- to="/"
13
- size="xl"
14
- color="neutral"
15
- variant="subtle"
16
- class="auth-back-button absolute left-8 top-8 rounded-full z-10"
17
- />
18
-
19
- <UPageCard variant="subtle" class="max-w-sm w-full">
13
+ <div class="auth-shell" :class="{ 'auth-shell--illustrated': backgrounds.desktop }">
14
+ <picture v-if="backgrounds.desktop" class="auth-background" aria-hidden="true">
15
+ <source media="(max-width: 1023px)" :srcset="backgrounds.mobile" />
16
+ <img :src="backgrounds.desktop" alt="" fetchpriority="high" decoding="async" />
17
+ </picture>
18
+ <UPageCard variant="subtle" class="auth-card relative w-full max-w-sm shrink-0">
20
19
  <UAlert
21
20
  v-if="route.path === '/signup' && bootstrap && !bootstrap.adminExists"
22
21
  class="mb-5"
@@ -30,3 +29,50 @@ const bootstrap = useState<{ adminExists?: boolean; completed?: boolean } | null
30
29
  </UPageCard>
31
30
  </div>
32
31
  </template>
32
+
33
+ <style scoped>
34
+ .auth-shell {
35
+ position: relative;
36
+ isolation: isolate;
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: center;
40
+ min-height: 100dvh;
41
+ padding: max(1.5rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))
42
+ max(1.5rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
43
+ }
44
+ .auth-background {
45
+ position: absolute;
46
+ inset: 0;
47
+ z-index: 0;
48
+ overflow: hidden;
49
+ pointer-events: none;
50
+ }
51
+ .auth-background img {
52
+ width: 100%;
53
+ height: 100%;
54
+ object-fit: cover;
55
+ object-position: center bottom;
56
+ }
57
+ .auth-card {
58
+ z-index: 1;
59
+ }
60
+ .auth-shell--illustrated .auth-card {
61
+ background: var(--ui-bg-elevated);
62
+ box-shadow: 0 1.5rem 4.5rem color-mix(in srgb, var(--ui-text) 12%, transparent);
63
+ }
64
+ @media (max-width: 1023px) {
65
+ .auth-shell {
66
+ align-items: flex-start;
67
+ }
68
+ }
69
+ @media (min-width: 1024px) {
70
+ .auth-shell--illustrated {
71
+ justify-content: flex-start;
72
+ padding-inline: clamp(3rem, 12vw, 12rem);
73
+ }
74
+ .auth-background img {
75
+ object-position: right center;
76
+ }
77
+ }
78
+ </style>
@@ -5,6 +5,8 @@ const toast = useToast()
5
5
  const { t } = useI18n()
6
6
  const open = ref(false)
7
7
  const sidebarCollapsed = ref(false)
8
+ const appConfig = useAppConfig()
9
+ const unifiedNavigation = computed(() => appConfig.portal.sidebarNavigation)
8
10
  const showFooter = computed(() => route.meta?.public === true)
9
11
 
10
12
  const { activeModule, activeModuleMenuItems } = useModuleNavigation(open)
@@ -45,7 +47,7 @@ onMounted(async () => {
45
47
  <!-- AppHeader - fixed at top -->
46
48
  <div class="fixed left-0 right-0 z-50" style="top: var(--portal-top-bar-height, 0px)">
47
49
  <div class="mx-auto w-full max-w-[1600px] px-4">
48
- <AppHeader />
50
+ <AppHeader :sidebar-navigation="unifiedNavigation" />
49
51
  </div>
50
52
  </div>
51
53
 
@@ -65,37 +67,48 @@ onMounted(async () => {
65
67
  :default-size="18"
66
68
  :min-size="18"
67
69
  :max-size="24"
68
- class="min-w-72 bg-elevated/25 lg:pb-12 data-[collapsed=true]:min-w-16"
69
- :ui="{ footer: 'lg:border-t lg:border-default' }"
70
+ class="min-w-72 bg-elevated/25 data-[collapsed=true]:min-w-16"
71
+ :class="unifiedNavigation ? '' : 'lg:pb-12'"
72
+ :ui="{ footer: 'border-t border-default' }"
70
73
  >
71
74
  <template #header="{ collapsed }">
72
- <div v-if="activeModule && !collapsed" class="flex min-w-0 items-center gap-2 px-2 text-sm font-semibold">
75
+ <div
76
+ v-if="activeModule && !collapsed && !unifiedNavigation"
77
+ class="flex min-w-0 items-center gap-2 px-2 text-sm font-semibold"
78
+ >
73
79
  <UIcon v-if="activeModule.icon" :name="activeModule.icon" class="size-4 shrink-0 text-primary" />
74
80
  <span class="truncate">{{ activeModule.label }}</span>
75
81
  </div>
76
82
  </template>
77
- <template #default="{ collapsed, collapse }">
78
- <div class="flex items-center pt-3 pb-1" :class="collapsed ? 'justify-center' : 'justify-between px-2.5'">
79
- <span v-if="!collapsed && activeModule" class="text-xs font-semibold uppercase text-muted">
83
+ <template #default="{ collapsed }">
84
+ <div v-if="!unifiedNavigation && !collapsed && activeModule" class="flex items-center px-2.5 pt-3 pb-1">
85
+ <span class="text-xs font-semibold uppercase text-muted">
80
86
  {{ activeModule.label }}
81
87
  </span>
88
+ </div>
89
+ <PortalSidebarNavigation v-if="unifiedNavigation" :collapsed="collapsed" @navigate="open = false" />
90
+ <UNavigationMenu
91
+ v-else
92
+ :collapsed="collapsed"
93
+ :items="activeModuleMenuItems"
94
+ orientation="vertical"
95
+ tooltip
96
+ popover
97
+ />
98
+ </template>
99
+ <template #footer="{ collapsed, collapse }">
100
+ <div class="flex w-full py-2" :class="collapsed ? 'justify-center' : 'justify-end'">
82
101
  <UButton
83
102
  :icon="collapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
84
103
  color="neutral"
85
104
  variant="ghost"
86
- size="xs"
105
+ size="sm"
106
+ class="min-h-11 min-w-11"
87
107
  :aria-label="t(collapsed ? 'common.expandSidebar' : 'common.collapseSidebar')"
88
108
  :title="t(collapsed ? 'common.expandSidebar' : 'common.collapseSidebar')"
89
109
  @click="collapse(!collapsed)"
90
110
  />
91
111
  </div>
92
- <UNavigationMenu
93
- :collapsed="collapsed"
94
- :items="activeModuleMenuItems"
95
- orientation="vertical"
96
- tooltip
97
- popover
98
- />
99
112
  </template>
100
113
  </UDashboardSidebar>
101
114
 
@@ -0,0 +1,11 @@
1
+ export interface AuthBackgroundBranding {
2
+ authBackgroundDesktop?: string
3
+ authBackgroundMobile?: string
4
+ }
5
+
6
+ export function resolveAuthBackgrounds(branding?: AuthBackgroundBranding | null) {
7
+ return {
8
+ desktop: branding?.authBackgroundDesktop || branding?.authBackgroundMobile || '',
9
+ mobile: branding?.authBackgroundMobile || branding?.authBackgroundDesktop || ''
10
+ }
11
+ }
@@ -0,0 +1,35 @@
1
+ interface Destination {
2
+ id?: string
3
+ label?: string
4
+ icon?: string
5
+ to?: unknown
6
+ }
7
+ interface NavigationGroup extends Destination {
8
+ id: string
9
+ menuItems: Destination[]
10
+ }
11
+
12
+ /** Search the same permission-filtered destinations shown by the module menus. */
13
+ export const buildNavigationSearchGroups = (
14
+ modules: NavigationGroup[],
15
+ standalone: Destination[],
16
+ fallbackLabel: string
17
+ ) => {
18
+ const seen = new Set<string>()
19
+ const items = (destinations: Destination[], suffix?: string) =>
20
+ destinations.flatMap((item) => {
21
+ if (typeof item.to !== 'string' || seen.has(item.to)) {
22
+ return []
23
+ }
24
+ seen.add(item.to)
25
+ return [{ id: item.to, label: item.label, icon: item.icon, to: item.to, suffix }]
26
+ })
27
+ const groups = modules.map((module) => ({
28
+ id: module.id,
29
+ label: module.label,
30
+ items: items([...module.menuItems, module], module.label)
31
+ }))
32
+ return [...groups, { id: 'navigation', label: fallbackLabel, items: items(standalone) }].filter(
33
+ (group) => group.items.length
34
+ )
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/ui",
3
- "version": "0.8.9",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
  "remark-parse": "^11.0.0",
40
40
  "unified": "^11.0.5",
41
41
  "zod": "^4.4.3",
42
- "@nuxt-customer-portal/core": "^0.8.9"
42
+ "@nuxt-customer-portal/core": "^0.10.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "nuxt": "^4.5.1",
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'ui',
3
- version: '0.8.9',
3
+ version: '0.10.0',
4
4
  source: '@nuxt-customer-portal/ui',
5
5
  dependsOn: ['core']
6
6
  }