@motor-cms/ui-admin 1.16.2 → 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.
@@ -129,3 +129,23 @@ export function useOnboardingDone() {
129
129
 
130
130
  return { commitDone, isDone }
131
131
  }
132
+
133
+ /**
134
+ * Central guard: should the onboarding tour run for this user?
135
+ *
136
+ * Returns true only when BOTH conditions are met:
137
+ * 1. Backend says show_onboarding=true (fresh user or "restart tour" from profile)
138
+ * 2. User hasn't already completed/skipped the tour in this browser (isDone=false)
139
+ *
140
+ * Use this everywhere instead of hand-rolling the check.
141
+ */
142
+ export function useOnboardingEnabled() {
143
+ const { user } = useSanctumAuth<User>()
144
+ const { isDone } = useOnboardingDone()
145
+
146
+ const isEnabled = computed(() => {
147
+ return !!user.value?.data?.show_onboarding && !isDone()
148
+ })
149
+
150
+ return { isEnabled }
151
+ }
@@ -0,0 +1,283 @@
1
+ import { generateUuid } from '@motor-cms/ui-core/app/utils/uuid'
2
+
3
+ // Types mirror @zrmdev/ui-builder/app/types/builder/page-definition. Names
4
+ // (HeadlineParagraphComponent, ImageComponent, HeadlineAtom, ParagraphAtom,
5
+ // ButtonAtom, ImageAtom) match the renderer registry in
6
+ // motor-ui-components/app/builder-registry/bootstrap.ts.
7
+
8
+ // ============================================
9
+ // Local type definitions (mirror page-definition)
10
+ // ============================================
11
+
12
+ interface CssProp {
13
+ key: string
14
+ value: string
15
+ }
16
+
17
+ interface PageAtom {
18
+ uuid: string
19
+ display_name: string
20
+ component_name: string
21
+ classes: string
22
+ attributes: Record<string, unknown>
23
+ locked_attributes: string[]
24
+ disabled: boolean
25
+ visible: boolean
26
+ cssProps?: CssProp[]
27
+ admin_scss?: string
28
+ }
29
+
30
+ interface ComponentSlot {
31
+ uuid: string
32
+ name: string
33
+ display_name: string
34
+ allowedAtoms: string[]
35
+ atoms: PageAtom[]
36
+ }
37
+
38
+ interface PageComponent {
39
+ uuid: string
40
+ name: string
41
+ display_name: string
42
+ icon: string
43
+ classes: string
44
+ cssClassName: string
45
+ visible: boolean
46
+ disabled: boolean
47
+ attributes: Record<string, unknown>
48
+ component_slot_name: string | null
49
+ component_slot_prefix: string | null
50
+ is_removable: number
51
+ is_duplicatable: number
52
+ min_amount_in_another_component: number
53
+ display_viewports: string
54
+ slots: ComponentSlot[]
55
+ components: PageComponent[]
56
+ scorings: unknown[]
57
+ scoring_component_configuration: { score: number; topic_id: number; comparison_operator: string }
58
+ anchors: unknown[]
59
+ admin_scss?: string
60
+ }
61
+
62
+ interface PageColumn {
63
+ uuid: string
64
+ classes: string
65
+ display_name: string
66
+ value_as_grid_column: number
67
+ rows: PageRow[]
68
+ components: PageComponent[]
69
+ admin_scss?: string
70
+ }
71
+
72
+ interface PageRow {
73
+ uuid: string
74
+ display_name: string
75
+ classes: string
76
+ global_css: string
77
+ cols: PageColumn[]
78
+ admin_scss?: string
79
+ }
80
+
81
+ type PageDefinition = PageRow[]
82
+
83
+ // ============================================
84
+ // Builder helpers
85
+ // ============================================
86
+
87
+ function makeAtom(
88
+ display_name: string,
89
+ component_name: string,
90
+ attributes: Record<string, unknown> = {}
91
+ ): PageAtom {
92
+ return {
93
+ uuid: generateUuid(),
94
+ display_name,
95
+ component_name,
96
+ classes: '',
97
+ attributes,
98
+ locked_attributes: [],
99
+ disabled: false,
100
+ visible: true
101
+ }
102
+ }
103
+
104
+ function makeSlot(
105
+ name: string,
106
+ display_name: string,
107
+ allowedAtoms: string[],
108
+ atoms: PageAtom[]
109
+ ): ComponentSlot {
110
+ return {
111
+ uuid: generateUuid(),
112
+ name,
113
+ display_name,
114
+ allowedAtoms,
115
+ atoms
116
+ }
117
+ }
118
+
119
+ function makeComponent(
120
+ name: string,
121
+ display_name: string,
122
+ cssClassName: string,
123
+ slots: ComponentSlot[],
124
+ attributes: Record<string, unknown> = {},
125
+ components: PageComponent[] = []
126
+ ): PageComponent {
127
+ return {
128
+ uuid: generateUuid(),
129
+ name,
130
+ display_name,
131
+ icon: '',
132
+ classes: '',
133
+ cssClassName,
134
+ visible: true,
135
+ disabled: false,
136
+ attributes,
137
+ component_slot_name: null,
138
+ component_slot_prefix: null,
139
+ is_removable: 1,
140
+ is_duplicatable: 1,
141
+ min_amount_in_another_component: 0,
142
+ display_viewports: 's,t,m,l,xl',
143
+ slots,
144
+ components,
145
+ scorings: [],
146
+ scoring_component_configuration: { score: 0, topic_id: 0, comparison_operator: '' },
147
+ anchors: []
148
+ }
149
+ }
150
+
151
+ function makeColumn(
152
+ display_name: string,
153
+ value_as_grid_column: number,
154
+ components: PageComponent[]
155
+ ): PageColumn {
156
+ return {
157
+ uuid: generateUuid(),
158
+ classes: '',
159
+ display_name,
160
+ value_as_grid_column,
161
+ rows: [],
162
+ components
163
+ }
164
+ }
165
+
166
+ function makeRow(display_name: string, cols: PageColumn[], classes = ''): PageRow {
167
+ return {
168
+ uuid: generateUuid(),
169
+ display_name,
170
+ classes,
171
+ global_css: '',
172
+ cols
173
+ }
174
+ }
175
+
176
+ // ============================================
177
+ // Footer template factory
178
+ // ============================================
179
+
180
+ const PLACEHOLDER_IMAGE = '/images/general/frau_mit_kind.jpeg'
181
+
182
+ const TEXT_SLOT_ALLOWED = ['OverlineAtom', 'HeadlineAtom', 'ParagraphAtom', 'ButtonAtom', 'ImageAtom', 'VideoAtom']
183
+
184
+ function textContentComponent(
185
+ display_name: string,
186
+ atoms: PageAtom[],
187
+ attributes: Record<string, unknown> = { has_background: false }
188
+ ): PageComponent {
189
+ return makeComponent(
190
+ 'HeadlineParagraphComponent',
191
+ display_name,
192
+ 'headline-paragraph',
193
+ [makeSlot('content', 'Content', TEXT_SLOT_ALLOWED, atoms)],
194
+ attributes
195
+ )
196
+ }
197
+
198
+ function headlineAtom(text: string, level: 'h2' | 'h3' | 'h4' = 'h3'): PageAtom {
199
+ return makeAtom('Headline', 'HeadlineAtom', {
200
+ text,
201
+ type: level,
202
+ displayedLevel: level,
203
+ weight: 'bold'
204
+ })
205
+ }
206
+
207
+ function paragraphAtom(text: string): PageAtom {
208
+ return makeAtom('Paragraph', 'ParagraphAtom', {
209
+ text,
210
+ bullet_type: 'check__default',
211
+ orderedlist_type: 'decimal'
212
+ })
213
+ }
214
+
215
+ function buttonAtom(title: string): PageAtom {
216
+ return makeAtom('Button', 'ButtonAtom', {
217
+ title,
218
+ link: '',
219
+ variant: 'dark',
220
+ has_arrow: false
221
+ })
222
+ }
223
+
224
+ function imageAtom(display_name: string): PageAtom {
225
+ return makeAtom(display_name, 'ImageAtom', {
226
+ src: PLACEHOLDER_IMAGE,
227
+ alt: display_name
228
+ })
229
+ }
230
+
231
+ function imageComponent(display_name: string, extraClasses = ''): PageComponent {
232
+ const c = makeComponent(
233
+ 'ImageComponent',
234
+ display_name,
235
+ 'image',
236
+ [makeSlot('content', 'Content', ['ImageAtom'], [imageAtom(display_name)])]
237
+ )
238
+ if (extraClasses) c.classes = extraClasses
239
+ return c
240
+ }
241
+
242
+ export function createFooterTemplate(): PageDefinition {
243
+ // ---- Row 1: 6/6 split ----
244
+ // Col 1: intro text (Headline + Paragraph)
245
+ // Col 2: three award badges, each its own ImageComponent
246
+ const row1 = makeRow('Row 1', [
247
+ makeColumn('Column 1', 6, [
248
+ textContentComponent('Intro', [
249
+ headlineAtom('Headline', 'h2'),
250
+ paragraphAtom('Paragraph')
251
+ ])
252
+ ]),
253
+ makeColumn('Column 2', 6, [
254
+ imageComponent('Badge 1', 'footer-badge'),
255
+ imageComponent('Badge 2', 'footer-badge'),
256
+ imageComponent('Badge 3', 'footer-badge')
257
+ ])
258
+ ])
259
+
260
+ // ---- Row 2: four 3/12 columns ----
261
+ // Col 1: Kontakt (pre-filled headline)
262
+ // Col 2-4: nav-style headline + paragraph + CTA button
263
+ // Row gets the white inset card chrome inside the peach FooterFrame.
264
+ const row2 = makeRow('Row 2', [
265
+ makeColumn('Column 1', 3, [
266
+ textContentComponent('Kontakt', [
267
+ headlineAtom('Kontakt', 'h3'),
268
+ paragraphAtom('Paragraph')
269
+ ])
270
+ ]),
271
+ ...['Column 2', 'Column 3', 'Column 4'].map((label) =>
272
+ makeColumn(label, 3, [
273
+ textContentComponent(label, [
274
+ headlineAtom('Headline', 'h3'),
275
+ paragraphAtom('Paragraph'),
276
+ buttonAtom('CTA Button')
277
+ ])
278
+ ])
279
+ )
280
+ ], 'footer-content-card')
281
+
282
+ return [row1, row2]
283
+ }
@@ -23,5 +23,38 @@
23
23
  "contact_phone": "Telefon",
