@motor-cms/ui-admin 1.16.3 → 2.0.1
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/app/components/UsersOnboarding.vue +0 -7
- package/app/components/client/FooterSlotCard.vue +261 -0
- package/app/components/client/FrontendConfigSection.vue +115 -0
- package/app/components/client/GlobalComponentsSection.vue +50 -0
- package/app/composables/useClientFrontendConfig.ts +145 -0
- package/app/composables/useClientLanguages.ts +81 -0
- package/app/data/footerTemplate.ts +283 -0
- package/app/lang/de/motor-admin/clients.json +34 -1
- package/app/lang/en/motor-admin/clients.json +34 -1
- package/app/pages/motor-admin/clients/[id]/edit.vue +158 -3
- package/app/types/frontend-config.ts +252 -0
- package/nuxt.config.ts +6 -0
- package/package.json +2 -2
|
@@ -41,13 +41,6 @@ function onFinish() {
|
|
|
41
41
|
return
|
|
42
42
|
}
|
|
43
43
|
markAdminGridDone()
|
|
44
|
-
// commitDone() before the API call — the browser may cancel the in-flight
|
|
45
|
-
// request if the user reloads before it resolves, and the localStorage flag
|
|
46
|
-
// must already be present to prevent DashboardOnboarding from resetting
|
|
47
|
-
// state and restarting the tour when the dashboard remounts after the
|
|
48
|
-
// builder tour redirects back to '/'.
|
|
49
|
-
commitDone()
|
|
50
|
-
completeOnboarding().catch(() => {})
|
|
51
44
|
setPending('builder-pages')
|
|
52
45
|
router.push('/motor-builder/builder-pages')
|
|
53
46
|
}
|
|
@@ -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
|
+
}
|