@nuxt-customer-portal/ui 0.3.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 +7 -0
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/app/app.config.ts +29 -0
- package/app/components/AppCard.vue +22 -0
- package/app/components/AppFooter.vue +22 -0
- package/app/components/AppHeader.vue +398 -0
- package/app/components/AppLogo.vue +65 -0
- package/app/components/AppUserMenu.vue +128 -0
- package/app/components/ConfirmationModal.vue +78 -0
- package/app/components/CustomPageCard.vue +39 -0
- package/app/components/DashboardContribution.vue +31 -0
- package/app/components/InvitationActions.vue +97 -0
- package/app/components/NotificationsSlideover.vue +41 -0
- package/app/components/PortalListToolbar.vue +122 -0
- package/app/components/home/HomeDateRangePicker.vue +119 -0
- package/app/components/home/HomePeriodSelect.vue +45 -0
- package/app/composables/useDashboard.ts +28 -0
- package/app/composables/useInvitationManagement.ts +4 -0
- package/app/composables/useModuleNavigation.ts +126 -0
- package/app/composables/useNavigationLinks.ts +83 -0
- package/app/layouts/auth.vue +32 -0
- package/app/layouts/centerform.vue +5 -0
- package/app/layouts/default.vue +91 -0
- package/app/layouts/portal.vue +22 -0
- package/app/pages/dashboard.vue +68 -0
- package/nuxt.config.ts +5 -0
- package/package.json +42 -0
- package/portal.manifest.mjs +6 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nuxt Customer Portal contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const dropdownContent = 'bg-default ring-1 ring-accented shadow-xl shadow-black/15 dark:shadow-black/40'
|
|
2
|
+
const selectItem =
|
|
3
|
+
'data-highlighted:not-data-disabled:before:bg-accented data-[state=checked]:before:bg-elevated data-[state=checked]:font-medium'
|
|
4
|
+
|
|
5
|
+
export default defineAppConfig({
|
|
6
|
+
ui: {
|
|
7
|
+
button: {
|
|
8
|
+
compoundVariants: [
|
|
9
|
+
{
|
|
10
|
+
color: 'primary',
|
|
11
|
+
variant: 'solid',
|
|
12
|
+
class: 'text-[color:var(--portal-on-primary,var(--ui-text-inverted))] hover:bg-primary active:bg-primary'
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
select: {
|
|
17
|
+
slots: { content: dropdownContent, item: selectItem }
|
|
18
|
+
},
|
|
19
|
+
selectMenu: {
|
|
20
|
+
slots: { content: dropdownContent, item: selectItem }
|
|
21
|
+
},
|
|
22
|
+
dropdownMenu: {
|
|
23
|
+
slots: {
|
|
24
|
+
content: dropdownContent,
|
|
25
|
+
item: 'data-highlighted:before:bg-accented data-[state=open]:before:bg-accented'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
title: string
|
|
4
|
+
description?: string
|
|
5
|
+
}>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<UCard>
|
|
10
|
+
<template #header>
|
|
11
|
+
<div>
|
|
12
|
+
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
13
|
+
{{ title }}
|
|
14
|
+
</h2>
|
|
15
|
+
<h6 v-if="description" class="text-sm text-gray-600 dark:text-gray-400">
|
|
16
|
+
{{ description }}
|
|
17
|
+
</h6>
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
<slot />
|
|
21
|
+
</UCard>
|
|
22
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { t } = useI18n()
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<USeparator class="h-px" />
|
|
7
|
+
|
|
8
|
+
<UFooter
|
|
9
|
+
class="portal-footer"
|
|
10
|
+
:ui="{ top: 'border-b border-default', container: 'w-full max-w-none px-0 sm:px-0 lg:px-0' }"
|
|
11
|
+
>
|
|
12
|
+
<template #left>
|
|
13
|
+
<p class="text-muted text-sm">© {{ new Date().getFullYear() }}. {{ t('footer.copyright') }}</p>
|
|
14
|
+
</template>
|
|
15
|
+
<template #right>
|
|
16
|
+
<nav class="flex gap-4 text-sm" aria-label="Legal">
|
|
17
|
+
<NuxtLink class="text-muted hover:text-highlighted" to="/terms">Terms</NuxtLink
|
|
18
|
+
><NuxtLink class="text-muted hover:text-highlighted" to="/privacy">Privacy</NuxtLink>
|
|
19
|
+
</nav>
|
|
20
|
+
</template>
|
|
21
|
+
</UFooter>
|
|
22
|
+
</template>
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { en, nl } from '@nuxt/ui/locale'
|
|
3
|
+
import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
showNavigation?: boolean
|
|
8
|
+
brandName?: string
|
|
9
|
+
brandTagline?: string
|
|
10
|
+
}>(),
|
|
11
|
+
{
|
|
12
|
+
showNavigation: true,
|
|
13
|
+
brandName: 'Nuxt Customer Portal',
|
|
14
|
+
brandTagline: 'Customer workspace'
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const { t, locale, setLocale } = useI18n()
|
|
19
|
+
const toast = useToast()
|
|
20
|
+
const portalRuntimeSettings = useState<{
|
|
21
|
+
branding?: { portalName?: string; tagline?: string; markLight?: string; markDark?: string }
|
|
22
|
+
appearance?: { colorMode?: string }
|
|
23
|
+
} | null>('portal-runtime-settings', () => null)
|
|
24
|
+
const colorMode = useColorMode()
|
|
25
|
+
const runtimeBrandName = computed(() => portalRuntimeSettings.value?.branding?.portalName || props.brandName)
|
|
26
|
+
const runtimeTagline = computed(() => portalRuntimeSettings.value?.branding?.tagline || props.brandTagline)
|
|
27
|
+
const runtimeMark = computed(() => {
|
|
28
|
+
const branding = portalRuntimeSettings.value?.branding
|
|
29
|
+
if (!branding) {
|
|
30
|
+
return ''
|
|
31
|
+
}
|
|
32
|
+
return colorMode.value === 'dark'
|
|
33
|
+
? branding.markDark || branding.markLight || ''
|
|
34
|
+
: branding.markLight || branding.markDark || ''
|
|
35
|
+
})
|
|
36
|
+
const showColorModeControl = computed(
|
|
37
|
+
() => !portalRuntimeSettings.value || portalRuntimeSettings.value.appearance?.colorMode === 'user-choice'
|
|
38
|
+
)
|
|
39
|
+
// User store
|
|
40
|
+
const userStore = useUserStore()
|
|
41
|
+
const { currentUser, isAuthenticated, currentSession, myOrganizations, activeOrganization, loadingOrganization } =
|
|
42
|
+
storeToRefs(userStore)
|
|
43
|
+
|
|
44
|
+
const showOrgSwitcherModal = ref(false)
|
|
45
|
+
const searchOpen = ref(false)
|
|
46
|
+
const headerMenuOpen = ref(false)
|
|
47
|
+
|
|
48
|
+
const hasMultipleOrganizations = computed(() => {
|
|
49
|
+
return myOrganizations.value && myOrganizations.value.length > 1
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Dummy ref for sidebarOpen (not used in header, but required by composable)
|
|
53
|
+
const sidebarOpen = ref(false)
|
|
54
|
+
|
|
55
|
+
// Get navigation links for search groups
|
|
56
|
+
const { searchGroups } = useNavigationLinks(sidebarOpen)
|
|
57
|
+
|
|
58
|
+
const { modules, moduleNavigationGroups, activeModuleId, activeModule } = useModuleNavigation(headerMenuOpen)
|
|
59
|
+
const expandedMobileModuleId = ref('')
|
|
60
|
+
|
|
61
|
+
watch(
|
|
62
|
+
activeModuleId,
|
|
63
|
+
(moduleId) => {
|
|
64
|
+
expandedMobileModuleId.value = moduleId
|
|
65
|
+
},
|
|
66
|
+
{ immediate: true }
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const toggleMobileModule = (moduleId: string) => {
|
|
70
|
+
expandedMobileModuleId.value = expandedMobileModuleId.value === moduleId ? '' : moduleId
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const moduleItems = computed(() => {
|
|
74
|
+
return modules.value.map((module) => ({
|
|
75
|
+
...module,
|
|
76
|
+
active: module.id === activeModuleId.value
|
|
77
|
+
}))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const moduleBadgeProps = (badge: (typeof modules.value)[number]['badge']) =>
|
|
81
|
+
typeof badge === 'object' ? badge : { label: badge }
|
|
82
|
+
|
|
83
|
+
// Create a reactive locale ref that's properly initialized
|
|
84
|
+
const currentLocale = ref(locale.value)
|
|
85
|
+
|
|
86
|
+
// Watch for locale changes and handle them properly
|
|
87
|
+
watch(
|
|
88
|
+
locale,
|
|
89
|
+
(newLocale) => {
|
|
90
|
+
currentLocale.value = newLocale
|
|
91
|
+
setLocale(newLocale)
|
|
92
|
+
console.log('Locale changed to:', newLocale)
|
|
93
|
+
// No URL change needed with no_prefix strategy
|
|
94
|
+
},
|
|
95
|
+
{ immediate: false }
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Watch currentLocale changes to update the global locale
|
|
99
|
+
watch(currentLocale, (newLocale) => {
|
|
100
|
+
setLocale(newLocale)
|
|
101
|
+
// No URL change needed with no_prefix strategy
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Impersonation state
|
|
105
|
+
const isImpersonating = computed(() => !!currentSession.value?.impersonatedBy)
|
|
106
|
+
|
|
107
|
+
// Stop impersonating function
|
|
108
|
+
const stopImpersonating = async () => {
|
|
109
|
+
try {
|
|
110
|
+
const { error } = await authClient.admin.stopImpersonating()
|
|
111
|
+
if (error) {
|
|
112
|
+
throw new Error(error.message || t('admin.user.impersonate.stopError'))
|
|
113
|
+
}
|
|
114
|
+
toast.add({
|
|
115
|
+
title: t('common.success'),
|
|
116
|
+
description: t('admin.user.impersonate.stopSuccess'),
|
|
117
|
+
color: 'success'
|
|
118
|
+
})
|
|
119
|
+
// Reload with the restored admin session before opening user management.
|
|
120
|
+
window.location.href = '/admin/users'
|
|
121
|
+
} catch (err: unknown) {
|
|
122
|
+
const errorMessage = err instanceof Error ? err.message : t('admin.user.impersonate.stopError')
|
|
123
|
+
toast.add({
|
|
124
|
+
title: t('common.error'),
|
|
125
|
+
description: errorMessage,
|
|
126
|
+
color: 'error'
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<template>
|
|
133
|
+
<!-- Impersonation Banner -->
|
|
134
|
+
<div v-if="isImpersonating" class="relative z-50 shrink-0">
|
|
135
|
+
<UAlert
|
|
136
|
+
color="warning"
|
|
137
|
+
orientation="horizontal"
|
|
138
|
+
:title="t('admin.user.impersonate.indicator')"
|
|
139
|
+
:ui="{
|
|
140
|
+
root: 'rounded-none bg-warning-50 px-4 py-2 dark:bg-warning-950',
|
|
141
|
+
wrapper: 'flex-1',
|
|
142
|
+
title: 'text-sm font-medium',
|
|
143
|
+
actions: 'ml-auto'
|
|
144
|
+
}"
|
|
145
|
+
variant="outline"
|
|
146
|
+
>
|
|
147
|
+
<template #actions>
|
|
148
|
+
<UButton color="warning" variant="solid" size="sm" @click="stopImpersonating">
|
|
149
|
+
{{ t('admin.user.impersonate.stop') }}
|
|
150
|
+
</UButton>
|
|
151
|
+
</template>
|
|
152
|
+
</UAlert>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<UHeader
|
|
156
|
+
v-model:open="headerMenuOpen"
|
|
157
|
+
class="portal-header"
|
|
158
|
+
:title="activeModule?.label || t('menu.module')"
|
|
159
|
+
mode="slideover"
|
|
160
|
+
:menu="{ side: 'right' }"
|
|
161
|
+
:ui="{
|
|
162
|
+
root: 'relative top-auto',
|
|
163
|
+
container: 'w-full max-w-none px-0',
|
|
164
|
+
content: 'w-full max-w-sm',
|
|
165
|
+
body: 'flex h-full flex-col overflow-y-auto p-0 sm:p-0'
|
|
166
|
+
}"
|
|
167
|
+
>
|
|
168
|
+
<template #left>
|
|
169
|
+
<div class="flex items-center gap-3">
|
|
170
|
+
<!-- Logo Icon -->
|
|
171
|
+
<NuxtLink to="/" class="shrink-0" :aria-label="runtimeBrandName">
|
|
172
|
+
<div class="relative">
|
|
173
|
+
<img v-if="runtimeMark" :src="runtimeMark" alt="" class="size-10 rounded-lg object-contain" />
|
|
174
|
+
<svg
|
|
175
|
+
v-else
|
|
176
|
+
class="portal-logo-mark"
|
|
177
|
+
width="40"
|
|
178
|
+
height="40"
|
|
179
|
+
viewBox="0 0 40 40"
|
|
180
|
+
fill="none"
|
|
181
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
182
|
+
aria-hidden="true"
|
|
183
|
+
>
|
|
184
|
+
<!-- Background circle -->
|
|
185
|
+
<circle cx="20" cy="20" r="20" fill="var(--portal-logo-surface)" />
|
|
186
|
+
<!-- Building/facility icon -->
|
|
187
|
+
<rect x="12" y="15" width="4" height="10" fill="var(--portal-logo-ink)" rx="1" />
|
|
188
|
+
<rect x="18" y="12" width="4" height="13" fill="var(--portal-logo-ink)" rx="1" />
|
|
189
|
+
<rect x="24" y="18" width="4" height="7" fill="var(--portal-logo-ink)" rx="1" />
|
|
190
|
+
<!-- Roof/peak -->
|
|
191
|
+
<path d="M10 15 L20 8 L30 15 L28 15 L20 10 L12 15 Z" fill="var(--portal-logo-ink)" />
|
|
192
|
+
<!-- Door -->
|
|
193
|
+
<rect x="18" y="20" width="2" height="5" fill="var(--portal-logo-surface)" />
|
|
194
|
+
</svg>
|
|
195
|
+
</div>
|
|
196
|
+
</NuxtLink>
|
|
197
|
+
|
|
198
|
+
<!-- Neutral fallback wordmark; host shells can replace the layout entirely. -->
|
|
199
|
+
<div class="hidden flex-col sm:flex">
|
|
200
|
+
<span class="portal-wordmark text-2xl font-bold text-gray-900 dark:text-white leading-tight">
|
|
201
|
+
{{ runtimeBrandName }}
|
|
202
|
+
</span>
|
|
203
|
+
<div class="-mt-1 flex items-center gap-1">
|
|
204
|
+
<span class="text-sm leading-tight text-gray-600 dark:text-gray-400">
|
|
205
|
+
{{ runtimeTagline }}
|
|
206
|
+
</span>
|
|
207
|
+
<UButton
|
|
208
|
+
v-if="isAuthenticated && activeOrganization && hasMultipleOrganizations"
|
|
209
|
+
:aria-label="t('menu.switchOrganization')"
|
|
210
|
+
:title="t('menu.switchOrganization')"
|
|
211
|
+
icon="i-lucide-arrow-left-right"
|
|
212
|
+
color="neutral"
|
|
213
|
+
variant="ghost"
|
|
214
|
+
size="xs"
|
|
215
|
+
square
|
|
216
|
+
class="hidden size-6 text-muted hover:text-highlighted lg:inline-flex"
|
|
217
|
+
@click="showOrgSwitcherModal = true"
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</template>
|
|
223
|
+
|
|
224
|
+
<nav v-if="showNavigation" class="hidden lg:flex flex-1 items-center justify-center gap-6">
|
|
225
|
+
<template v-for="module in moduleItems" :key="module.id">
|
|
226
|
+
<NuxtLink
|
|
227
|
+
:to="module.to"
|
|
228
|
+
:class="[
|
|
229
|
+
'text-sm font-medium transition-colors flex items-center gap-1.5',
|
|
230
|
+
module.active ? 'text-primary font-semibold' : 'text-muted hover:text-highlighted'
|
|
231
|
+
]"
|
|
232
|
+
>
|
|
233
|
+
<UIcon v-if="module.icon" :name="module.icon" class="w-4 h-4" />
|
|
234
|
+
{{ module.label }}
|
|
235
|
+
<UBadge v-if="module.badge" v-bind="moduleBadgeProps(module.badge)" />
|
|
236
|
+
</NuxtLink>
|
|
237
|
+
</template>
|
|
238
|
+
</nav>
|
|
239
|
+
|
|
240
|
+
<template #right>
|
|
241
|
+
<div class="hidden lg:flex items-center gap-3 ml-auto">
|
|
242
|
+
<UButton icon="i-lucide-search" color="neutral" variant="ghost" size="sm" square @click="searchOpen = true" />
|
|
243
|
+
<ULocaleSelect
|
|
244
|
+
v-model="currentLocale"
|
|
245
|
+
:locales="[en, nl]"
|
|
246
|
+
:ui="{ content: 'w-max min-w-40', itemLabel: 'whitespace-nowrap' }"
|
|
247
|
+
/>
|
|
248
|
+
<UColorModeButton v-if="showColorModeControl" />
|
|
249
|
+
|
|
250
|
+
<!-- User Avatar Dropdown (only show when user is logged in) -->
|
|
251
|
+
<AppUserMenu size="sm" />
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<!-- Organization Switcher Modal -->
|
|
255
|
+
<UModal v-model:open="showOrgSwitcherModal" :title="t('menu.switchOrganization')" :ui="{ footer: 'justify-end' }">
|
|
256
|
+
<template #body>
|
|
257
|
+
<OrganizationSwitcher
|
|
258
|
+
v-if="isAuthenticated"
|
|
259
|
+
:show-create-button="false"
|
|
260
|
+
@switched="showOrgSwitcherModal = false"
|
|
261
|
+
/>
|
|
262
|
+
</template>
|
|
263
|
+
<template #footer="{ close }">
|
|
264
|
+
<UButton label="Close" color="neutral" variant="outline" @click="close" />
|
|
265
|
+
</template>
|
|
266
|
+
</UModal>
|
|
267
|
+
</template>
|
|
268
|
+
|
|
269
|
+
<template #body>
|
|
270
|
+
<div class="border-b border-default px-4 py-2">
|
|
271
|
+
<div class="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2">
|
|
272
|
+
<UButton
|
|
273
|
+
icon="i-lucide-search"
|
|
274
|
+
:label="t('menu.search')"
|
|
275
|
+
color="neutral"
|
|
276
|
+
variant="outline"
|
|
277
|
+
size="md"
|
|
278
|
+
class="min-h-11 justify-start"
|
|
279
|
+
@click="searchOpen = true"
|
|
280
|
+
/>
|
|
281
|
+
<ULocaleSelect
|
|
282
|
+
v-model="currentLocale"
|
|
283
|
+
:locales="[en, nl]"
|
|
284
|
+
class="w-32"
|
|
285
|
+
:ui="{ content: 'w-max min-w-40', itemLabel: 'whitespace-nowrap' }"
|
|
286
|
+
/>
|
|
287
|
+
<UColorModeButton v-if="showColorModeControl" size="md" class="min-h-11 min-w-11" />
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<nav v-if="showNavigation" :aria-label="t('menu.moduleNavigation')" class="space-y-5 p-5 pt-4">
|
|
292
|
+
<section v-for="module in moduleNavigationGroups" :key="module.id" class="space-y-1">
|
|
293
|
+
<button
|
|
294
|
+
v-if="module.menuItems.length > 1"
|
|
295
|
+
type="button"
|
|
296
|
+
class="flex min-h-11 w-full items-center gap-3 rounded-md px-3 text-left text-base font-bold text-highlighted transition-colors hover:bg-elevated"
|
|
297
|
+
:class="module.id === activeModuleId ? 'bg-primary/10 text-primary' : ''"
|
|
298
|
+
:aria-expanded="expandedMobileModuleId === module.id"
|
|
299
|
+
:aria-controls="`mobile-module-${module.id}`"
|
|
300
|
+
@click="toggleMobileModule(module.id)"
|
|
301
|
+
>
|
|
302
|
+
<UIcon v-if="module.icon" :name="module.icon" class="size-5 shrink-0" />
|
|
303
|
+
<span class="min-w-0 flex-1 truncate">{{ module.label }}</span>
|
|
304
|
+
<UBadge v-if="module.badge" v-bind="moduleBadgeProps(module.badge)" />
|
|
305
|
+
<UIcon
|
|
306
|
+
name="i-lucide-chevron-down"
|
|
307
|
+
class="size-4 shrink-0 transition-transform"
|
|
308
|
+
:class="expandedMobileModuleId === module.id ? 'rotate-180' : ''"
|
|
309
|
+
/>
|
|
310
|
+
</button>
|
|
311
|
+
<NuxtLink
|
|
312
|
+
v-else
|
|
313
|
+
:to="module.menuItems[0]?.to ?? module.to"
|
|
314
|
+
class="flex min-h-11 items-center gap-3 rounded-md px-3 text-base font-bold text-highlighted transition-colors hover:bg-elevated"
|
|
315
|
+
:class="module.id === activeModuleId ? 'bg-primary/10 text-primary' : ''"
|
|
316
|
+
@click="headerMenuOpen = false"
|
|
317
|
+
>
|
|
318
|
+
<UIcon
|
|
319
|
+
v-if="module.menuItems[0]?.icon ?? module.icon"
|
|
320
|
+
:name="module.menuItems[0]?.icon ?? module.icon"
|
|
321
|
+
class="size-5 shrink-0"
|
|
322
|
+
/>
|
|
323
|
+
<span class="min-w-0 flex-1 truncate">{{ module.menuItems[0]?.label ?? module.label }}</span>
|
|
324
|
+
<UBadge
|
|
325
|
+
v-if="module.menuItems[0]?.badge ?? module.badge"
|
|
326
|
+
v-bind="moduleBadgeProps(module.menuItems[0]?.badge ?? module.badge)"
|
|
327
|
+
/>
|
|
328
|
+
</NuxtLink>
|
|
329
|
+
<UNavigationMenu
|
|
330
|
+
v-if="module.menuItems.length > 1 && expandedMobileModuleId === module.id"
|
|
331
|
+
:id="`mobile-module-${module.id}`"
|
|
332
|
+
:items="module.menuItems"
|
|
333
|
+
orientation="vertical"
|
|
334
|
+
class="w-full pl-5"
|
|
335
|
+
:ui="{
|
|
336
|
+
list: 'gap-1 border-l border-default pl-3',
|
|
337
|
+
link: 'min-h-10 rounded-md px-3 text-sm font-medium'
|
|
338
|
+
}"
|
|
339
|
+
/>
|
|
340
|
+
</section>
|
|
341
|
+
</nav>
|
|
342
|
+
|
|
343
|
+
<div class="mt-auto border-t border-default p-4">
|
|
344
|
+
<!-- Active Organization Display for Mobile -->
|
|
345
|
+
<UButton
|
|
346
|
+
v-if="isAuthenticated && loadingOrganization"
|
|
347
|
+
variant="ghost"
|
|
348
|
+
size="md"
|
|
349
|
+
disabled
|
|
350
|
+
block
|
|
351
|
+
class="min-h-11 justify-start text-muted"
|
|
352
|
+
>
|
|
353
|
+
<UIcon name="i-lucide-loader-2" class="w-4 h-4 animate-spin" />
|
|
354
|
+
</UButton>
|
|
355
|
+
<div v-else-if="isAuthenticated && activeOrganization" class="flex items-center gap-1">
|
|
356
|
+
<UButton
|
|
357
|
+
variant="ghost"
|
|
358
|
+
size="md"
|
|
359
|
+
icon="i-lucide-building-2"
|
|
360
|
+
class="min-h-11 min-w-0 flex-1 justify-start text-muted hover:text-highlighted"
|
|
361
|
+
@click="showOrgSwitcherModal = true"
|
|
362
|
+
>
|
|
363
|
+
<span class="truncate">{{ activeOrganization.name }}</span>
|
|
364
|
+
</UButton>
|
|
365
|
+
<UButton
|
|
366
|
+
v-if="hasMultipleOrganizations"
|
|
367
|
+
:aria-label="t('menu.switchOrganization')"
|
|
368
|
+
:title="t('menu.switchOrganization')"
|
|
369
|
+
icon="i-lucide-arrow-left-right"
|
|
370
|
+
color="neutral"
|
|
371
|
+
variant="ghost"
|
|
372
|
+
size="md"
|
|
373
|
+
square
|
|
374
|
+
class="min-h-11 min-w-11 shrink-0 text-muted hover:text-highlighted"
|
|
375
|
+
@click="showOrgSwitcherModal = true"
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div v-if="currentUser" class="mt-2 border-t border-default pt-2">
|
|
380
|
+
<AppUserMenu inline size="md" @navigate="headerMenuOpen = false" />
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</template>
|
|
384
|
+
</UHeader>
|
|
385
|
+
|
|
386
|
+
<!-- Dashboard Search Modal -->
|
|
387
|
+
<UDashboardSearch
|
|
388
|
+
v-model:open="searchOpen"
|
|
389
|
+
:groups="searchGroups"
|
|
390
|
+
:fuse="{
|
|
391
|
+
fuseOptions: {
|
|
392
|
+
ignoreLocation: true,
|
|
393
|
+
threshold: 0.1,
|
|
394
|
+
keys: ['label', 'suffix', '_searchText']
|
|
395
|
+
}
|
|
396
|
+
}"
|
|
397
|
+
/>
|
|
398
|
+
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
name?: string
|
|
5
|
+
tagline?: string
|
|
6
|
+
}>(),
|
|
7
|
+
{
|
|
8
|
+
name: 'Nuxt Customer Portal',
|
|
9
|
+
tagline: 'Customer workspace'
|
|
10
|
+
}
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const portalRuntimeSettings = useState<{
|
|
14
|
+
branding?: { portalName?: string; tagline?: string; markLight?: string; markDark?: string }
|
|
15
|
+
} | null>('portal-runtime-settings', () => null)
|
|
16
|
+
const colorMode = useColorMode()
|
|
17
|
+
const runtimeName = computed(() => portalRuntimeSettings.value?.branding?.portalName || props.name)
|
|
18
|
+
const runtimeTagline = computed(() => portalRuntimeSettings.value?.branding?.tagline || props.tagline)
|
|
19
|
+
const runtimeMark = computed(() => {
|
|
20
|
+
const branding = portalRuntimeSettings.value?.branding
|
|
21
|
+
return colorMode.value === 'dark'
|
|
22
|
+
? branding?.markDark || branding?.markLight || ''
|
|
23
|
+
: branding?.markLight || branding?.markDark || ''
|
|
24
|
+
})
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div class="flex items-center gap-3">
|
|
29
|
+
<!-- Logo Icon -->
|
|
30
|
+
<div class="relative">
|
|
31
|
+
<img v-if="runtimeMark" :src="runtimeMark" alt="" class="size-10 rounded-lg object-contain" />
|
|
32
|
+
<svg
|
|
33
|
+
v-else
|
|
34
|
+
class="portal-logo-mark"
|
|
35
|
+
width="40"
|
|
36
|
+
height="40"
|
|
37
|
+
viewBox="0 0 40 40"
|
|
38
|
+
fill="none"
|
|
39
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
>
|
|
42
|
+
<!-- Background circle -->
|
|
43
|
+
<circle cx="20" cy="20" r="20" fill="var(--portal-logo-surface)" />
|
|
44
|
+
<!-- Building/facility icon -->
|
|
45
|
+
<rect x="12" y="15" width="4" height="10" fill="var(--portal-logo-ink)" rx="1" />
|
|
46
|
+
<rect x="18" y="12" width="4" height="13" fill="var(--portal-logo-ink)" rx="1" />
|
|
47
|
+
<rect x="24" y="18" width="4" height="7" fill="var(--portal-logo-ink)" rx="1" />
|
|
48
|
+
<!-- Roof/peak -->
|
|
49
|
+
<path d="M10 15 L20 8 L30 15 L28 15 L20 10 L12 15 Z" fill="var(--portal-logo-ink)" />
|
|
50
|
+
<!-- Door -->
|
|
51
|
+
<rect x="18" y="20" width="2" height="5" fill="var(--portal-logo-surface)" />
|
|
52
|
+
</svg>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- Company Name -->
|
|
56
|
+
<div class="flex flex-col">
|
|
57
|
+
<span class="portal-wordmark text-2xl font-bold text-gray-900 dark:text-white leading-tight">
|
|
58
|
+
{{ runtimeName }}
|
|
59
|
+
</span>
|
|
60
|
+
<span class="text-sm text-gray-600 dark:text-gray-400 leading-tight -mt-1">
|
|
61
|
+
{{ runtimeTagline }}
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|