24
24
  "group_address": "Adresse",
25
25
  "group_contact": "Kontakt",
26
- "group_other": "Sonstiges"
26
+ "group_other": "Sonstiges",
27
+ "frontend_config": {
28
+ "group_brand": "Marke",
29
+ "group_contact": "Kontakt (Frontend)",
30
+ "group_features": "Funktionen",
31
+ "group_social": "Social Media",
32
+ "group_seo": "SEO",
33
+ "brand_name": "Markenname",
34
+ "brand_logo_alt": "Logo Alt-Text",
35
+ "color_scheme": "Farbschema",
36
+ "logo_slug": "Logo",
37
+ "contact_url": "Kontaktseiten-URL",
38
+ "contact_email": "Kontakt-E-Mail",
39
+ "contact_whatsapp_url": "WhatsApp-URL",
40
+ "features_order_line": "Bestellstrecke",
41
+ "features_appointments": "Terminbuchung",
42
+ "features_clickpath": "Clickpath",
43
+ "features_footer_menu": "Footer-Menü",
44
+ "social_instagram": "Instagram",
45
+ "social_facebook": "Facebook",
46
+ "seo_site_name": "Seitenname"
47
+ },
48
+ "global_components": {
49
+ "title": "Globale Komponenten",
50
+ "footer": "Footer",
51
+ "no_footer": "Kein Footer konfiguriert",
52
+ "create_footer": "Footer erstellen",
53
+ "edit_footer": "Footer bearbeiten",
54
+ "unlink_footer": "Verknüpfung lösen",
55
+ "footer_created": "Footer erfolgreich erstellt.",
56
+ "published": "Veröffentlicht",
57
+ "draft": "Entwurf",
58
+ "no_languages": "Keine Sprachen für diesen Mandanten konfiguriert. Erstellen Sie zuerst Navigationsbäume."
59
+ }
27
60
  }
