@sbc-connect/nuxt-base 0.1.3 → 0.1.4

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.
Files changed (33) hide show
  1. package/.env.example +11 -1
  2. package/CHANGELOG.md +37 -0
  3. package/LICENSE +28 -0
  4. package/README.md +36 -47
  5. package/app/app.config.ts +31 -171
  6. package/app/app.vue +16 -3
  7. package/app/components/Connect/BCGovLogo.vue +4 -4
  8. package/app/components/Connect/Breadcrumb.vue +61 -0
  9. package/app/components/Connect/Footer.vue +104 -0
  10. package/app/components/Connect/Header/LogoHomeLink.vue +4 -4
  11. package/app/components/Connect/Header/WhatsNew.vue +36 -0
  12. package/app/components/Connect/Header/Wrapper.vue +2 -2
  13. package/app/components/Connect/Header/index.vue +16 -1
  14. package/app/components/Connect/LocaleSelect.vue +1 -1
  15. package/app/components/Connect/Slideover/WhatsNew.vue +61 -0
  16. package/app/components/Connect/SystemBanner.vue +50 -0
  17. package/app/composables/useConnectLaunchDarkly.ts +177 -0
  18. package/app/error.vue +53 -0
  19. package/app/interfaces/connect-breadcrumb.ts +5 -0
  20. package/app/interfaces/connect-whatsnew.ts +14 -0
  21. package/app/layouts/{default.vue → ConnectBase.vue} +3 -0
  22. package/app/plugins/launchdarkly.client.ts +9 -0
  23. package/app/plugins/sanitize.client.ts +13 -0
  24. package/app/plugins/whats-new.client.ts +45 -0
  25. package/app/spa-loading-template.html +39 -0
  26. package/app/utils/appendUrlParam.ts +4 -0
  27. package/i18n/locales/en-CA.ts +50 -82
  28. package/i18n/locales/fr-CA.ts +33 -30
  29. package/modules/assets/index.ts +1 -1
  30. package/modules/assets/runtime/assets/core-tw.css +57 -30
  31. package/nuxt.config.ts +62 -1
  32. package/package.json +18 -8
  33. package/app/components/Connect/Header/Base.vue +0 -17
@@ -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-connect-yellow bg-connect-darkBlue dark:border-b dark:bg-connect-darkGray"
5
+ class="relative border-b-2 border-brandLight bg-brandDark"
6
6
  >
7
7
  <nav
8
8
  class="app-inner-container px-2"
9
- :aria-label="$t('ConnectHeader.navLabel')"
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
- <ConnectHeaderBase />
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>
@@ -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('ConnectLocaleSelect.label')"
31
+ :aria-label="$t('connect.label.localeSelect')"
32
32
  size="lg"
33
33
  variant="ghost"
34
34
  color="secondary"
@@ -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-gray-100/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-markBlue);
59
+ text-decoration: underline;
60
+ }
61
+ </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 px-2">
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-0 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,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>
@@ -0,0 +1,5 @@
1
+ import type { BreadcrumbItem } from '#ui/types'
2
+
3
+ export interface ConnectBreadcrumb extends BreadcrumbItem {
4
+ appendAccountId?: boolean
5
+ }
@@ -0,0 +1,14 @@
1
+ export interface ConnectWhatsNewItem {
2
+ app: string
3
+ date: string
4
+ description: string
5
+ id: number
6
+ priority: boolean
7
+ read: boolean
8
+ title: string
9
+ }
10
+
11
+ export interface ConnectWhatsNewState {
12
+ viewed: boolean
13
+ items: ConnectWhatsNewItem[]
14
+ }
@@ -1,6 +1,8 @@
1
1
  <template>
2
2
  <div class="app-container">
3
3
  <ConnectHeader />
4
+ <ConnectSystemBanner />
5
+ <ConnectBreadcrumb />
4
6
  <div class="app-inner-container app-body">
5
7
  <div class="flex flex-col lg:flex-row lg:gap-6 grow">
6
8
  <div class="grow max-w-full overflow-hidden">
@@ -8,5 +10,6 @@
8
10
  </div>
9
11
  </div>
10
12
  </div>
13
+ <ConnectFooter />
11
14
  </div>
12
15
  </template>
