@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.
@@ -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,4 @@
1
+ export const useInvitationManagement = () => ({
2
+ changeRole: (endpoint: string, role: string) => $fetch(endpoint, { method: 'PATCH', body: { role } }),
3
+ revoke: (endpoint: string) => $fetch(endpoint, { method: 'PATCH', body: { status: 'canceled' } })
4
+ })
@@ -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,5 @@
1
+ <template>
2
+ <div class="auth-shell h-screen flex items-center justify-center px-4">
3
+ <slot />
4
+ </div>
5
+ </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
@@ -0,0 +1,5 @@
1
+ export default defineNuxtConfig({
2
+ $meta: { name: 'nuxt-customer-portal-ui' },
3
+ compatibilityDate: '2025-10-24',
4
+ modules: ['@nuxt/ui', '@nuxtjs/i18n', '@pinia/nuxt']
5
+ })
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
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ id: 'ui',
3
+ version: '0.3.0',
4
+ source: '@nuxt-customer-portal/ui',
5
+ dependsOn: ['core']
6
+ }