@sbc-connect/nuxt-base 0.1.3 → 0.1.5
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/.env.example +11 -1
- package/CHANGELOG.md +54 -0
- package/LICENSE +28 -0
- package/README.md +36 -47
- package/app/app.config.ts +56 -138
- package/app/app.vue +16 -3
- package/app/components/Connect/BCGovLogo.vue +4 -4
- package/app/components/Connect/Breadcrumb.vue +61 -0
- package/app/components/Connect/Footer.vue +104 -0
- package/app/components/Connect/Header/LogoHomeLink.vue +4 -4
- package/app/components/Connect/Header/WhatsNew.vue +36 -0
- package/app/components/Connect/Header/Wrapper.vue +3 -3
- package/app/components/Connect/Header/index.vue +16 -1
- package/app/components/Connect/I18n/Helper.vue +24 -0
- package/app/components/Connect/Input.vue +32 -0
- package/app/components/Connect/LocaleSelect.vue +1 -1
- package/app/components/Connect/PageSection.vue +57 -0
- package/app/components/Connect/Slideover/WhatsNew.vue +61 -0
- package/app/components/Connect/Spinner.vue +94 -0
- package/app/components/Connect/SystemBanner.vue +50 -0
- package/app/components/Connect/Textarea.vue +30 -0
- package/app/composables/useConnectLaunchDarkly.ts +177 -0
- package/app/error.vue +53 -0
- package/app/interfaces/connect-breadcrumb.ts +5 -0
- package/app/interfaces/connect-whatsnew.ts +14 -0
- package/app/layouts/{default.vue → ConnectBase.vue} +3 -0
- package/app/plugins/launchdarkly.client.ts +9 -0
- package/app/plugins/maska.client.ts +10 -0
- package/app/plugins/sanitize.client.ts +13 -0
- package/app/plugins/whats-new.client.ts +45 -0
- package/app/spa-loading-template.html +61 -0
- package/app/types/base-app-config.d.ts +15 -0
- package/app/types/base-page-meta.d.ts +8 -0
- package/app/utils/appendUrlParam.ts +4 -0
- package/app/utils/parseSpecialCharacters.ts +25 -0
- package/app/utils/setBreadcrumbs.ts +5 -0
- package/i18n/locales/en-CA.ts +53 -84
- package/i18n/locales/fr-CA.ts +33 -30
- package/modules/assets/index.ts +1 -1
- package/modules/assets/runtime/assets/core-tw.css +61 -32
- package/nuxt.config.ts +80 -1
- package/package.json +20 -9
- package/app/components/Connect/Header/Base.vue +0 -17
|
@@ -9,12 +9,12 @@ const localePath = useLocalePath()
|
|
|
9
9
|
:class="[
|
|
10
10
|
'flex items-center gap-4 rounded-md',
|
|
11
11
|
'px-2 transition-colors duration-300',
|
|
12
|
-
'ease-in-out hover:bg-
|
|
13
|
-
'focus:outline-none focus-visible:ring-2 focus-visible:ring-
|
|
12
|
+
'ease-in-out hover:bg-secondary/[0.1]',
|
|
13
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary',
|
|
14
14
|
]"
|
|
15
|
-
:aria-label="$t('
|
|
15
|
+
:aria-label="$t('connect.header.homeLink')"
|
|
16
16
|
>
|
|
17
17
|
<ConnectBCGovLogo />
|
|
18
|
-
<span class="text-base font-semibold text-
|
|
18
|
+
<span class="text-base font-semibold text-secondary lg:text-lg"> {{ $t('connect.header.title') }} </span>
|
|
19
19
|
</NuxtLink>
|
|
20
20
|
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useStorage } from '@vueuse/core'
|
|
3
|
+
import { ConnectSlideoverWhatsNew } from '#components'
|
|
4
|
+
|
|
5
|
+
const state = useStorage<ConnectWhatsNewState>('connect-whats-new', { viewed: false, items: [] })
|
|
6
|
+
const overlay = useOverlay()
|
|
7
|
+
const slideover = overlay.create(ConnectSlideoverWhatsNew)
|
|
8
|
+
|
|
9
|
+
function open() {
|
|
10
|
+
slideover.open({
|
|
11
|
+
items: state.value.items
|
|
12
|
+
})
|
|
13
|
+
state.value.viewed = true
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<UChip
|
|
19
|
+
:show="!state.viewed && state.items.length > 0"
|
|
20
|
+
color="error"
|
|
21
|
+
position="top-left"
|
|
22
|
+
inset
|
|
23
|
+
:ui="{
|
|
24
|
+
base: 'ring-(--ui-error)',
|
|
25
|
+
}"
|
|
26
|
+
>
|
|
27
|
+
<UButton
|
|
28
|
+
variant="ghost"
|
|
29
|
+
color="secondary"
|
|
30
|
+
class="px-2 py-1 text-sm"
|
|
31
|
+
:label="$t('connect.label.whatsNew')"
|
|
32
|
+
:aria-label="$t('connect.label.whatsNewAria', { count: state.viewed ? 0 : state.items.length })"
|
|
33
|
+
@click="open"
|
|
34
|
+
/>
|
|
35
|
+
</UChip>
|
|
36
|
+
</template>
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
<header
|
|
3
3
|
id="connect-header-wrapper"
|
|
4
4
|
data-testid="connect-header-wrapper"
|
|
5
|
-
class="relative border-b-2 border-
|
|
5
|
+
class="relative border-b-2 border-brandLight bg-brandDark p-2 sm:px-4"
|
|
6
6
|
>
|
|
7
7
|
<nav
|
|
8
|
-
class="app-inner-container
|
|
9
|
-
:aria-label="$t('
|
|
8
|
+
class="app-inner-container"
|
|
9
|
+
:aria-label="$t('connect.header.navLabel')"
|
|
10
10
|
>
|
|
11
11
|
<slot />
|
|
12
12
|
</nav>
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const headerConfig = useAppConfig().connect.header
|
|
3
|
+
</script>
|
|
4
|
+
|
|
1
5
|
<template>
|
|
2
|
-
<
|
|
6
|
+
<ConnectHeaderWrapper>
|
|
7
|
+
<div class="flex items-center justify-between">
|
|
8
|
+
<ConnectHeaderLogoHomeLink />
|
|
9
|
+
<ClientOnly>
|
|
10
|
+
<div class="flex gap-4">
|
|
11
|
+
<slot />
|
|
12
|
+
<ConnectHeaderWhatsNew v-if="headerConfig.whatsNew" />
|
|
13
|
+
<ConnectLocaleSelect v-if="headerConfig.localeSelect" />
|
|
14
|
+
</div>
|
|
15
|
+
</ClientOnly>
|
|
16
|
+
</div>
|
|
17
|
+
</ConnectHeaderWrapper>
|
|
3
18
|
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
translationPath: { type: String, required: true }
|
|
4
|
+
})
|
|
5
|
+
|
|
6
|
+
const attrs = useAttrs()
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
|
|
9
|
+
const textToDisplay = computed(() => {
|
|
10
|
+
const translationProps = {
|
|
11
|
+
...attrs,
|
|
12
|
+
boldStart: '<strong>',
|
|
13
|
+
boldEnd: '</strong>',
|
|
14
|
+
italicStart: '<em>',
|
|
15
|
+
italicEnd: '</em>'
|
|
16
|
+
}
|
|
17
|
+
return t(props.translationPath, translationProps)
|
|
18
|
+
})
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
23
|
+
<span v-html="textToDisplay" />
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const model = defineModel<string>({ required: true })
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
id: string
|
|
6
|
+
label: string
|
|
7
|
+
mask?: string
|
|
8
|
+
invalid?: boolean
|
|
9
|
+
}>()
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<UInput
|
|
14
|
+
:id
|
|
15
|
+
v-model.trim="model"
|
|
16
|
+
v-maska="mask"
|
|
17
|
+
:data-testid="id"
|
|
18
|
+
placeholder=" "
|
|
19
|
+
class="w-full grow"
|
|
20
|
+
:ui="{
|
|
21
|
+
base: invalid ? 'ring-0 shadow-inputError focus:shadow-inputError' : '',
|
|
22
|
+
}"
|
|
23
|
+
>
|
|
24
|
+
<label
|
|
25
|
+
:for="id"
|
|
26
|
+
class="floating-label-input"
|
|
27
|
+
:class="invalid ? 'text-error' : ''"
|
|
28
|
+
>
|
|
29
|
+
{{ label }}
|
|
30
|
+
</label>
|
|
31
|
+
</UInput>
|
|
32
|
+
</template>
|
|
@@ -28,7 +28,7 @@ const items = computed((): DropdownMenuItem[] => {
|
|
|
28
28
|
<UButton
|
|
29
29
|
data-testid="locale-select-dropdown"
|
|
30
30
|
icon="i-mdi-web"
|
|
31
|
-
:aria-label="$t('
|
|
31
|
+
:aria-label="$t('connect.label.localeSelect')"
|
|
32
32
|
size="lg"
|
|
33
33
|
variant="ghost"
|
|
34
34
|
color="secondary"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ButtonProps } from '#ui/types'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
heading?: {
|
|
6
|
+
label?: string
|
|
7
|
+
labelClass?: string
|
|
8
|
+
level?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
|
9
|
+
icon?: string
|
|
10
|
+
iconClass?: string
|
|
11
|
+
ui?: string
|
|
12
|
+
}
|
|
13
|
+
uiBody?: string
|
|
14
|
+
actions?: ButtonProps[]
|
|
15
|
+
}>()
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<UCard
|
|
20
|
+
as="section"
|
|
21
|
+
class="w-full"
|
|
22
|
+
variant="outline"
|
|
23
|
+
:ui="{
|
|
24
|
+
header: heading?.ui ?? 'bg-shadeSecondary px-4 py-4 sm:px-4 rounded-t-md',
|
|
25
|
+
body: uiBody ?? 'p-0 sm:p-0',
|
|
26
|
+
footer: 'p-0 sm:p-0',
|
|
27
|
+
}"
|
|
28
|
+
>
|
|
29
|
+
<template v-if="heading?.label || $slots.header" #header>
|
|
30
|
+
<slot name="header">
|
|
31
|
+
<div class="flex items-center justify-between gap-2.5">
|
|
32
|
+
<component :is="heading?.level || 'h2'">
|
|
33
|
+
<div class="flex items-center gap-2.5">
|
|
34
|
+
<UIcon
|
|
35
|
+
v-if="heading?.icon"
|
|
36
|
+
:name="heading.icon"
|
|
37
|
+
:class="heading?.iconClass || 'size-6 shrink-0 text-primary'"
|
|
38
|
+
/>
|
|
39
|
+
<span :class="heading?.labelClass || 'font-semibold text-neutralDark text-base'">
|
|
40
|
+
{{ heading?.label }}
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
</component>
|
|
44
|
+
|
|
45
|
+
<div class="flex items-center gap-2.5">
|
|
46
|
+
<UButton
|
|
47
|
+
v-for="(action, i) in actions"
|
|
48
|
+
:key="i"
|
|
49
|
+
v-bind="action"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</slot>
|
|
54
|
+
</template>
|
|
55
|
+
<slot />
|
|
56
|
+
</UCard>
|
|
57
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { $sanitize } = useNuxtApp()
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
items: ConnectWhatsNewItem[]
|
|
6
|
+
}>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<USlideover
|
|
11
|
+
:title="$t('connect.label.whatsNewSlideover')"
|
|
12
|
+
:description="$t('connect.text.whatsNewSlideoverDescription')"
|
|
13
|
+
:overlay="false"
|
|
14
|
+
:close="{
|
|
15
|
+
color: 'neutral',
|
|
16
|
+
variant: 'ghost',
|
|
17
|
+
class: 'cursor-pointer hover:bg-shadePrimary/50 focus-visible:bg-transparent focus-visible:ring-2 rounded-full',
|
|
18
|
+
}"
|
|
19
|
+
:ui="{
|
|
20
|
+
header: 'bg-brandLight',
|
|
21
|
+
title: 'text-brandDark',
|
|
22
|
+
body: 'flex-1 overflow-y-auto p-0 sm:p-0',
|
|
23
|
+
description: 'sr-only',
|
|
24
|
+
}"
|
|
25
|
+
>
|
|
26
|
+
<template #body>
|
|
27
|
+
<div class="overflow-y-auto text-neutral">
|
|
28
|
+
<div class="flex-1">
|
|
29
|
+
<ol v-if="items.length">
|
|
30
|
+
<li
|
|
31
|
+
v-for="item, i in items"
|
|
32
|
+
:key="i"
|
|
33
|
+
class="flex flex-col border-b border-gray-500 p-4 last:border-0"
|
|
34
|
+
>
|
|
35
|
+
<h3 class="text-lg font-bold">
|
|
36
|
+
{{ item.title }}
|
|
37
|
+
</h3>
|
|
38
|
+
<time class="text-sm">{{ item.date }}</time>
|
|
39
|
+
<!-- eslint-disable vue/no-v-html -->
|
|
40
|
+
<div
|
|
41
|
+
class="pt-3 vhtml"
|
|
42
|
+
v-html="$sanitize(item.description)"
|
|
43
|
+
/>
|
|
44
|
+
</li>
|
|
45
|
+
</ol>
|
|
46
|
+
<div v-else class="flex justify-center py-10">
|
|
47
|
+
<span>{{ $t('connect.text.whatsNewSlideoverEmpty') }}</span>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
</USlideover>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<!-- must style globally for vhtml style to work -->
|
|
56
|
+
<style>
|
|
57
|
+
.vhtml a {
|
|
58
|
+
color: var(--ui-mark);
|
|
59
|
+
text-decoration: underline;
|
|
60
|
+
}
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
fullscreen: {
|
|
4
|
+
type: Boolean,
|
|
5
|
+
default: false
|
|
6
|
+
},
|
|
7
|
+
size: {
|
|
8
|
+
type: Number,
|
|
9
|
+
default: 75
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const spinnerStyle = computed(() => ({
|
|
14
|
+
'--spinner-size': `${props.size}px`,
|
|
15
|
+
'--spinner-margin': `${props.size / -2}px`
|
|
16
|
+
}))
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<!-- eslint-disable tailwindcss/no-custom-classname -->
|
|
21
|
+
<Teleport v-if="fullscreen" to="body">
|
|
22
|
+
<div class="fixed inset-0 z-[999] h-screen w-screen bg-gray-100 opacity-50" />
|
|
23
|
+
<svg
|
|
24
|
+
class="spinner fixed"
|
|
25
|
+
viewBox="0 0 50 50"
|
|
26
|
+
:style="spinnerStyle"
|
|
27
|
+
>
|
|
28
|
+
<circle
|
|
29
|
+
class="path"
|
|
30
|
+
cx="25"
|
|
31
|
+
cy="25"
|
|
32
|
+
r="20"
|
|
33
|
+
fill="none"
|
|
34
|
+
stroke-width="5"
|
|
35
|
+
/>
|
|
36
|
+
</svg>
|
|
37
|
+
</Teleport>
|
|
38
|
+
<svg
|
|
39
|
+
v-else
|
|
40
|
+
class="spinner absolute"
|
|
41
|
+
viewBox="0 0 50 50"
|
|
42
|
+
:style="spinnerStyle"
|
|
43
|
+
>
|
|
44
|
+
<circle
|
|
45
|
+
class="path"
|
|
46
|
+
cx="25"
|
|
47
|
+
cy="25"
|
|
48
|
+
r="20"
|
|
49
|
+
fill="none"
|
|
50
|
+
stroke-width="5"
|
|
51
|
+
/>
|
|
52
|
+
</svg>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style scoped>
|
|
56
|
+
.spinner {
|
|
57
|
+
animation: rotate 1.5s linear infinite;
|
|
58
|
+
z-index: 10000;
|
|
59
|
+
top: 50%;
|
|
60
|
+
left: 50%;
|
|
61
|
+
margin-top: var(--spinner-margin, -37.5px);
|
|
62
|
+
margin-left: var(--spinner-margin, -37.5px);
|
|
63
|
+
width: var(--spinner-size, 75px);
|
|
64
|
+
height: var(--spinner-size, 75px);
|
|
65
|
+
transform-origin: 50% 50%;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.spinner .path {
|
|
69
|
+
stroke: #1669bb;
|
|
70
|
+
stroke-linecap: round;
|
|
71
|
+
animation: dash 1.25s ease-in-out infinite;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@keyframes rotate {
|
|
75
|
+
100% {
|
|
76
|
+
transform: rotate(360deg);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@keyframes dash {
|
|
81
|
+
0% {
|
|
82
|
+
stroke-dasharray: 1, 150;
|
|
83
|
+
stroke-dashoffset: 0;
|
|
84
|
+
}
|
|
85
|
+
50% {
|
|
86
|
+
stroke-dasharray: 90, 150;
|
|
87
|
+
stroke-dashoffset: -35;
|
|
88
|
+
}
|
|
89
|
+
100% {
|
|
90
|
+
stroke-dasharray: 90, 150;
|
|
91
|
+
stroke-dashoffset: -124;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const ld = useConnectLaunchDarkly()
|
|
3
|
+
const { $sanitize } = useNuxtApp()
|
|
4
|
+
|
|
5
|
+
defineProps({
|
|
6
|
+
dismissible: { type: Boolean, default: false },
|
|
7
|
+
icon: { type: String, default: 'i-mdi-information' }
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const close = ref(false)
|
|
11
|
+
const message = ref('')
|
|
12
|
+
onMounted(async () => {
|
|
13
|
+
message.value = $sanitize(await ld.getStoredFlag('banner-text', '', 'await'))
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="bg-brandLight">
|
|
19
|
+
<UAlert
|
|
20
|
+
v-show="!!message && !close"
|
|
21
|
+
class="py-0"
|
|
22
|
+
:description="message"
|
|
23
|
+
:close="dismissible"
|
|
24
|
+
close-icon="i-mdi-close"
|
|
25
|
+
:ui="{
|
|
26
|
+
root: 'rounded-none bg-brandLight p-2 sm:px-4 app-inner-container',
|
|
27
|
+
wrapper: 'bg-brandLight',
|
|
28
|
+
close: 'mt-2 text-neutralDark',
|
|
29
|
+
}"
|
|
30
|
+
@update:open="close = true"
|
|
31
|
+
>
|
|
32
|
+
<!-- dismissible ? { class: 'pr-2 text-neutralDark' } : null -->
|
|
33
|
+
<template #description>
|
|
34
|
+
<div class="flex gap-2 items-center py-2">
|
|
35
|
+
<UIcon class="size-7 shrink-0 text-neutralDark self-start" :name="icon" />
|
|
36
|
+
<!-- eslint-disable vue/no-v-html tailwindcss/no-custom-classname -->
|
|
37
|
+
<p class="vhtml text-neutralDark" v-html="message" />
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
</UAlert>
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<!-- must style globally for vhtml style to work -->
|
|
45
|
+
<style>
|
|
46
|
+
.vhtml > a {
|
|
47
|
+
color: var(--ui-neutralDark);
|
|
48
|
+
text-decoration: underline;
|
|
49
|
+
}
|
|
50
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const model = defineModel<string>({ required: true })
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
id: string
|
|
6
|
+
label: string
|
|
7
|
+
invalid?: boolean
|
|
8
|
+
}>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<UTextarea
|
|
13
|
+
:id
|
|
14
|
+
v-model.trim="model"
|
|
15
|
+
:data-testid="id"
|
|
16
|
+
placeholder=" "
|
|
17
|
+
class="w-full grow"
|
|
18
|
+
:ui="{
|
|
19
|
+
base: invalid ? 'ring-0 shadow-inputError focus:shadow-inputError resize-none' : 'resize-none',
|
|
20
|
+
}"
|
|
21
|
+
>
|
|
22
|
+
<label
|
|
23
|
+
class="floating-label-textarea bg-shadePrimary w-[96%] flex-1 pt-1 -mt-1"
|
|
24
|
+
:class="invalid ? 'text-error' : ''"
|
|
25
|
+
:for="id"
|
|
26
|
+
>
|
|
27
|
+
{{ label }}
|
|
28
|
+
</label>
|
|
29
|
+
</UTextarea>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { initialize } from 'launchdarkly-js-client-sdk'
|
|
2
|
+
import type { LDClient, LDFlagSet, LDOptions, LDContext } from 'launchdarkly-js-client-sdk'
|
|
3
|
+
|
|
4
|
+
// state
|
|
5
|
+
// Defined outside of composable, so they are created only once and shared
|
|
6
|
+
const ldClient = shallowRef<LDClient | null>(null)
|
|
7
|
+
const ldFlagSet = shallowRef<LDFlagSet>({})
|
|
8
|
+
const ldInitialized = ref(false)
|
|
9
|
+
const isInitializing = ref(false)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initializes the LaunchDarkly client with an anonymous context.
|
|
13
|
+
*/
|
|
14
|
+
function init(): void {
|
|
15
|
+
const rtc = useRuntimeConfig().public
|
|
16
|
+
|
|
17
|
+
// Prevent re-initialization
|
|
18
|
+
if (ldInitialized.value || isInitializing.value) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
// Prevent initialization if missing client ID
|
|
22
|
+
if (!rtc.ldClientId) {
|
|
23
|
+
console.error('LaunchDarkly: ldClientId is not configured.')
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
isInitializing.value = true
|
|
28
|
+
|
|
29
|
+
const ldContext: LDContext = {
|
|
30
|
+
kind: 'user',
|
|
31
|
+
key: 'anonymous',
|
|
32
|
+
anonymous: true,
|
|
33
|
+
appSource: rtc.appName
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const options: LDOptions = {
|
|
37
|
+
streaming: false,
|
|
38
|
+
useReport: false,
|
|
39
|
+
diagnosticOptOut: true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
ldClient.value = initialize(rtc.ldClientId, ldContext, options)
|
|
44
|
+
|
|
45
|
+
ldClient.value.on('initialized', () => {
|
|
46
|
+
ldFlagSet.value = ldClient.value?.allFlags() || {}
|
|
47
|
+
ldInitialized.value = true
|
|
48
|
+
isInitializing.value = false
|
|
49
|
+
console.info('LaunchDarkly: Anonymous initialization complete.')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
ldClient.value.on('error', (error) => {
|
|
53
|
+
console.error('LaunchDarkly: Initialization error.', error)
|
|
54
|
+
isInitializing.value = false
|
|
55
|
+
})
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('LaunchDarkly: Failed to initialize.', error)
|
|
58
|
+
isInitializing.value = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Composable for the LaunchDarkly service.
|
|
64
|
+
*/
|
|
65
|
+
export const useConnectLaunchDarkly = () => {
|
|
66
|
+
// initialize only once
|
|
67
|
+
if (!ldInitialized.value && !isInitializing.value && import.meta.client) {
|
|
68
|
+
init()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns a flag's value. Can operate in two modes.
|
|
73
|
+
* @param name The name of the feature flag.
|
|
74
|
+
* @param defaultValue The value to use until the flag is loaded.
|
|
75
|
+
* @param mode - 'reactive' (default) returns a ref that updates automatically.
|
|
76
|
+
* 'await' returns a promise that resolves when the client is ready.
|
|
77
|
+
* @returns A readonly ref or a promise resolving to the flag's value.
|
|
78
|
+
*/
|
|
79
|
+
function getFeatureFlag<T>(
|
|
80
|
+
name: string,
|
|
81
|
+
defaultValue: T,
|
|
82
|
+
mode: 'await'
|
|
83
|
+
): Promise<T>
|
|
84
|
+
function getFeatureFlag<T>(
|
|
85
|
+
name: string,
|
|
86
|
+
defaultValue?: T,
|
|
87
|
+
mode?: 'reactive'
|
|
88
|
+
): Readonly<Ref<T>>
|
|
89
|
+
function getFeatureFlag<T>(
|
|
90
|
+
name: string,
|
|
91
|
+
defaultValue: T,
|
|
92
|
+
mode: 'reactive' | 'await' = 'reactive'
|
|
93
|
+
): Readonly<Ref<T>> | Promise<T> {
|
|
94
|
+
if (mode === 'await') {
|
|
95
|
+
if (!ldClient.value) {
|
|
96
|
+
return Promise.resolve(defaultValue)
|
|
97
|
+
}
|
|
98
|
+
return ldClient.value.waitUntilReady()
|
|
99
|
+
.then(() => ldClient.value ? ldClient.value.variation(name, defaultValue) : defaultValue)
|
|
100
|
+
.catch((error) => {
|
|
101
|
+
console.error(`LaunchDarkly: Error waiting for client while getting flag "${name}".`, error)
|
|
102
|
+
return defaultValue
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// reactive mode
|
|
107
|
+
return readonly(computed(() => {
|
|
108
|
+
if (!ldClient.value || !ldInitialized.value) {
|
|
109
|
+
return defaultValue
|
|
110
|
+
}
|
|
111
|
+
return ldClient.value.variation(name, defaultValue)
|
|
112
|
+
}))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns a flag's value from the locally stored flag set. Can operate in two modes.
|
|
117
|
+
* @param name The name of the feature flag.
|
|
118
|
+
* @param defaultValue The value to use until the flag is loaded.
|
|
119
|
+
* @param mode - 'reactive' (default) returns a ref that updates automatically.
|
|
120
|
+
* 'await' returns a promise that resolves when the client is ready.
|
|
121
|
+
* @returns A readonly ref or a promise resolving to the flag's value.
|
|
122
|
+
*/
|
|
123
|
+
async function getStoredFlag<T>(name: string, defaultValue: T, mode: 'await'): Promise<T>
|
|
124
|
+
function getStoredFlag<T>(name: string, defaultValue?: T, mode?: 'reactive'): Readonly<Ref<T>>
|
|
125
|
+
function getStoredFlag<T>(
|
|
126
|
+
name: string,
|
|
127
|
+
defaultValue: T,
|
|
128
|
+
mode: 'reactive' | 'await' = 'reactive'
|
|
129
|
+
): Readonly<Ref<T>> | Promise<T> {
|
|
130
|
+
if (mode === 'await') {
|
|
131
|
+
if (!ldClient.value) {
|
|
132
|
+
return Promise.resolve(defaultValue)
|
|
133
|
+
}
|
|
134
|
+
return ldClient.value.waitUntilReady()
|
|
135
|
+
.then(() => ldFlagSet.value[name] ?? defaultValue)
|
|
136
|
+
.catch((error) => {
|
|
137
|
+
console.error(`LaunchDarkly: Error waiting for client while getting stored flag "${name}".`, error)
|
|
138
|
+
return defaultValue
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// reactive mode
|
|
143
|
+
return readonly(computed(() => {
|
|
144
|
+
if (!ldInitialized.value) {
|
|
145
|
+
return defaultValue
|
|
146
|
+
}
|
|
147
|
+
return ldFlagSet.value[name] ?? defaultValue
|
|
148
|
+
}))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// TODO: or do we do this and then everytime the fn is called its required to use await?
|
|
152
|
+
// TODO: returning the computed means we dont need to add LD to middleware
|
|
153
|
+
// async function getStoredFlag(name: string): Promise<unknown> {
|
|
154
|
+
// await ldClient.value?.waitUntilReady()
|
|
155
|
+
// return ldFlagSet.value[name]
|
|
156
|
+
// }
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resets the LaunchDarkly state and closes the client connection.
|
|
160
|
+
*/
|
|
161
|
+
const $reset = () => {
|
|
162
|
+
ldInitialized.value = false
|
|
163
|
+
isInitializing.value = false
|
|
164
|
+
ldClient.value?.close()
|
|
165
|
+
ldClient.value = null
|
|
166
|
+
ldFlagSet.value = {}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
getFeatureFlag,
|
|
171
|
+
getStoredFlag,
|
|
172
|
+
ldInitialized: readonly(ldInitialized),
|
|
173
|
+
ldFlagSet: readonly(ldFlagSet),
|
|
174
|
+
ldClient: readonly(ldClient),
|
|
175
|
+
$reset
|
|
176
|
+
}
|
|
177
|
+
}
|
package/app/error.vue
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NuxtError } from '#app'
|
|
3
|
+
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const localePath = useLocalePath()
|
|
6
|
+
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
error: { type: Object as () => NuxtError, default: undefined }
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
// TODO: update with other error codes?
|
|
12
|
+
const errorKey: string | number = props.error?.statusCode === 404 ? 404 : 'unknown'
|
|
13
|
+
|
|
14
|
+
// cant use definePageMeta in error.vue
|
|
15
|
+
useRoute().meta.hideBreadcrumbs = true
|
|
16
|
+
|
|
17
|
+
useHead({
|
|
18
|
+
title: errorKey === 404 ? t('connect.page.error.404.title') : t('connect.page.error.unknown.title')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const errorObj = {
|
|
22
|
+
name: props.error?.name || '',
|
|
23
|
+
cause: props.error?.cause || '',
|
|
24
|
+
message: props.error?.message || '',
|
|
25
|
+
statusCode: props.error?.statusCode || '',
|
|
26
|
+
statusMessage: props.error?.statusMessage || '',
|
|
27
|
+
stack: props.error?.stack || '',
|
|
28
|
+
data: props.error?.data || ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
onMounted(() => {
|
|
32
|
+
console.error('Application Error: ', errorObj)
|
|
33
|
+
})
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<UApp :toaster="{ position: 'bottom-center' }">
|
|
38
|
+
<NuxtLayout name="connect-base">
|
|
39
|
+
<div class="m-auto flex flex-col items-center gap-4 h-full justify-center">
|
|
40
|
+
<h1>
|
|
41
|
+
{{ $t(`connect.page.error.${errorKey}.h1`) }}
|
|
42
|
+
</h1>
|
|
43
|
+
<p>{{ $t(`connect.page.error.${errorKey}.content`) }}</p>
|
|
44
|
+
<UButton
|
|
45
|
+
:label="$t('connect.label.goHome')"
|
|
46
|
+
icon="i-mdi-home"
|
|
47
|
+
size="xl"
|
|
48
|
+
:to="localePath('/')"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
</NuxtLayout>
|
|
52
|
+
</UApp>
|
|
53
|
+
</template>
|