@motor-cms/ui-admin 1.16.3 → 2.0.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,261 @@
1
+ <!-- app/components/client/FooterSlotCard.vue -->
2
+ <script setup lang="ts">
3
+ import type { components } from '@motor-cms/ui-core/app/types/generated/api'
4
+ import { createFooterTemplate } from '../../data/footerTemplate'
5
+
6
+ type BuilderPageResource = components['schemas']['BuilderPageResource']
7
+
8
+ const props = defineProps<{
9
+ clientId: number | string
10
+ clientName: string
11
+ languageId: number
12
+ languageName?: string
13
+ showLanguageLabel: boolean
14
+ builderPageUuid: string | null
15
+ disabled?: boolean
16
+ }>()
17
+
18
+ const emit = defineEmits<{
19
+ 'linked': [uuid: string, pageId: number]
20
+ 'unlinked': []
21
+ }>()
22
+
23
+ const client = useSanctumClient()
24
+ const router = useRouter()
25
+ const { t, locale } = useI18n()
26
+ const { success, error: notifyError } = useNotify()
27
+
28
+ // ============================================
29
+ // Page info state
30
+ // ============================================
31
+
32
+ interface PageInfo {
33
+ id: number
34
+ name: string
35
+ is_published: boolean
36
+ updated_at: string
37
+ }
38
+
39
+ const pageInfo = ref<PageInfo | null>(null)
40
+ const loadingPage = ref(false)
41
+ const creating = ref(false)
42
+
43
+ // ============================================
44
+ // Fetch page info on mount if UUID provided
45
+ // ============================================
46
+
47
+ async function fetchPageInfo(uuid: string): Promise<void> {
48
+ loadingPage.value = true
49
+ try {
50
+ const response = await client<{ data: BuilderPageResource }>(
51
+ `/api/v2/builder-pages/uuid/${uuid}`
52
+ )
53
+ const data = response.data
54
+ pageInfo.value = {
55
+ id: data.id,
56
+ name: data.name,
57
+ is_published: data.is_published,
58
+ updated_at: data.updated_at,
59
+ }
60
+ } catch (err: unknown) {
61
+ const message = err instanceof Error ? err.message : t('motor-core.errors.something_went_wrong')
62
+ notifyError(t('motor-admin.clients.global_components.footer'), message)
63
+ } finally {
64
+ loadingPage.value = false
65
+ }
66
+ }
67
+
68
+ watch(
69
+ () => props.builderPageUuid,
70
+ (uuid) => {
71
+ if (uuid) {
72
+ void fetchPageInfo(uuid)
73
+ } else {
74
+ pageInfo.value = null
75
+ }
76
+ },
77
+ { immediate: true }
78
+ )
79
+
80
+ // ============================================
81
+ // Helpers
82
+ // ============================================
83
+
84
+ function formatDate(isoString: string): string {
85
+ return new Date(isoString).toLocaleDateString(locale.value, {
86
+ day: 'numeric',
87
+ month: 'long',
88
+ year: 'numeric',
89
+ hour: '2-digit',
90
+ minute: '2-digit',
91
+ })
92
+ }
93
+
94
+ function buildPageName(): string {
95
+ const base = `${props.clientName} - Footer`
96
+ if (props.showLanguageLabel && props.languageName) {
97
+ return `${base} (${props.languageName})`
98
+ }
99
+ return base
100
+ }
101
+
102
+ // ============================================
103
+ // Actions
104
+ // ============================================
105
+
106
+ async function onCreateFooter(): Promise<void> {
107
+ creating.value = true
108
+ try {
109
+ // Step 1: create the empty page. The backend's createBuilderPage service
110
+ // hard-codes page_definition to []; the template is installed via the
111
+ // separate definition endpoint below.
112
+ const response = await client<{ data: BuilderPageResource }>('/api/v2/builder-pages', {
113
+ method: 'POST',
114
+ body: {
115
+ name: buildPageName(),
116
+ client_id: props.clientId,
117
+ language_id: props.languageId,
118
+ type: 'global_component',
119
+ cache_type: 'always',
120
+ ttl: 0,
121
+ is_excluded_from_search_index: false,
122
+ is_excluded_from_search: false,
123
+ is_excluded_from_cookie_banner: false,
124
+ },
125
+ })
126
+ const data = response.data
127
+
128
+ // Step 2: install the template via the definition endpoint.
129
+ try {
130
+ await client(`/api/v2/builder-pages/${data.id}/definition`, {
131
+ method: 'PUT',
132
+ body: {
133
+ id: data.id,
134
+ page_definition: JSON.stringify(createFooterTemplate()),
135
+ is_published: false,
136
+ },
137
+ })
138
+ } catch (defErr: unknown) {
139
+ // Page exists but the template install failed. Surface a warning so the
140
+ // user knows to populate it manually, but still link + navigate so the
141
+ // empty page isn't orphaned.
142
+ const message = defErr instanceof Error ? defErr.message : t('motor-core.errors.update_failed')
143
+ notifyError(t('motor-admin.clients.global_components.footer'), message)
144
+ }
145
+
146
+ emit('linked', data.uuid, data.id)
147
+ success(t('motor-admin.clients.global_components.footer_created'))
148
+ await router.push(`/motor-builder/builder-pages/${data.id}/edit`)
149
+ } catch (err: unknown) {
150
+ const message = err instanceof Error ? err.message : t('motor-core.errors.create_failed')
151
+ notifyError(t('motor-admin.clients.global_components.footer'), message)
152
+ } finally {
153
+ creating.value = false
154
+ }
155
+ }
156
+
157
+ function onEditFooter(): void {
158
+ if (pageInfo.value) {
159
+ router.push(`/motor-builder/builder-pages/${pageInfo.value.id}/edit`)
160
+ }
161
+ }
162
+
163
+ function onUnlinkFooter(): void {
164
+ emit('unlinked')
165
+ }
166
+ </script>
167
+
168
+ <template>
169
+ <div class="flex items-center justify-between gap-4 rounded-lg border border-default px-4 py-3">
170
+ <!-- Left: info -->
171
+ <div class="flex items-center gap-3 min-w-0">
172
+ <UIcon name="i-lucide-panel-bottom" class="size-5 text-muted shrink-0" />
173
+
174
+ <div class="min-w-0">
175
+ <!-- Label -->
176
+ <div class="text-sm font-medium text-highlighted">
177
+ {{ t('motor-admin.clients.global_components.footer') }}
178
+ <span v-if="showLanguageLabel && languageName" class="text-muted font-normal">
179
+ ({{ languageName }})
180
+ </span>
181
+ </div>
182
+
183
+ <!-- Footer details when page exists -->
184
+ <template v-if="pageInfo">
185
+ <div class="flex items-center flex-wrap gap-2 mt-0.5">
186
+ <span class="text-sm text-muted truncate">{{ pageInfo.name }}</span>
187
+ <UBadge
188
+ v-if="pageInfo.is_published"
189
+ color="success"
190
+ variant="subtle"
191
+ size="xs"
192
+ >
193
+ {{ t('motor-admin.clients.global_components.published') }}
194
+ </UBadge>
195
+ <UBadge
196
+ v-else
197
+ color="warning"
198
+ variant="subtle"
199
+ size="xs"
200
+ >
201
+ {{ t('motor-admin.clients.global_components.draft') }}
202
+ </UBadge>
203
+ <span class="text-xs text-dimmed">{{ formatDate(pageInfo.updated_at) }}</span>
204
+ </div>
205
+ </template>
206
+
207
+ <!-- Loading state -->
208
+ <template v-else-if="loadingPage">
209
+ <div class="flex items-center gap-1.5 mt-0.5">
210
+ <UIcon name="i-lucide-loader-2" class="size-3.5 animate-spin text-muted" />
211
+ <span class="text-sm text-muted">{{ t('motor-core.global.loading') }}</span>
212
+ </div>
213
+ </template>
214
+
215
+ <!-- No footer configured -->
216
+ <template v-else>
217
+ <div class="text-sm text-dimmed mt-0.5">
218
+ {{ t('motor-admin.clients.global_components.no_footer') }}
219
+ </div>
220
+ </template>
221
+ </div>
222
+ </div>
223
+
224
+ <!-- Right: actions -->
225
+ <div class="flex items-center gap-2 shrink-0">
226
+ <!-- Footer exists: Edit + Unlink -->
227
+ <template v-if="pageInfo">
228
+ <UButton
229
+ variant="outline"
230
+ size="sm"
231
+ :disabled="disabled"
232
+ @click="onEditFooter"
233
+ >
234
+ {{ t('motor-admin.clients.global_components.edit_footer') }}
235
+ </UButton>
236
+ <UButton
237
+ variant="ghost"
238
+ color="error"
239
+ size="sm"
240
+ :disabled="disabled"
241
+ @click="onUnlinkFooter"
242
+ >
243
+ {{ t('motor-admin.clients.global_components.unlink_footer') }}
244
+ </UButton>
245
+ </template>
246
+
247
+ <!-- No footer: Create -->
248
+ <template v-else-if="!loadingPage">
249
+ <UButton
250
+ variant="outline"
251
+ size="sm"
252
+ :loading="creating"
253
+ :disabled="disabled || creating"
254
+ @click="onCreateFooter"
255
+ >
256
+ {{ t('motor-admin.clients.global_components.create_footer') }}
257
+ </UButton>
258
+ </template>
259
+ </div>
260
+ </div>
261
+ </template>
@@ -0,0 +1,115 @@
1
+ <!-- app/components/client/FrontendConfigSection.vue -->
2
+ <script setup lang="ts">
3
+ import type { FormFieldConfig, FormGroupConfig } from '@motor-cms/ui-core/app/types/form'
4
+
5
+ const props = defineProps<{
6
+ state: Record<string, unknown>
7
+ fields: FormFieldConfig[]
8
+ groups: FormGroupConfig[]
9
+ errors: Record<string, string>
10
+ disabled?: boolean
11
+ colorSchemeOptions: { label: string, value: string }[]
12
+ logoSlugOptions: { label: string, value: string }[]
13
+ }>()
14
+
15
+ // ============================================
16
+ // Dot-path helpers
17
+ // ============================================
18
+
19
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
20
+ return path.split('.').reduce<unknown>((current, key) => {
21
+ if (current != null && typeof current === 'object') {
22
+ return (current as Record<string, unknown>)[key]
23
+ }
24
+ return undefined
25
+ }, obj)
26
+ }
27
+
28
+ function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
29
+ const keys = path.split('.')
30
+ let current: Record<string, unknown> = obj
31
+ for (let i = 0; i < keys.length - 1; i++) {
32
+ const key = keys[i]!
33
+ if (current[key] == null || typeof current[key] !== 'object') {
34
+ current[key] = {}
35
+ }
36
+ current = current[key] as Record<string, unknown>
37
+ }
38
+ const lastKey = keys[keys.length - 1]!
39
+ current[lastKey] = value
40
+ }
41
+
42
+ // ============================================
43
+ // Grouped field helpers
44
+ // ============================================
45
+
46
+ function fieldsForGroup(groupKey: string): FormFieldConfig[] {
47
+ return props.fields.filter(f => f.group === groupKey)
48
+ }
49
+
50
+ // ============================================
51
+ // Options resolver
52
+ // ============================================
53
+
54
+ function optionsForField(fieldKey: string): { label: string, value: string }[] {
55
+ if (fieldKey === 'colorScheme') return props.colorSchemeOptions
56
+ if (fieldKey === 'logoSlug') return props.logoSlugOptions
57
+ return []
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <template
63
+ v-for="group in groups"
64
+ :key="group.key"
65
+ >
66
+ <UPageCard :title="group.label">
67
+ <div class="space-y-4">
68
+ <template
69
+ v-for="field in fieldsForGroup(group.key)"
70
+ :key="field.key"
71
+ >
72
+ <UFormField
73
+ :label="field.label"
74
+ :name="field.key"
75
+ :required="field.required"
76
+ :error="errors[field.key]"
77
+ orientation="horizontal"
78
+ :ui="{ container: 'w-full max-w-2xl' }"
79
+ >
80
+ <!-- Toggle -->
81
+ <USwitch
82
+ v-if="field.input === 'toggle'"
83
+ :model-value="(getNestedValue(state, field.key) as boolean) ?? false"
84
+ :disabled="disabled"
85
+ @update:model-value="setNestedValue(state, field.key, $event)"
86
+ />
87
+
88
+ <!-- Select -->
89
+ <USelectMenu
90
+ v-else-if="field.input === 'select'"
91
+ :model-value="(getNestedValue(state, field.key) as string | undefined)"
92
+ :items="optionsForField(field.key)"
93
+ value-key="value"
94
+ label-key="label"
95
+ :placeholder="field.label"
96
+ :disabled="disabled"
97
+ class="w-full"
98
+ @update:model-value="setNestedValue(state, field.key, $event)"
99
+ />
100
+
101
+ <!-- Text / Email / URL -->
102
+ <UInput
103
+ v-else
104
+ :model-value="(getNestedValue(state, field.key) as string) ?? ''"
105
+ :type="field.input === 'email' ? 'email' : field.input === 'url' ? 'url' : 'text'"
106
+ :disabled="disabled"
107
+ class="w-full"
108
+ @update:model-value="setNestedValue(state, field.key, $event)"
109
+ />
110
+ </UFormField>
111
+ </template>
112
+ </div>
113
+ </UPageCard>
114
+ </template>
115
+ </template>
@@ -0,0 +1,50 @@
1
+ <!-- app/components/client/GlobalComponentsSection.vue -->
2
+ <script setup lang="ts">
3
+ const { t } = useI18n()
4
+
5
+ const props = defineProps<{
6
+ clientId: number | string
7
+ clientName: string
8
+ footerMap: Record<string, string> | undefined
9
+ languages: { id: number, name: string }[]
10
+ isMultiLanguage: boolean
11
+ languagesLoading: boolean
12
+ disabled?: boolean
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ 'footer-linked': [languageId: number, uuid: string, pageId: number]
17
+ 'footer-unlinked': [languageId: number]
18
+ }>()
19
+
20
+ function getFooterUuid(languageId: number): string | null {
21
+ return props.footerMap?.[String(languageId)] ?? null
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <UPageCard :title="t('motor-admin.clients.global_components.title')">
27
+ <div v-if="languagesLoading" class="flex items-center gap-2 text-muted py-4">
28
+ <UIcon name="i-lucide-loader-2" class="size-4 animate-spin" />
29
+ <span class="text-sm">{{ t('motor-core.global.loading') }}</span>
30
+ </div>
31
+ <div v-else-if="languages.length === 0" class="text-sm text-muted py-4">
32
+ {{ t('motor-admin.clients.global_components.no_languages') }}
33
+ </div>
34
+ <template v-else>
35
+ <ClientFooterSlotCard
36
+ v-for="lang in languages"
37
+ :key="lang.id"
38
+ :client-id="clientId"
39
+ :client-name="clientName"
40
+ :language-id="lang.id"
41
+ :language-name="lang.name"
42
+ :show-language-label="isMultiLanguage"
43
+ :builder-page-uuid="getFooterUuid(lang.id)"
44
+ :disabled="disabled"
45
+ @linked="(uuid: string, pageId: number) => emit('footer-linked', lang.id, uuid, pageId)"
46
+ @unlinked="emit('footer-unlinked', lang.id)"
47
+ />
48
+ </template>
49
+ </UPageCard>
50
+ </template>
@@ -0,0 +1,145 @@
1
+ import type { Ref, ComputedRef } from 'vue'
2
+ import type { FormFieldConfig, FormGroupConfig } from '@motor-cms/ui-core/app/types/form'
3
+ import {
4
+ type FrontendConfigFormState,
5
+ frontendConfigSchema,
6
+ emptyFrontendConfig,
7
+ frontendConfigFields,
8
+ frontendConfigGroups
9
+ } from '../types/frontend-config'
10
+
11
+ // ============================================
12
+ // Types
13
+ // ============================================
14
+
15
+ export interface UseClientFrontendConfigOptions {
16
+ clientRecord: Ref<{ data: Record<string, unknown> } | null | undefined>
17
+ fetching: Ref<boolean>
18
+ }
19
+
20
+ export interface UseClientFrontendConfigReturn {
21
+ state: FrontendConfigFormState
22
+ errors: Ref<Record<string, string>>
23
+ fields: ComputedRef<FormFieldConfig[]>
24
+ groups: ComputedRef<FormGroupConfig[]>
25
+ validate(): boolean
26
+ getSubmitData(): Record<string, unknown>
27
+ }
28
+
29
+ // ============================================
30
+ // Composable
31
+ // ============================================
32
+
33
+ export function useClientFrontendConfig(
34
+ options: UseClientFrontendConfigOptions
35
+ ): UseClientFrontendConfigReturn {
36
+ const { t } = useI18n()
37
+
38
+ // Reactive form state — starts empty, hydrated on load
39
+ const state = reactive<FrontendConfigFormState>(emptyFrontendConfig())
40
+
41
+ // Flat validation error map: dot-path → first message
42
+ const errors = ref<Record<string, string>>({})
43
+
44
+ // ============================================
45
+ // Hydration
46
+ // ============================================
47
+
48
+ watch(
49
+ () => options.fetching.value,
50
+ (isFetching) => {
51
+ // Hydrate when fetching completes (or data is already loaded)
52
+ if (!isFetching) {
53
+ const raw = options.clientRecord.value?.data?.frontend_config
54
+ if (!raw) return
55
+
56
+ const parsed = frontendConfigSchema.safeParse(raw)
57
+
58
+ if (parsed.success) {
59
+ Object.assign(state, parsed.data)
60
+ } else {
61
+ // Best-effort deep merge of whatever passed validation
62
+ const rawConfig = raw as Record<string, unknown>
63
+ for (const key of Object.keys(state) as Array<keyof FrontendConfigFormState>) {
64
+ const incoming = rawConfig[key]
65
+ if (incoming !== undefined && incoming !== null) {
66
+ if (typeof incoming === 'object' && !Array.isArray(incoming)) {
67
+ Object.assign(
68
+ state[key] as Record<string, unknown>,
69
+ incoming as Record<string, unknown>
70
+ )
71
+ } else {
72
+ // @ts-expect-error -- best-effort assignment for primitive fields
73
+ state[key] = incoming
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ },
80
+ { immediate: true }
81
+ )
82
+
83
+ // ============================================
84
+ // Validation
85
+ // ============================================
86
+
87
+ function validate(): boolean {
88
+ const result = frontendConfigSchema.safeParse(state)
89
+ if (result.success) {
90
+ errors.value = {}
91
+ return true
92
+ }
93
+
94
+ const flat: Record<string, string> = {}
95
+ for (const issue of result.error.issues) {
96
+ const path = issue.path.join('.')
97
+ if (!flat[path]) {
98
+ flat[path] = issue.message
99
+ }
100
+ }
101
+ errors.value = flat
102
+ return false
103
+ }
104
+
105
+ // ============================================
106
+ // Submit data
107
+ // ============================================
108
+
109
+ function getSubmitData(): Record<string, unknown> {
110
+ const clone = JSON.parse(JSON.stringify(state)) as Record<string, unknown>
111
+
112
+ // Preserve globalComponents from the raw API record — these are
113
+ // footer UUIDs managed separately (e.g. by GlobalComponentsSection) and
114
+ // must not be overwritten by the frontend config form.
115
+ const originalConfig = options.clientRecord.value?.data?.frontend_config as
116
+ | { globalComponents?: Record<string, unknown> }
117
+ | undefined
118
+
119
+ if (originalConfig?.globalComponents !== undefined) {
120
+ clone.globalComponents = originalConfig.globalComponents
121
+ }
122
+
123
+ return clone
124
+ }
125
+
126
+ // ============================================
127
+ // Field / group definitions (i18n-aware)
128
+ // ============================================
129
+
130
+ const fields = computed<FormFieldConfig[]>(() => frontendConfigFields(t))
131
+ const groups = computed<FormGroupConfig[]>(() => frontendConfigGroups(t))
132
+
133
+ // ============================================
134
+ // Return
135
+ // ============================================
136
+
137
+ return {
138
+ state,
139
+ errors,
140
+ fields,
141
+ groups,
142
+ validate,
143
+ getSubmitData
144
+ }
145
+ }
@@ -0,0 +1,81 @@
1
+ import type { Ref, ComputedRef } from 'vue'
2
+ import type { components } from '@motor-cms/ui-core/app/types/generated/api'
3
+
4
+ type NavigationTreeResource = components['schemas']['NavigationTreeResource']
5
+ type LanguageResource = components['schemas']['LanguageResource']
6
+
7
+ interface PaginatedResponse<T> {
8
+ data: T[]
9
+ }
10
+
11
+ export interface ClientLanguage {
12
+ id: number
13
+ name: string
14
+ }
15
+
16
+ export interface UseClientLanguagesReturn {
17
+ languages: Ref<ClientLanguage[]>
18
+ isMultiLanguage: ComputedRef<boolean>
19
+ loading: Ref<boolean>
20
+ }
21
+
22
+ export function useClientLanguages(clientId: Ref<string | number>): UseClientLanguagesReturn {
23
+ const client = useSanctumClient()
24
+
25
+ const languages = ref<ClientLanguage[]>([])
26
+ const loading = ref(false)
27
+
28
+ const isMultiLanguage = computed(() => languages.value.length > 1)
29
+
30
+ watch(
31
+ clientId,
32
+ async (id) => {
33
+ if (!id) {
34
+ languages.value = []
35
+ return
36
+ }
37
+
38
+ loading.value = true
39
+ try {
40
+ const treesResponse = await client<PaginatedResponse<NavigationTreeResource>>(
41
+ `/api/v2/navigation-trees?filter[client_id]=${id}&per_page=100`
42
+ )
43
+
44
+ const trees = treesResponse.data ?? []
45
+
46
+ const distinctLanguageIds = [...new Set(
47
+ trees
48
+ .map((tree) => Number(tree.language_id))
49
+ .filter((langId) => !isNaN(langId) && langId > 0)
50
+ )]
51
+
52
+ if (distinctLanguageIds.length === 0) {
53
+ languages.value = []
54
+ return
55
+ }
56
+
57
+ const languagesResponse = await client<PaginatedResponse<LanguageResource>>(
58
+ '/api/v2/languages?per_page=100'
59
+ )
60
+
61
+ const allLanguages = languagesResponse.data ?? []
62
+
63
+ languages.value = allLanguages
64
+ .filter((lang) => distinctLanguageIds.includes(lang.id))
65
+ .map((lang) => ({ id: lang.id, name: lang.english_name }))
66
+ .sort((a, b) => a.id - b.id)
67
+ } catch {
68
+ languages.value = []
69
+ } finally {
70
+ loading.value = false
71
+ }
72
+ },
73
+ { immediate: true }
74
+ )
75
+
76
+ return {
77
+ languages,
78
+ isMultiLanguage,
79
+ loading,
80
+ }
81
+ }