@@ -0,0 +1,9 @@
1
+ // import { LDPlugin } from 'launchdarkly-vue-client-sdk'
2
+
3
+ // export default defineNuxtPlugin({
4
+ // name: 'connect-launchdarkly-plugin',
5
+ // parallel: true,
6
+ // setup(nuxtApp) {
7
+ // nuxtApp.vueApp.use(LDPlugin, { deferInitialization: true })
8
+ // }
9
+ // })
@@ -0,0 +1,13 @@
1
+ import DOMPurify from 'dompurify'
2
+
3
+ export default defineNuxtPlugin({
4
+ name: 'connect-sanitize-plugin',
5
+ parallel: true,
6
+ setup() {
7
+ return {
8
+ provide: {
9
+ sanitize: DOMPurify.sanitize
10
+ }
11
+ }
12
+ }
13
+ })
@@ -0,0 +1,45 @@
1
+ import { useStorage } from '@vueuse/core'
2
+ import isEqual from 'lodash-es/isEqual'
3
+
4
+ export default defineNuxtPlugin({
5
+ name: 'connect-whats-new-plugin',
6
+ parallel: true,
7
+ setup(nuxtApp) {
8
+ // exit early if whats new === false
9
+ const whatsNew = useAppConfig().connect.header.whatsNew
10
+ if (!whatsNew) {
11
+ return
12
+ }
13
+ const rtc = useRuntimeConfig().public
14
+ const url = rtc.statusApiUrl + rtc.statusApiVersion
15
+
16
+ // load whats new only once when app mounts
17
+ nuxtApp.hook('app:mounted', async () => {
18
+ // set default localStorage state
19
+ const state = useStorage<ConnectWhatsNewState>(
20
+ 'connect-whats-new', // storage key
21
+ { viewed: false, items: [] }, // default value
22
+ localStorage, // could change this to sessionStorage
23
+ { mergeDefaults: true } // merge anything already in localStorage with default value
24
+ )
25
+
26
+ try {
27
+ // fetch items
28
+ const res = await $fetch<ConnectWhatsNewItem[]>(`${url}/whatsnew`, {
29
+ parseResponse: JSON.parse, // required to parse JSON correctly
30
+ headers: {
31
+ 'App-Name': rtc.appName
32
+ }
33
+ })
34
+
35
+ // only update value if new items exist
36
+ if (!isEqual(state.value.items, res) && res.length > 0) {
37
+ state.value.items = res
38
+ state.value.viewed = false
39
+ }
40
+ } catch {
41
+ // silently handle errors
42
+ }
43
+ })
44
+ }
45
+ })
@@ -0,0 +1,39 @@
1
+ <div class="loader"></div>
2
+ <style>
3
+ .loader {
4
+ display: block;
5
+ position: fixed;
6
+ z-index: 1031;
7
+ top: 50%;
8
+ left: 50%;
9
+ transform: translate(-50%, -50%);
10
+ width: 50px;
11
+ height: 50px;
12
+ box-sizing: border-box;
13
+ border: solid 5px transparent;
14
+ border-top-color: #1669bb;
15
+ border-left-color: #1669bb;
16
+ border-bottom-color: #efefef;
17
+ border-right-color: #efefef;
18
+ border-radius: 50%;
19
+ -webkit-animation: loader 400ms linear infinite;
20
+ animation: loader 400ms linear infinite;
21
+ }
22
+
23
+ @-webkit-keyframes loader {
24
+ 0% {
25
+ -webkit-transform: translate(-50%, -50%) rotate(0deg);
26
+ }
27
+ 100% {
28
+ -webkit-transform: translate(-50%, -50%) rotate(360deg);
29
+ }
30
+ }
31
+ @keyframes loader {
32
+ 0% {
33
+ transform: translate(-50%, -50%) rotate(0deg);
34
+ }
35
+ 100% {
36
+ transform: translate(-50%, -50%) rotate(360deg);
37
+ }
38
+ }
39
+ </style>
@@ -0,0 +1,4 @@
1
+ export function appendUrlParam(url: string, key: string, value: string | number) {
2
+ const separator = url.includes('?') ? '&' : '?'
3
+ return `${url}${separator}${key}=${value}`
4
+ }