@@ -23,5 +23,38 @@
23
23
  "contact_phone": "Phone",
24
24
  "group_address": "Address",
25
25
  "group_contact": "Contact",
26
- "group_other": "Other"
26
+ "group_other": "Other",
27
+ "frontend_config": {
28
+ "group_brand": "Brand",
29
+ "group_contact": "Contact (Frontend)",
30
+ "group_features": "Features",
31
+ "group_social": "Social Media",
32
+ "group_seo": "SEO",
33
+ "brand_name": "Brand Name",
34
+ "brand_logo_alt": "Logo Alt Text",
35
+ "color_scheme": "Color Scheme",
36
+ "logo_slug": "Logo",
37
+ "contact_url": "Contact Page URL",
38
+ "contact_email": "Contact Email",
39
+ "contact_whatsapp_url": "WhatsApp URL",
40
+ "features_order_line": "Order Line",
41
+ "features_appointments": "Appointments",
42
+ "features_clickpath": "Clickpath",
43
+ "features_footer_menu": "Footer Menu",
44
+ "social_instagram": "Instagram",
45
+ "social_facebook": "Facebook",
46
+ "seo_site_name": "Site Name"
47
+ },
48
+ "global_components": {
49
+ "title": "Global Components",
50
+ "footer": "Footer",
51
+ "no_footer": "No footer configured",
52
+ "create_footer": "Create Footer",
53
+ "edit_footer": "Edit Footer",
54
+ "unlink_footer": "Unlink",
55
+ "footer_created": "Footer created successfully.",
56
+ "published": "Published",
57
+ "draft": "Draft",
58
+ "no_languages": "No languages configured for this client. Add navigation trees first."
59
+ }
27
60
  }
