@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,128 @@
1
+ <script setup lang="ts">
2
+ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
3
+ import type { DropdownMenuItem } from '@nuxt/ui'
4
+
5
+ withDefaults(
6
+ defineProps<{
7
+ size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'xs'
8
+ inline?: boolean
9
+ }>(),
10
+ {
11
+ size: 'sm',
12
+ inline: false
13
+ }
14
+ )
15
+
16
+ const emit = defineEmits<{ navigate: [] }>()
17
+
18
+ const { t } = useI18n()
19
+ const userStore = useUserStore()
20
+ const { currentUser, userInitials } = storeToRefs(userStore)
21
+
22
+ // Logic preserved from AppHeader
23
+ const isOrgAdmin = ref(false)
24
+
25
+ const signOut = async () => {
26
+ await authClient.signOut()
27
+ userStore.clearUserData()
28
+ emit('navigate')
29
+ await navigateTo('/')
30
+ }
31
+
32
+ const userMenuItems = computed(() => {
33
+ const menuItems: DropdownMenuItem[][] = [
34
+ [
35
+ {
36
+ label: currentUser.value?.name || currentUser.value?.email || 'User',
37
+ avatar: {
38
+ src: currentUser.value?.image || undefined,
39
+ alt: currentUser.value?.name || currentUser.value?.email || 'User'
40
+ },
41
+ type: 'label' as const
42
+ }
43
+ ],
44
+ [
45
+ {
46
+ label: t('menu.settings.title'),
47
+ icon: 'i-lucide-cog',
48
+ to: '/settings'
49
+ }
50
+ ]
51
+ ]
52
+
53
+ // Add organization menu items for admins/owners
54
+ if (isOrgAdmin.value && menuItems[1]) {
55
+ menuItems[1].push(
56
+ {
57
+ label: 'Create Organization',
58
+ icon: 'i-lucide-plus-circle',
59
+ to: '/organizations/create'
60
+ },
61
+ {
62
+ label: 'Invite User',
63
+ icon: 'i-lucide-user-plus',
64
+ onSelect: () => {
65
+ // Navigate to organization page with invite modal
66
+ navigateTo('/organization?invite=true')
67
+ }
68
+ }
69
+ )
70
+ }
71
+
72
+ menuItems.push([
73
+ {
74
+ label: t('menu.logout'),
75
+ icon: 'i-lucide-log-out',
76
+ onSelect: signOut
77
+ }
78
+ ])
79
+
80
+ return menuItems
81
+ })
82
+ </script>
83
+
84
+ <template>
85
+ <div v-if="currentUser && inline" class="space-y-2">
86
+ <div class="flex min-w-0 items-center gap-3 px-3 py-2">
87
+ <UAvatar
88
+ :src="currentUser.image ?? undefined"
89
+ :alt="currentUser.name || currentUser.email || 'User'"
90
+ :text="userInitials"
91
+ :size="size"
92
+ class="shrink-0"
93
+ />
94
+ <div class="min-w-0">
95
+ <div class="truncate text-sm font-semibold text-highlighted">{{ currentUser.name || currentUser.email }}</div>
96
+ <div v-if="currentUser.name" class="truncate text-xs text-muted">{{ currentUser.email }}</div>
97
+ </div>
98
+ </div>
99
+ <UButton
100
+ :label="t('menu.settings.title')"
101
+ icon="i-lucide-cog"
102
+ to="/settings"
103
+ color="neutral"
104
+ variant="ghost"
105
+ block
106
+ class="min-h-11 justify-start"
107
+ @click="emit('navigate')"
108
+ />
109
+ <UButton
110
+ :label="t('menu.logout')"
111
+ icon="i-lucide-log-out"
112
+ color="neutral"
113
+ variant="ghost"
114
+ block
115
+ class="min-h-11 justify-start"
116
+ @click="signOut"
117
+ />
118
+ </div>
119
+ <UDropdownMenu v-else-if="currentUser" :items="userMenuItems" :ui="{ content: 'w-48' }">
120
+ <UAvatar
121
+ :src="currentUser.image ?? undefined"
122
+ :alt="currentUser.name || currentUser.email || 'User'"
123
+ :text="userInitials"
124
+ :size="size"
125
+ class="cursor-pointer transition-[box-shadow] hover:ring-2 hover:ring-primary/20"
126
+ />
127
+ </UDropdownMenu>
128
+ </template>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ interface Props {
3
+ title: string
4
+ message: string
5
+ messageParams?: Record<string, string | number>
6
+ confirmText?: string
7
+ cancelText?: string
8
+ confirmColor?: 'primary' | 'error' | 'warning' | 'success' | 'info'
9
+ confirmVariant?: 'solid' | 'outline' | 'ghost' | 'soft'
10
+ }
11
+
12
+ const props = withDefaults(defineProps<Props>(), {
13
+ confirmText: undefined,
14
+ cancelText: undefined,
15
+ confirmColor: 'primary',
16
+ confirmVariant: 'solid',
17
+ messageParams: () => ({})
18
+ })
19
+
20
+ const emit = defineEmits<{
21
+ confirm: []
22
+ cancel: []
23
+ }>()
24
+
25
+ const open = defineModel<boolean>('open', { default: false })
26
+ const { t } = useI18n()
27
+
28
+ const handleConfirm = () => {
29
+ open.value = false
30
+ emit('confirm')
31
+ }
32
+
33
+ const handleCancel = () => {
34
+ open.value = false
35
+ emit('cancel')
36
+ }
37
+
38
+ // Helper to check if a string is an i18n key (contains a dot)
39
+ const isI18nKey = (str: string): boolean => {
40
+ return str.includes('.')
41
+ }
42
+
43
+ // Get translated or raw text
44
+ const getText = (key: string | undefined, fallback: string): string => {
45
+ if (!key) {
46
+ return fallback
47
+ }
48
+ if (isI18nKey(key)) {
49
+ return t(key)
50
+ }
51
+ return key
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <UModal v-model:open="open" :title="getText(title, title)" :ui="{ footer: 'justify-end' }">
57
+ <template #body>
58
+ <div class="space-y-4">
59
+ <p class="text-sm text-gray-600 dark:text-gray-400">
60
+ <template v-if="isI18nKey(props.message)">
61
+ {{ t(props.message, props.messageParams) }}
62
+ </template>
63
+ <template v-else>
64
+ {{ props.message }}
65
+ </template>
66
+ </p>
67
+ <div class="flex gap-4 justify-end pt-4">
68
+ <UButton type="button" variant="outline" @click="handleCancel">
69
+ {{ getText(props.cancelText, t('common.cancel')) }}
70
+ </UButton>
71
+ <UButton type="button" :color="confirmColor" :variant="confirmVariant" @click="handleConfirm">
72
+ {{ getText(props.confirmText, t('common.confirm')) }}
73
+ </UButton>
74
+ </div>
75
+ </div>
76
+ </template>
77
+ </UModal>
78
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ const props = withDefaults(
3
+ defineProps<{
4
+ title?: string
5
+ description?: string
6
+ success?: string
7
+ error?: string
8
+ showLogo?: boolean
9
+ }>(),
10
+ {
11
+ title: '',
12
+ description: '',
13
+ success: '',
14
+ error: '',
15
+ showLogo: true
16
+ }
17
+ )
18
+ </script>
19
+
20
+ <template>
21
+ <div>
22
+ <UAlert v-if="props.success" color="success" :description="props.success" variant="outline" />
23
+ <UAlert v-if="props.error" color="error" :description="props.error" variant="outline" />
24
+ <UPageCard variant="subtle" class="max-w-sm w-full mt-10 space-y-6 text-center">
25
+ <template v-if="props.title" #title>
26
+ <div class="flex justify-center mb-10">
27
+ <AppLogo class="w-auto h-6 shrink-0" />
28
+ </div>
29
+ <h2 class="text-2xl text-center">{{ props.title }}</h2>
30
+ </template>
31
+ <template v-if="props.description" #description>
32
+ <div class="text-center text-sm text-gray-500 mb-5">
33
+ {{ props.description }}
34
+ </div>
35
+ </template>
36
+ <slot />
37
+ </UPageCard>
38
+ </div>
39
+ </template>
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{ component: string }>()
3
+ // Component resolution depends on the active Vue instance. Resolve during setup;
4
+ // deferring it through a computed getter runs outside that context during render
5
+ // and turns registered Nuxt components into empty custom elements.
6
+ const resolvedComponent = resolveComponent(props.component)
7
+ const componentMissing = typeof resolvedComponent === 'string'
8
+ </script>
9
+
10
+ <template>
11
+ <NuxtErrorBoundary>
12
+ <component :is="resolvedComponent" v-if="!componentMissing" />
13
+ <UCard v-else>
14
+ <p class="font-medium">{{ $t('dashboard.error.title') }}</p>
15
+ <p class="mt-1 text-sm text-muted">{{ $t('dashboard.error.description') }}</p>
16
+ </UCard>
17
+ <template #error="{ clearError }">
18
+ <UCard>
19
+ <div class="flex items-center justify-between gap-4">
20
+ <div>
21
+ <p class="font-medium">{{ $t('dashboard.error.title') }}</p>
22
+ <p class="mt-1 text-sm text-muted">{{ $t('dashboard.error.description') }}</p>
23
+ </div>
24
+ <UButton color="neutral" variant="outline" icon="i-lucide-refresh-cw" @click="clearError()">
25
+ {{ $t('dashboard.error.retry') }}
26
+ </UButton>
27
+ </div>
28
+ </UCard>
29
+ </template>
30
+ </NuxtErrorBoundary>
31
+ </template>
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ import { invitationRoleSchema, type InvitationRole } from '@nuxt-customer-portal/core/shared/invitation-validation'
3
+
4
+ const props = defineProps<{
5
+ endpoint: string
6
+ email: string
7
+ role: string | null
8
+ canEdit?: boolean
9
+ canRevoke?: boolean
10
+ }>()
11
+ const emit = defineEmits<{ refresh: [] }>()
12
+ const { t } = useI18n()
13
+ const api = useInvitationManagement()
14
+ const toast = useToast()
15
+ const editing = ref(false)
16
+ const revoking = ref(false)
17
+ const busy = ref(false)
18
+ const currentRole = (): InvitationRole => (props.role === 'admin' || props.role === 'owner' ? props.role : 'member')
19
+ const state = reactive({ role: currentRole() })
20
+ const openEditor = () => {
21
+ state.role = currentRole()
22
+ editing.value = true
23
+ }
24
+ const save = async () => {
25
+ busy.value = true
26
+ try {
27
+ await api.changeRole(props.endpoint, state.role)
28
+ editing.value = false
29
+ toast.add({ title: t('invitationManagement.updated'), color: 'success' })
30
+ emit('refresh')
31
+ } catch {
32
+ toast.add({ title: t('invitationManagement.failed'), color: 'error' })
33
+ } finally {
34
+ busy.value = false
35
+ }
36
+ }
37
+ const revoke = async () => {
38
+ busy.value = true
39
+ try {
40
+ await api.revoke(props.endpoint)
41
+ toast.add({ title: t('invitationManagement.revoked'), color: 'success' })
42
+ emit('refresh')
43
+ } catch {
44
+ toast.add({ title: t('invitationManagement.failed'), color: 'error' })
45
+ } finally {
46
+ busy.value = false
47
+ }
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <div class="flex items-center gap-1" @click.stop @keydown.stop>
53
+ <UButton
54
+ v-if="canEdit"
55
+ icon="i-lucide-pencil"
56
+ color="neutral"
57
+ variant="ghost"
58
+ size="sm"
59
+ :disabled="busy"
60
+ :aria-label="t('invitationManagement.edit')"
61
+ @click="openEditor"
62
+ />
63
+ <UButton
64
+ v-if="canRevoke"
65
+ icon="i-lucide-x"
66
+ color="error"
67
+ variant="ghost"
68
+ size="sm"
69
+ :disabled="busy"
70
+ :aria-label="t('invitationManagement.revoke')"
71
+ @click="revoking = true"
72
+ />
73
+ <UModal v-model:open="editing" :title="t('invitationManagement.edit')">
74
+ <template #body>
75
+ <UForm :state="state" :schema="invitationRoleSchema" novalidate class="space-y-4" @submit="save">
76
+ <p>{{ email }}</p>
77
+ <UFormField name="role" :label="t('invitationManagement.role')" required>
78
+ <USelect v-model="state.role" :items="['member', 'admin', 'owner']" class="w-full" />
79
+ </UFormField>
80
+ <div class="flex justify-end gap-2">
81
+ <UButton color="neutral" variant="outline" @click="editing = false">{{ t('common.cancel') }}</UButton>
82
+ <UButton type="submit" :loading="busy">{{ t('invitationManagement.save') }}</UButton>
83
+ </div>
84
+ </UForm>
85
+ </template>
86
+ </UModal>
87
+ <ConfirmationModal
88
+ v-model:open="revoking"
89
+ title="invitationManagement.revoke"
90
+ message="invitationManagement.confirmRevoke"
91
+ :message-params="{ email }"
92
+ confirm-text="invitationManagement.revoke"
93
+ confirm-color="error"
94
+ @confirm="revoke"
95
+ />
96
+ </div>
97
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { formatTimeAgo } from '@vueuse/core'
3
+ import type { Notification } from '@nuxt-customer-portal/core/app/types'
4
+
5
+ const { isNotificationsSlideoverOpen } = useDashboard()
6
+
7
+ const { data: notifications } = await useFetch<Notification[]>('/api/notifications')
8
+ </script>
9
+
10
+ <template>
11
+ <USlideover v-model:open="isNotificationsSlideoverOpen" title="Notifications">
12
+ <template #body>
13
+ <NuxtLink
14
+ v-for="notification in notifications"
15
+ :key="notification.id"
16
+ :to="`/inbox?id=${notification.id}`"
17
+ class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3"
18
+ >
19
+ <UChip color="error" :show="!!notification.unread" inset>
20
+ <UAvatar v-bind="notification.sender?.avatar" :alt="notification.sender.name" size="md" />
21
+ </UChip>
22
+
23
+ <div class="text-sm flex-1">
24
+ <p class="flex items-center justify-between">
25
+ <span class="text-highlighted font-medium">{{ notification.sender.name }}</span>
26
+
27
+ <time
28
+ :datetime="notification.date"
29
+ class="text-muted text-xs"
30
+ v-text="formatTimeAgo(new Date(notification.date))"
31
+ />
32
+ </p>
33
+
34
+ <p class="text-dimmed">
35
+ {{ notification.body }}
36
+ </p>
37
+ </div>
38
+ </NuxtLink>
39
+ </template>
40
+ </USlideover>
41
+ </template>
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ type Option = { label: string; value: string | undefined; badgeText?: string; badgeColor?: string }
3
+ const props = defineProps<{
4
+ searchPlaceholder?: string
5
+ filters: Array<{ key: string; placeholder: string; items: Option[] }>
6
+ filterValues: Record<string, string | undefined>
7
+ sortOptions: Array<{ label: string; value: string }>
8
+ sortBy: string
9
+ sortDir: 'asc' | 'desc'
10
+ }>()
11
+ const emit = defineEmits<{
12
+ filter: [key: string, value: string | undefined]
13
+ sort: [value: string]
14
+ toggleDirection: []
15
+ }>()
16
+ const search = defineModel<string>('search', { required: true })
17
+ const { t } = useI18n()
18
+ const showFilters = ref(false)
19
+ const showSort = ref(false)
20
+ const toolbar = ref<HTMLElement | null>(null)
21
+ const { width } = useElementSize(toolbar)
22
+ const isMobile = computed(() => width.value < Math.max(660, 160 + props.filters.length * 120 + 232))
23
+ </script>
24
+
25
+ <template>
26
+ <div ref="toolbar" class="shrink-0 border-b border-default pb-4">
27
+ <div class="flex items-center gap-2">
28
+ <UInput
29
+ v-model="search"
30
+ :placeholder="searchPlaceholder || t('common.searchPlaceholder')"
31
+ icon="i-lucide-search"
32
+ :class="isMobile ? 'min-w-0 flex-1' : 'min-w-40 flex-1 md:max-w-xs'"
33
+ />
34
+ <template v-if="!isMobile">
35
+ <USelect
36
+ v-for="filter in filters"
37
+ :key="filter.key"
38
+ :model-value="filterValues[filter.key]"
39
+ :items="filter.items"
40
+ value-key="value"
41
+ :placeholder="filter.placeholder"
42
+ :aria-label="filter.placeholder"
43
+ class="w-44 min-w-28 shrink"
44
+ @update:model-value="emit('filter', filter.key, $event)"
45
+ />
46
+ <div class="ml-auto flex shrink-0 items-center gap-2">
47
+ <USelect
48
+ :model-value="sortBy"
49
+ :items="sortOptions"
50
+ value-key="value"
51
+ icon="i-lucide-arrow-down-up"
52
+ :aria-label="t('common.sortBy')"
53
+ class="w-44"
54
+ @update:model-value="emit('sort', $event)"
55
+ />
56
+ <UButton
57
+ color="neutral"
58
+ variant="outline"
59
+ :icon="sortDir === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow'"
60
+ :aria-label="t('common.direction')"
61
+ @click="emit('toggleDirection')"
62
+ />
63
+ </div>
64
+ </template>
65
+ <template v-else>
66
+ <UButton
67
+ v-if="filters.length"
68
+ color="neutral"
69
+ variant="outline"
70
+ icon="i-lucide-filter"
71
+ :aria-label="t('common.filters')"
72
+ @click="showFilters = true"
73
+ />
74
+ <UButton
75
+ color="neutral"
76
+ variant="outline"
77
+ icon="i-lucide-arrow-down-up"
78
+ :aria-label="t('common.sort')"
79
+ @click="showSort = true"
80
+ />
81
+ </template>
82
+ </div>
83
+ <UModal v-model:open="showFilters" :title="t('common.filters')">
84
+ <template #body>
85
+ <div class="space-y-4">
86
+ <UFormField v-for="filter in filters" :key="filter.key" :label="filter.placeholder">
87
+ <USelect
88
+ :model-value="filterValues[filter.key]"
89
+ :items="filter.items"
90
+ value-key="value"
91
+ class="w-full"
92
+ @update:model-value="emit('filter', filter.key, $event)"
93
+ />
94
+ </UFormField>
95
+ </div>
96
+ </template>
97
+ </UModal>
98
+ <UModal v-model:open="showSort" :title="t('common.sort')">
99
+ <template #body>
100
+ <div class="space-y-4">
101
+ <USelect
102
+ :model-value="sortBy"
103
+ :items="sortOptions"
104
+ icon="i-lucide-arrow-down-up"
105
+ :aria-label="t('common.sortBy')"
106
+ value-key="value"
107
+ class="w-full"
108
+ @update:model-value="emit('sort', $event)"
109
+ /><UButton
110
+ block
111
+ color="neutral"
112
+ variant="outline"
113
+ :icon="sortDir === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow'"
114
+ @click="emit('toggleDirection')"
115
+ >
116
+ {{ t('common.direction') }}
117
+ </UButton>
118
+ </div>
119
+ </template>
120
+ </UModal>
121
+ </div>
122
+ </template>
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ import { DateFormatter, getLocalTimeZone, CalendarDate, today } from '@internationalized/date'
3
+ import type { Range } from '@nuxt-customer-portal/core/app/types'
4
+
5
+ const df = new DateFormatter('en-US', {
6
+ dateStyle: 'medium'
7
+ })
8
+
9
+ const selected = defineModel<Range>({ required: true })
10
+
11
+ const ranges = [
12
+ { label: 'Last 7 days', days: 7 },
13
+ { label: 'Last 14 days', days: 14 },
14
+ { label: 'Last 30 days', days: 30 },
15
+ { label: 'Last 3 months', months: 3 },
16
+ { label: 'Last 6 months', months: 6 },
17
+ { label: 'Last year', years: 1 }
18
+ ]
19
+
20
+ const toCalendarDate = (date: Date) => {
21
+ return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
22
+ }
23
+
24
+ const calendarRange = computed({
25
+ get: () => ({
26
+ start: selected.value.start ? toCalendarDate(selected.value.start) : undefined,
27
+ end: selected.value.end ? toCalendarDate(selected.value.end) : undefined
28
+ }),
29
+ set: (newValue: { start: CalendarDate | null; end: CalendarDate | null }) => {
30
+ selected.value = {
31
+ start: newValue.start ? newValue.start.toDate(getLocalTimeZone()) : new Date(),
32
+ end: newValue.end ? newValue.end.toDate(getLocalTimeZone()) : new Date()
33
+ }
34
+ }
35
+ })
36
+
37
+ const isRangeSelected = (range: { days?: number; months?: number; years?: number }) => {
38
+ if (!selected.value.start || !selected.value.end) {
39
+ return false
40
+ }
41
+
42
+ const currentDate = today(getLocalTimeZone())
43
+ let startDate = currentDate.copy()
44
+
45
+ if (range.days) {
46
+ startDate = startDate.subtract({ days: range.days })
47
+ } else if (range.months) {
48
+ startDate = startDate.subtract({ months: range.months })
49
+ } else if (range.years) {
50
+ startDate = startDate.subtract({ years: range.years })
51
+ }
52
+
53
+ const selectedStart = toCalendarDate(selected.value.start)
54
+ const selectedEnd = toCalendarDate(selected.value.end)
55
+
56
+ return selectedStart.compare(startDate) === 0 && selectedEnd.compare(currentDate) === 0
57
+ }
58
+
59
+ const selectRange = (range: { days?: number; months?: number; years?: number }) => {
60
+ const endDate = today(getLocalTimeZone())
61
+ let startDate = endDate.copy()
62
+
63
+ if (range.days) {
64
+ startDate = startDate.subtract({ days: range.days })
65
+ } else if (range.months) {
66
+ startDate = startDate.subtract({ months: range.months })
67
+ } else if (range.years) {
68
+ startDate = startDate.subtract({ years: range.years })
69
+ }
70
+
71
+ selected.value = {
72
+ start: startDate.toDate(getLocalTimeZone()),
73
+ end: endDate.toDate(getLocalTimeZone())
74
+ }
75
+ }
76
+ </script>
77
+
78
+ <template>
79
+ <UPopover :content="{ align: 'start' }" :modal="true">
80
+ <UButton color="neutral" variant="ghost" icon="i-lucide-calendar" class="data-[state=open]:bg-elevated group">
81
+ <span class="truncate">
82
+ <template v-if="selected.start">
83
+ <template v-if="selected.end"> {{ df.format(selected.start) }} - {{ df.format(selected.end) }} </template>
84
+ <template v-else>
85
+ {{ df.format(selected.start) }}
86
+ </template>
87
+ </template>
88
+ <template v-else> Pick a date </template>
89
+ </span>
90
+
91
+ <template #trailing>
92
+ <UIcon
93
+ name="i-lucide-chevron-down"
94
+ class="shrink-0 text-dimmed size-5 group-data-[state=open]:rotate-180 transition-transform duration-200"
95
+ />
96
+ </template>
97
+ </UButton>
98
+
99
+ <template #content>
100
+ <div class="flex items-stretch sm:divide-x divide-default">
101
+ <div class="hidden sm:flex flex-col justify-center">
102
+ <UButton
103
+ v-for="(range, index) in ranges"
104
+ :key="index"
105
+ :label="range.label"
106
+ color="neutral"
107
+ variant="ghost"
108
+ class="rounded-none px-4"
109
+ :class="[isRangeSelected(range) ? 'bg-elevated' : 'hover:bg-elevated/50']"
110
+ truncate
111
+ @click="selectRange(range)"
112
+ />
113
+ </div>
114
+
115
+ <UCalendar v-model="calendarRange" class="p-2" :number-of-months="2" range />
116
+ </div>
117
+ </template>
118
+ </UPopover>
119
+ </template>