@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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { eachDayOfInterval } from 'date-fns'
|
|
3
|
+
import type { Period, Range } from '@nuxt-customer-portal/core/app/types'
|
|
4
|
+
|
|
5
|
+
const model = defineModel<Period>({ required: true })
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
range: Range
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const days = computed(() => eachDayOfInterval(props.range))
|
|
12
|
+
|
|
13
|
+
const periods = computed<Period[]>(() => {
|
|
14
|
+
if (days.value.length <= 8) {
|
|
15
|
+
return ['daily']
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (days.value.length <= 31) {
|
|
19
|
+
return ['daily', 'weekly']
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return ['weekly', 'monthly']
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Ensure the model value is always a valid period
|
|
26
|
+
watch(periods, () => {
|
|
27
|
+
if (!periods.value.includes(model.value)) {
|
|
28
|
+
model.value = periods.value[0]!
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<USelect
|
|
35
|
+
v-model="model"
|
|
36
|
+
:items="periods"
|
|
37
|
+
variant="ghost"
|
|
38
|
+
class="data-[state=open]:bg-elevated"
|
|
39
|
+
:ui="{
|
|
40
|
+
value: 'capitalize',
|
|
41
|
+
itemLabel: 'capitalize',
|
|
42
|
+
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200'
|
|
43
|
+
}"
|
|
44
|
+
/>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createSharedComposable } from '@vueuse/core'
|
|
2
|
+
|
|
3
|
+
const _useDashboard = () => {
|
|
4
|
+
const route = useRoute()
|
|
5
|
+
const router = useRouter()
|
|
6
|
+
const isNotificationsSlideoverOpen = ref(false)
|
|
7
|
+
|
|
8
|
+
defineShortcuts({
|
|
9
|
+
'g-h': () => router.push('/'),
|
|
10
|
+
'g-i': () => router.push('/inbox'),
|
|
11
|
+
'g-c': () => router.push('/customers'),
|
|
12
|
+
'g-s': () => router.push('/settings'),
|
|
13
|
+
n: () => (isNotificationsSlideoverOpen.value = !isNotificationsSlideoverOpen.value)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
watch(
|
|
17
|
+
() => route.fullPath,
|
|
18
|
+
() => {
|
|
19
|
+
isNotificationsSlideoverOpen.value = false
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
isNotificationsSlideoverOpen
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const useDashboard = createSharedComposable(_useDashboard)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
2
|
+
import type { PortalAudience } from '@nuxt-customer-portal/core/shared/types/feature'
|
|
3
|
+
|
|
4
|
+
const hasAudience = (
|
|
5
|
+
audiences: PortalAudience[],
|
|
6
|
+
isAuthenticated: boolean,
|
|
7
|
+
isAdmin: boolean,
|
|
8
|
+
organizationRole: string | null,
|
|
9
|
+
organizationType: 'PROVIDER' | 'CLIENT' | null
|
|
10
|
+
) =>
|
|
11
|
+
audiences.some((audience) => {
|
|
12
|
+
if (audience === 'public') {
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
if (audience === 'authenticated') {
|
|
16
|
+
return isAuthenticated
|
|
17
|
+
}
|
|
18
|
+
if (audience === 'admin') {
|
|
19
|
+
return isAdmin
|
|
20
|
+
}
|
|
21
|
+
if (audience === 'providerAuthenticated') {
|
|
22
|
+
return organizationType === 'PROVIDER' && Boolean(organizationRole)
|
|
23
|
+
}
|
|
24
|
+
if (audience === 'providerAdmin') {
|
|
25
|
+
return organizationType === 'PROVIDER' && (organizationRole === 'owner' || organizationRole === 'admin')
|
|
26
|
+
}
|
|
27
|
+
if (audience === 'clientAuthenticated') {
|
|
28
|
+
return organizationType === 'CLIENT' && Boolean(organizationRole)
|
|
29
|
+
}
|
|
30
|
+
if (audience === 'clientAdmin') {
|
|
31
|
+
return organizationType === 'CLIENT' && (organizationRole === 'owner' || organizationRole === 'admin')
|
|
32
|
+
}
|
|
33
|
+
return organizationType === 'PROVIDER' && (organizationRole === 'owner' || organizationRole === 'admin')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const useModuleNavigation = (sidebarOpen?: Ref<boolean>) => {
|
|
37
|
+
const { t } = useI18n()
|
|
38
|
+
const route = useRoute()
|
|
39
|
+
const { modules: registeredModules } = usePortalFeatures()
|
|
40
|
+
const { isAuthenticated, isSystemAdmin, activeOrganizationRole, activeOrganizationType } = usePortalSession()
|
|
41
|
+
|
|
42
|
+
const modules = computed(() =>
|
|
43
|
+
registeredModules.value
|
|
44
|
+
.filter((module) =>
|
|
45
|
+
hasAudience(
|
|
46
|
+
module.audiences,
|
|
47
|
+
isAuthenticated.value,
|
|
48
|
+
isSystemAdmin.value,
|
|
49
|
+
activeOrganizationRole.value,
|
|
50
|
+
activeOrganizationType.value
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
.map((module) => ({ ...module, label: t(module.labelKey) }))
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const activeModule = computed(
|
|
57
|
+
() =>
|
|
58
|
+
modules.value
|
|
59
|
+
.map((module) => ({
|
|
60
|
+
module,
|
|
61
|
+
matchLength: Math.max(
|
|
62
|
+
0,
|
|
63
|
+
...module.routePrefixes.filter((prefix) => route.path.startsWith(prefix)).map((prefix) => prefix.length)
|
|
64
|
+
)
|
|
65
|
+
}))
|
|
66
|
+
.filter((item) => item.matchLength > 0)
|
|
67
|
+
.sort((left, right) => right.matchLength - left.matchLength)[0]?.module
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const activeModuleId = computed(() => activeModule.value?.id ?? '')
|
|
71
|
+
const moduleNavigationGroups = computed(() =>
|
|
72
|
+
modules.value.map((module) => ({
|
|
73
|
+
...module,
|
|
74
|
+
menuItems: (module.menuItems ?? [])
|
|
75
|
+
.filter((item) =>
|
|
76
|
+
hasAudience(
|
|
77
|
+
item.audiences,
|
|
78
|
+
isAuthenticated.value,
|
|
79
|
+
isSystemAdmin.value,
|
|
80
|
+
activeOrganizationRole.value,
|
|
81
|
+
activeOrganizationType.value
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
85
|
+
.map((item) => ({
|
|
86
|
+
label: t(item.labelKey),
|
|
87
|
+
icon: item.icon,
|
|
88
|
+
to: item.to,
|
|
89
|
+
exact: item.exact,
|
|
90
|
+
badge: item.badge,
|
|
91
|
+
onSelect: () => {
|
|
92
|
+
if (sidebarOpen) {
|
|
93
|
+
sidebarOpen.value = false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}))
|
|
97
|
+
}))
|
|
98
|
+
)
|
|
99
|
+
const activeModuleMenuItems = computed<NavigationMenuItem[]>(() =>
|
|
100
|
+
(activeModule.value?.menuItems ?? [])
|
|
101
|
+
.filter((item) =>
|
|
102
|
+
hasAudience(
|
|
103
|
+
item.audiences,
|
|
104
|
+
isAuthenticated.value,
|
|
105
|
+
isSystemAdmin.value,
|
|
106
|
+
activeOrganizationRole.value,
|
|
107
|
+
activeOrganizationType.value
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
111
|
+
.map((item) => ({
|
|
112
|
+
label: t(item.labelKey),
|
|
113
|
+
icon: item.icon,
|
|
114
|
+
to: item.to,
|
|
115
|
+
exact: item.exact,
|
|
116
|
+
badge: item.badge,
|
|
117
|
+
onSelect: () => {
|
|
118
|
+
if (sidebarOpen) {
|
|
119
|
+
sidebarOpen.value = false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}))
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return { modules, moduleNavigationGroups, activeModuleId, activeModule, activeModuleMenuItems }
|
|
126
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CommandPaletteGroup, CommandPaletteItem, NavigationMenuItem } from '@nuxt/ui'
|
|
2
|
+
import type { PortalAudience } from '@nuxt-customer-portal/core/shared/types/feature'
|
|
3
|
+
|
|
4
|
+
const hasAudience = (
|
|
5
|
+
audiences: PortalAudience[],
|
|
6
|
+
isAuthenticated: boolean,
|
|
7
|
+
isAdmin: boolean,
|
|
8
|
+
organizationRole: string | null,
|
|
9
|
+
organizationType: 'PROVIDER' | 'CLIENT' | null
|
|
10
|
+
) =>
|
|
11
|
+
audiences.some((audience) => {
|
|
12
|
+
if (audience === 'public') {
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
if (audience === 'authenticated') {
|
|
16
|
+
return isAuthenticated
|
|
17
|
+
}
|
|
18
|
+
if (audience === 'admin') {
|
|
19
|
+
return isAdmin
|
|
20
|
+
}
|
|
21
|
+
if (audience === 'providerAuthenticated') {
|
|
22
|
+
return organizationType === 'PROVIDER' && Boolean(organizationRole)
|
|
23
|
+
}
|
|
24
|
+
if (audience === 'providerAdmin') {
|
|
25
|
+
return organizationType === 'PROVIDER' && (organizationRole === 'owner' || organizationRole === 'admin')
|
|
26
|
+
}
|
|
27
|
+
if (audience === 'clientAuthenticated') {
|
|
28
|
+
return organizationType === 'CLIENT' && Boolean(organizationRole)
|
|
29
|
+
}
|
|
30
|
+
if (audience === 'clientAdmin') {
|
|
31
|
+
return organizationType === 'CLIENT' && (organizationRole === 'owner' || organizationRole === 'admin')
|
|
32
|
+
}
|
|
33
|
+
return organizationType === 'PROVIDER' && (organizationRole === 'owner' || organizationRole === 'admin')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const useNavigationLinks = (sidebarOpen: Ref<boolean>) => {
|
|
37
|
+
const { t } = useI18n()
|
|
38
|
+
const route = useRoute()
|
|
39
|
+
const { navigation } = usePortalFeatures()
|
|
40
|
+
const { isAuthenticated, isSystemAdmin, activeOrganizationRole, activeOrganizationType } = usePortalSession()
|
|
41
|
+
|
|
42
|
+
const visibleItems = computed(() =>
|
|
43
|
+
navigation.value.filter(
|
|
44
|
+
(item) =>
|
|
45
|
+
hasAudience(
|
|
46
|
+
item.audiences,
|
|
47
|
+
isAuthenticated.value,
|
|
48
|
+
isSystemAdmin.value,
|
|
49
|
+
activeOrganizationRole.value,
|
|
50
|
+
activeOrganizationType.value
|
|
51
|
+
) &&
|
|
52
|
+
(route.path === '/' || !item.audiences.every((audience) => audience === 'public'))
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const links = computed<NavigationMenuItem[][]>(() => [
|
|
57
|
+
visibleItems.value.map((item) => ({
|
|
58
|
+
id: item.id,
|
|
59
|
+
label: t(item.labelKey),
|
|
60
|
+
icon: item.icon,
|
|
61
|
+
to: item.to,
|
|
62
|
+
badge: item.badge,
|
|
63
|
+
onSelect: () => {
|
|
64
|
+
sidebarOpen.value = false
|
|
65
|
+
}
|
|
66
|
+
}))
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
const searchGroups = computed<CommandPaletteGroup<CommandPaletteItem>[]>(() => [
|
|
70
|
+
{
|
|
71
|
+
id: 'navigation',
|
|
72
|
+
label: t('menu.search'),
|
|
73
|
+
items: visibleItems.value.map((item) => ({
|
|
74
|
+
id: item.id,
|
|
75
|
+
label: t(item.labelKey),
|
|
76
|
+
icon: item.icon,
|
|
77
|
+
to: item.to
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
return { links, searchGroups }
|
|
83
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!-- eslint-disable vue/multi-word-component-names -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
const route = useRoute()
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const bootstrap = useState<{ adminExists?: boolean; completed?: boolean } | null>('portal-bootstrap-state', () => null)
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<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">
|
|
20
|
+
<UAlert
|
|
21
|
+
v-if="route.path === '/signup' && bootstrap && !bootstrap.adminExists"
|
|
22
|
+
class="mb-5"
|
|
23
|
+
color="info"
|
|
24
|
+
variant="subtle"
|
|
25
|
+
icon="i-lucide-shield-check"
|
|
26
|
+
:title="t('saasSettings.bootstrap.title')"
|
|
27
|
+
:description="t('saasSettings.bootstrap.description')"
|
|
28
|
+
/>
|
|
29
|
+
<slot />
|
|
30
|
+
</UPageCard>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!-- Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
const route = useRoute()
|
|
4
|
+
const toast = useToast()
|
|
5
|
+
const open = ref(false)
|
|
6
|
+
const showFooter = computed(() => route.meta?.public === true)
|
|
7
|
+
|
|
8
|
+
const { activeModule, activeModuleMenuItems } = useModuleNavigation(open)
|
|
9
|
+
const { currentSession } = storeToRefs(useUserStore())
|
|
10
|
+
const isImpersonating = computed(() => Boolean(currentSession.value?.impersonatedBy))
|
|
11
|
+
|
|
12
|
+
onMounted(async () => {
|
|
13
|
+
const cookie = useCookie('cookie-consent')
|
|
14
|
+
if (cookie.value === 'accepted') {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
toast.add({
|
|
19
|
+
title: 'We use first-party cookies to enhance your experience on our website.',
|
|
20
|
+
duration: 0,
|
|
21
|
+
close: false,
|
|
22
|
+
actions: [
|
|
23
|
+
{
|
|
24
|
+
label: 'Accept',
|
|
25
|
+
color: 'neutral',
|
|
26
|
+
variant: 'outline',
|
|
27
|
+
onClick: () => {
|
|
28
|
+
cookie.value = 'accepted'
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
label: 'Opt out',
|
|
33
|
+
color: 'neutral',
|
|
34
|
+
variant: 'ghost'
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div class="dashboard-layout relative min-h-screen">
|
|
43
|
+
<!-- AppHeader - fixed at top -->
|
|
44
|
+
<div class="fixed top-0 left-0 right-0 z-50">
|
|
45
|
+
<div class="mx-auto w-full max-w-[1600px] px-4">
|
|
46
|
+
<AppHeader />
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<UDashboardGroup unit="rem" class="px-4 mx-auto max-w-[1600px]! w-full!">
|
|
51
|
+
<UDashboardSidebar
|
|
52
|
+
id="default"
|
|
53
|
+
v-model:open="open"
|
|
54
|
+
collapsible
|
|
55
|
+
resizable
|
|
56
|
+
:default-size="18"
|
|
57
|
+
:min-size="18"
|
|
58
|
+
:max-size="24"
|
|
59
|
+
class="min-w-72 bg-elevated/25 lg:pb-12 data-[collapsed=true]:min-w-0"
|
|
60
|
+
:ui="{ footer: 'lg:border-t lg:border-default' }"
|
|
61
|
+
>
|
|
62
|
+
<template #header>
|
|
63
|
+
<div v-if="activeModule" class="flex min-w-0 items-center gap-2 px-2 text-sm font-semibold">
|
|
64
|
+
<UIcon v-if="activeModule.icon" :name="activeModule.icon" class="size-4 shrink-0 text-primary" />
|
|
65
|
+
<span class="truncate">{{ activeModule.label }}</span>
|
|
66
|
+
</div>
|
|
67
|
+
</template>
|
|
68
|
+
<template #default="{ collapsed }">
|
|
69
|
+
<UDashboardSearchButton :collapsed="collapsed" class="mt-2 bg-transparent ring-default" />
|
|
70
|
+
<div v-if="!collapsed && activeModule" class="px-2.5 pt-3 pb-1 text-xs font-semibold uppercase text-muted">
|
|
71
|
+
{{ activeModule.label }}
|
|
72
|
+
</div>
|
|
73
|
+
<UNavigationMenu
|
|
74
|
+
:collapsed="collapsed"
|
|
75
|
+
:items="activeModuleMenuItems"
|
|
76
|
+
orientation="vertical"
|
|
77
|
+
tooltip
|
|
78
|
+
popover
|
|
79
|
+
/>
|
|
80
|
+
</template>
|
|
81
|
+
</UDashboardSidebar>
|
|
82
|
+
|
|
83
|
+
<NotificationsSlideover />
|
|
84
|
+
<UMain class="flex-1 min-w-0" :class="isImpersonating ? 'pt-32 lg:pt-28' : 'pt-20 lg:pt-16'">
|
|
85
|
+
<slot />
|
|
86
|
+
</UMain>
|
|
87
|
+
</UDashboardGroup>
|
|
88
|
+
|
|
89
|
+
<AppFooter v-if="showFooter" />
|
|
90
|
+
</div>
|
|
91
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const route = useRoute()
|
|
3
|
+
const showFooter = computed(() => route.meta?.public === true)
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div class="portal-layout min-h-screen">
|
|
8
|
+
<div class="mx-auto w-full max-w-[1600px] px-4">
|
|
9
|
+
<AppHeader />
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<UMain class="main-content mx-auto w-full max-w-[1600px] px-4">
|
|
13
|
+
<slot />
|
|
14
|
+
</UMain>
|
|
15
|
+
|
|
16
|
+
<div v-if="showFooter" class="mx-auto w-full max-w-[1600px] px-4">
|
|
17
|
+
<div class="px-[clamp(1rem,4vw,4rem)]">
|
|
18
|
+
<AppFooter />
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { t } = useI18n()
|
|
3
|
+
const { dashboardWidgets } = usePortalFeatures()
|
|
4
|
+
const { currentUser } = usePortalSession()
|
|
5
|
+
|
|
6
|
+
const areas = ['attention', 'main', 'aside'] as const
|
|
7
|
+
const orderedDashboardWidgets = computed(() =>
|
|
8
|
+
[...dashboardWidgets.value].sort(
|
|
9
|
+
(left, right) =>
|
|
10
|
+
left.order - right.order ||
|
|
11
|
+
areas.indexOf(left.area) - areas.indexOf(right.area) ||
|
|
12
|
+
left.id.localeCompare(right.id)
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
const sizeClass = (size: 'full' | 'half' | 'third') =>
|
|
16
|
+
({
|
|
17
|
+
full: 'lg:col-span-12',
|
|
18
|
+
half: 'lg:col-span-6',
|
|
19
|
+
third: 'lg:col-span-4'
|
|
20
|
+
})[size]
|
|
21
|
+
|
|
22
|
+
useSeoMeta({ title: () => t('dashboard.seo.title'), description: () => t('dashboard.seo.description') })
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<UDashboardPanel
|
|
27
|
+
id="dashboard"
|
|
28
|
+
class="min-h-0 overflow-hidden"
|
|
29
|
+
style="height: calc(100dvh - var(--ui-header-height))"
|
|
30
|
+
:ui="{ body: 'flex flex-col flex-1 min-h-0 overflow-y-auto p-4 sm:p-6' }"
|
|
31
|
+
>
|
|
32
|
+
<template #header>
|
|
33
|
+
<UDashboardNavbar :toggle="false">
|
|
34
|
+
<template #leading>
|
|
35
|
+
<UIcon name="i-lucide-layout-dashboard" class="size-6 shrink-0" />
|
|
36
|
+
<span class="text-lg font-semibold">{{ t('dashboard.title') }}</span>
|
|
37
|
+
</template>
|
|
38
|
+
</UDashboardNavbar>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<template #body>
|
|
42
|
+
<div class="mx-auto w-full max-w-[1440px] space-y-6">
|
|
43
|
+
<header>
|
|
44
|
+
<h1 class="text-2xl font-semibold tracking-tight">
|
|
45
|
+
{{ t('dashboard.greeting', { name: currentUser?.name ?? '' }) }}
|
|
46
|
+
</h1>
|
|
47
|
+
<p class="mt-1 text-sm text-muted">{{ t('dashboard.introduction') }}</p>
|
|
48
|
+
</header>
|
|
49
|
+
|
|
50
|
+
<section v-if="orderedDashboardWidgets.length" :aria-label="t('dashboard.title')">
|
|
51
|
+
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
|
52
|
+
<div v-for="widget in orderedDashboardWidgets" :key="widget.id" :class="sizeClass(widget.size)">
|
|
53
|
+
<DashboardContribution :component="widget.component" />
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<UCard v-if="!dashboardWidgets.length">
|
|
59
|
+
<div class="py-8 text-center">
|
|
60
|
+
<UIcon name="i-lucide-circle-check-big" class="mx-auto size-8 text-success" />
|
|
61
|
+
<p class="mt-3 font-medium">{{ t('dashboard.empty.title') }}</p>
|
|
62
|
+
<p class="mt-1 text-sm text-muted">{{ t('dashboard.empty.description') }}</p>
|
|
63
|
+
</div>
|
|
64
|
+
</UCard>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
</UDashboardPanel>
|
|
68
|
+
</template>
|
package/nuxt.config.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nuxt-customer-portal/ui",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"homepage": "https://nuxt-customer-portal.com",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ludulicious/nuxt-customer-portal.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./nuxt.config.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"app",
|
|
16
|
+
"nuxt.config.ts",
|
|
17
|
+
"portal.manifest.mjs",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./nuxt.config.ts",
|
|
22
|
+
"./portal-manifest": "./portal.manifest.mjs"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@nuxt/ui": "^4.10.0",
|
|
29
|
+
"@nuxtjs/i18n": "^10.6.0",
|
|
30
|
+
"@pinia/nuxt": "^1.0.1",
|
|
31
|
+
"pinia": "^4.0.2",
|
|
32
|
+
"@nuxt-customer-portal/core": "^0.3.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"nuxt": "^4.5.1",
|
|
36
|
+
"vue": "^3.5.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"nuxt": "^4.5.1",
|
|
40
|
+
"vue": "^3.5.0"
|
|
41
|
+
}
|
|
42
|
+
}
|