@@ -47,17 +47,6 @@ function onAnnouncementCreated() {
47
47
 
48
48
  const toast = useToast()
49
49
 
50
- onMounted(() => {
51
- if (sessionStorage.getItem('motor:login-success')) {
52
- sessionStorage.removeItem('motor:login-success')
53
- toast.add({
54
- title: t('motor-core.login.login_success'),
55
- color: 'success',
56
- icon: 'i-lucide-check-circle'
57
- })
58
- }
59
- })
60
-
61
50
  async function onDismiss(id: number) {
62
51
  await dismissAnnouncement(id)
63
52
  toast.add({ title: t('motor-admin.dashboard.announcement_dismissed'), icon: 'i-lucide-check', color: 'success' })
@@ -4,6 +4,7 @@ import type { FormSubmitEvent } from '@nuxt/ui'
4
4
 
5
5
  const { t } = useI18n()
6
6
  const { login } = useSanctumAuth()
7
+ const toast = useToast()
7
8
 
8
9
  definePageMeta({
9
10
  layout: 'auth',
@@ -53,7 +54,11 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
53
54
  email: event.data.email,
54
55
  password: event.data.password
55
56
  })
56
- sessionStorage.setItem('motor:login-success', '1')
57
+ toast.add({
58
+ title: t('motor-core.login.login_success'),
59
+ color: 'success',
60
+ icon: 'i-lucide-check-circle'
61
+ })
57
62
  } catch {
58
63
  error.value = t('motor-core.login.login_failed')
59
64
  } finally {
@@ -2,19 +2,148 @@
2
2
  <script setup lang="ts">
3
3
  import { clientFormMeta } from '../../../../types/generated/form-meta'
4
4
  import { clientFormConfig } from '@motor-cms/ui-core/app/types/config/client'
5
+ import { useClientFrontendConfig } from '../../../../composables/useClientFrontendConfig'
6
+ import { useClientLanguages } from '../../../../composables/useClientLanguages'
5
7
 
6
8
  definePageMeta({ layout: 'default', permission: 'clients.read' })
7
9
 
10
+ const { t } = useI18n()
11
+ const { error: notifyError } = useNotify()
8
12
  const route = useRoute()
9
- const { fields, schema, groups, state, loading, fetching, fetchError, canWrite, pageTitle, formRef, onSubmit, onSaveAndContinue, onSaveAndNew, deleteRecord, deleting } = await useEntityForm({
13
+ const clientId = route.params.id as string
14
+
15
+ const isFrontendConfigEnabled
16
+ = useRuntimeConfig().public.featureClientFrontendConfig === true
17
+
18
+ const {
19
+ fields,
20
+ schema,
21
+ groups,
22
+ state,
23
+ loading,
24
+ fetching,
25
+ fetchError,
26
+ canWrite,
27
+ pageTitle,
28
+ formRef,
29
+ onSubmit,
30
+ onSaveAndContinue,
31
+ onSaveAndNew,
32
+ deleteRecord,
33
+ deleting
34
+ } = await useEntityForm({
10
35
  apiEndpoint: '/api/v2/clients',
11
36
  routePrefix: '/motor-admin/clients',
12
37
  translationPrefix: 'motor-admin.clients',
13
38
  formMeta: clientFormMeta,
14
39
  formConfig: clientFormConfig,
15
40
  mode: 'edit',
16
- id: route.params.id as string
41
+ id: clientId,
42
+ beforeSubmit: (data) => {
43
+ if (!isFrontendConfigEnabled) return
44
+ if (!validateFrontendConfig()) {
45
+ throw new Error(t('motor-core.global.validation_failed'))
46
+ }
47
+ data.frontend_config = getFrontendConfigSubmitData()
48
+ }
49
+ })
50
+
51
+ const { data: clientRecord } = useNuxtData<{ data: Record<string, unknown> }>(
52
+ `entity-form-/api/v2/clients-${clientId}`
53
+ )
54
+
55
+ const {
56
+ state: frontendConfigState,
57
+ errors: frontendConfigErrors,
58
+ fields: frontendConfigFields,
59
+ groups: frontendConfigGroups,
60
+ validate: validateFrontendConfig,
61
+ getSubmitData: getFrontendConfigSubmitData
62
+ } = useClientFrontendConfig({ clientRecord, fetching })
63
+
64
+ const colorSchemeOptions = [
65
+ { label: 'energis', value: 'energis' },
66
+ { label: 'highspeed', value: 'highspeed' },
67
+ { label: 'jaeckel', value: 'jaeckel' }
68
+ ]
69
+
70
+ const logoSlugOptions = [
71
+ { label: 'energis', value: 'energis' },
72
+ { label: 'highspeed', value: 'highspeed' },
73
+ { label: 'jaeckel', value: 'jaeckel' }
74
+ ]
75
+
76
+ const clientIdRef = computed(() =>
77
+ isFrontendConfigEnabled ? (route.params.id as string) : ''
78
+ )
79
+ const { languages, isMultiLanguage, loading: languagesLoading } = useClientLanguages(clientIdRef)
80
+
81
+ const footerMap = computed(() => {
82
+ const fc = clientRecord.value?.data?.frontend_config as Record<string, unknown> | undefined
83
+ const gc = fc?.globalComponents as Record<string, unknown> | undefined
84
+ return gc?.footer as Record<string, string> | undefined
17
85
  })
86
+
87
+ const sanctumClient = useSanctumClient()
88
+
89
+ async function onFooterLinked(languageId: number, uuid: string, _pageId: number) {
90
+ try {
91
+ const freshClient = await sanctumClient<{ data: Record<string, unknown> }>(
92
+ `/api/v2/clients/${clientId}`
93
+ )
94
+ const freshConfig = (freshClient.data.frontend_config as Record<string, unknown>) ?? {}
95
+ const freshGc = (freshConfig.globalComponents as Record<string, unknown>) ?? {}
96
+ const freshFooter = { ...(freshGc.footer as Record<string, string>) ?? {} }
97
+ freshFooter[String(languageId)] = uuid
98
+
99
+ await sanctumClient(`/api/v2/clients/${clientId}`, {
100
+ method: 'PATCH',
101
+ body: {
102
+ name: freshClient.data.name,
103
+ slug: freshClient.data.slug,
104
+ frontend_config: {
105
+ ...freshConfig,
106
+ globalComponents: { ...freshGc, footer: freshFooter }
107
+ }
108
+ }
109
+ })
110
+
111
+ await refreshNuxtData(`entity-form-/api/v2/clients-${clientId}`)
112
+ } catch (err: unknown) {
113
+ const message = err instanceof Error ? err.message : t('motor-core.errors.update_failed')
114
+ notifyError(t('motor-admin.clients.edit_title'), message)
115
+ }
116
+ }
117
+
118
+ async function onFooterUnlinked(languageId: number) {
119
+ try {
120
+ const freshClient = await sanctumClient<{ data: Record<string, unknown> }>(
121
+ `/api/v2/clients/${clientId}`
122
+ )
123
+ const freshConfig = (freshClient.data.frontend_config as Record<string, unknown>) ?? {}
124
+ const freshGc = (freshConfig.globalComponents as Record<string, unknown>) ?? {}
125
+ const freshFooter = { ...(freshGc.footer as Record<string, string>) ?? {} }
126
+ delete freshFooter[String(languageId)]
127
+
128
+ await sanctumClient(`/api/v2/clients/${clientId}`, {
129
+ method: 'PATCH',
130
+ body: {
131
+ name: freshClient.data.name,
132
+ slug: freshClient.data.slug,
133
+ frontend_config: {
134
+ ...freshConfig,
135
+ globalComponents: { ...freshGc, footer: freshFooter }
136
+ }
137
+ }
138
+ })
139
+
140
+ await refreshNuxtData(`entity-form-/api/v2/clients-${clientId}`)
141
+ } catch (err: unknown) {
142
+ const message = err instanceof Error ? err.message : t('motor-core.errors.update_failed')
143
+ notifyError(t('motor-admin.clients.edit_title'), message)
144
+ }
145
+ }
146
+
18
147
  </script>
19
148
 
20
149
  <template>
@@ -40,6 +169,32 @@ const { fields, schema, groups, state, loading, fetching, fetchError, canWrite,
40
169
  @submit="onSubmit"
41
170
  @save-and-continue="onSaveAndContinue"
42
171
  @save-and-new="onSaveAndNew"
43
- />
172
+ >
173
+ <template
174
+ v-if="isFrontendConfigEnabled"
175
+ #after-fields
176
+ >
177
+ <ClientFrontendConfigSection
178
+ :state="frontendConfigState"
179
+ :fields="frontendConfigFields"
180
+ :groups="frontendConfigGroups"
181
+ :errors="frontendConfigErrors"
182
+ :disabled="!canWrite"
183
+ :color-scheme-options="colorSchemeOptions"
184
+ :logo-slug-options="logoSlugOptions"
185
+ />
186
+ <ClientGlobalComponentsSection
187
+ :client-id="route.params.id"
188
+ :client-name="(state.name as string) ?? ''"
189
+ :footer-map="footerMap"
190
+ :languages="languages"
191
+ :is-multi-language="isMultiLanguage"
192
+ :languages-loading="languagesLoading"
193
+ :disabled="!canWrite"
194
+ @footer-linked="onFooterLinked"
195
+ @footer-unlinked="onFooterUnlinked"
196
+ />
197
+ </template>
198
+ </FormBase>
44
199
  </FormPage>
45
200
  </template>
@@ -185,6 +185,7 @@ async function onRestartTour() {
185
185
  try {
186
186
  await resetOnboarding()
187
187
  resetOnboardingState()
188
+ await refreshIdentity()
188
189
  success(t('motor-core.profile.toast_tour_reset_title'), t('motor-core.profile.toast_tour_reset_message'))
189
190
  await router.push('/')
190
191
  }