@nuxt-customer-portal/saas-configuration 0.3.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +13 -0
  4. package/app/assets/css/main.css +439 -0
  5. package/app/assets/css/themes/brutal.css +453 -0
  6. package/app/assets/css/tokens.css +136 -0
  7. package/app/components/PortalSettingsEditor.vue +452 -0
  8. package/app/components/PublicPortalLogo.vue +26 -0
  9. package/app/components/portal-settings/BrandingStep.vue +198 -0
  10. package/app/components/portal-settings/HomeStep.vue +116 -0
  11. package/app/components/portal-settings/LegalStep.vue +44 -0
  12. package/app/components/portal-settings/ModulesStep.vue +88 -0
  13. package/app/components/portal-settings/ReviewStep.vue +54 -0
  14. package/app/composables/usePortalSettings.ts +25 -0
  15. package/app/middleware/00-portal-onboarding.global.ts +52 -0
  16. package/app/pages/admin/portal-settings.vue +6 -0
  17. package/app/pages/index.vue +170 -0
  18. package/app/pages/onboarding.vue +8 -0
  19. package/app/pages/privacy.vue +46 -0
  20. package/app/pages/terms.vue +46 -0
  21. package/app/plugins/00-portal-settings.ts +26 -0
  22. package/app/plugins/saas-configuration-feature.ts +3 -0
  23. package/i18n/locales/en.json +101 -0
  24. package/i18n/locales/nl.json +105 -0
  25. package/migrations/0000_portal_settings.sql +9 -0
  26. package/nuxt.config.ts +15 -0
  27. package/package.json +50 -0
  28. package/portal.manifest.mjs +7 -0
  29. package/server/api/admin/portal-settings/complete.post.ts +5 -0
  30. package/server/api/admin/portal-settings/index.get.ts +4 -0
  31. package/server/api/admin/portal-settings/index.put.ts +5 -0
  32. package/server/api/portal/bootstrap.get.ts +1 -0
  33. package/server/api/portal/public.get.ts +10 -0
  34. package/server/middleware/01-portal-bootstrap-gate.ts +50 -0
  35. package/server/utils/portal-settings.ts +138 -0
  36. package/shared/feature.ts +30 -0
  37. package/shared/primary-contrast.ts +12 -0
  38. package/shared/settings.ts +146 -0
  39. package/shared/theme.ts +134 -0
@@ -0,0 +1,452 @@
1
+ <script setup lang="ts">
2
+ import type { PortalOnboardingStep, PortalSettings } from '../../shared/settings'
3
+ import { portalOnboardingSteps, portalSettingsSchema } from '../../shared/settings'
4
+ import { primaryForeground } from '../../shared/primary-contrast'
5
+
6
+ const props = defineProps<{ onboarding?: boolean }>()
7
+ const { t } = useI18n()
8
+ const {
9
+ data,
10
+ status,
11
+ error: loadError
12
+ } = await useFetch<{ settings: PortalSettings; step: PortalOnboardingStep; completed: boolean }>(
13
+ '/api/admin/portal-settings'
14
+ )
15
+ const state = ref<PortalSettings | null>(null)
16
+ const step = ref<PortalOnboardingStep>('branding')
17
+ const busy = ref(false)
18
+ const colorMode = useColorMode()
19
+ const toast = useToast()
20
+
21
+ function showError(message: string) {
22
+ toast.add({ title: message, color: 'error' })
23
+ }
24
+
25
+ watch(
26
+ data,
27
+ (value) => {
28
+ if (!value || state.value) {
29
+ return
30
+ }
31
+ state.value = structuredClone(value.settings)
32
+ step.value = !props.onboarding && value.step === 'review' ? 'branding' : value.step
33
+ },
34
+ { immediate: true }
35
+ )
36
+
37
+ watchEffect(() => {
38
+ if (!props.onboarding && step.value === 'review') {
39
+ step.value = 'branding'
40
+ }
41
+ })
42
+
43
+ watch(
44
+ () => state.value?.appearance.colorMode,
45
+ (policy) => {
46
+ if (policy === 'light-only') {
47
+ colorMode.preference = 'light'
48
+ }
49
+ if (policy === 'dark-only') {
50
+ colorMode.preference = 'dark'
51
+ }
52
+ },
53
+ { immediate: true }
54
+ )
55
+
56
+ const showColorModeSwitcher = computed(() => state.value?.appearance.colorMode === 'user-choice')
57
+ const editorSteps = computed(() => portalOnboardingSteps.filter((item) => item !== 'review'))
58
+ const primaryColorStyle = computed<Record<string, string>>(() => {
59
+ const appearance = state.value?.appearance
60
+ if (!appearance) {
61
+ return {} as Record<string, string>
62
+ }
63
+ const primary = colorMode.value === 'dark' ? appearance.primaryDark : appearance.primaryLight
64
+ return {
65
+ '--portal-primary': primary,
66
+ '--portal-on-primary': primaryForeground(primary),
67
+ '--ui-primary': primary,
68
+ '--color-primary-500': primary,
69
+ '--color-primary-600': primary
70
+ }
71
+ })
72
+ const stepIndex = computed(() => portalOnboardingSteps.indexOf(step.value))
73
+ const stepLabels = computed<Record<PortalOnboardingStep, string>>(() => ({
74
+ branding: t('saasSettings.editor.steps.branding'),
75
+ modules: t('saasSettings.editor.steps.modules'),
76
+ home: t('saasSettings.editor.steps.home'),
77
+ legal: t('saasSettings.editor.steps.legal'),
78
+ review: t('saasSettings.editor.steps.review')
79
+ }))
80
+ async function save(next?: PortalOnboardingStep) {
81
+ if (!state.value) {
82
+ return false
83
+ }
84
+ const parsed = portalSettingsSchema.safeParse(state.value)
85
+ if (!parsed.success) {
86
+ showError(t('saasSettings.editor.messages.reviewSettings'))
87
+ return false
88
+ }
89
+ busy.value = true
90
+ try {
91
+ const result = await $fetch<{ settings: PortalSettings; step: PortalOnboardingStep }>(
92
+ '/api/admin/portal-settings',
93
+ { method: 'PUT', body: { settings: parsed.data, step: next || step.value } }
94
+ )
95
+ state.value = structuredClone(result.settings)
96
+ step.value = result.step
97
+ await usePortalSettings().refreshPublicSettings()
98
+ if (!props.onboarding) {
99
+ toast.add({ title: t('saasSettings.editor.messages.saved'), color: 'success' })
100
+ }
101
+ return true
102
+ } catch {
103
+ showError(t('saasSettings.editor.messages.saveFailed'))
104
+ return false
105
+ } finally {
106
+ busy.value = false
107
+ }
108
+ }
109
+ async function complete() {
110
+ if (!state.value) {
111
+ return
112
+ }
113
+ busy.value = true
114
+ try {
115
+ await $fetch('/api/admin/portal-settings/complete', { method: 'POST', body: { settings: state.value } })
116
+ await Promise.all([usePortalSettings().refreshBootstrap(), usePortalSettings().refreshPublicSettings()])
117
+ window.location.assign('/dashboard')
118
+ } catch {
119
+ showError(t('saasSettings.editor.messages.completeFailed'))
120
+ } finally {
121
+ busy.value = false
122
+ }
123
+ }
124
+ async function submitForm() {
125
+ if (!props.onboarding) {
126
+ return save()
127
+ }
128
+ if (step.value === 'review') {
129
+ return complete()
130
+ }
131
+ return save(portalOnboardingSteps[Math.min(stepIndex.value + 1, portalOnboardingSteps.length - 1)]!)
132
+ }
133
+ </script>
134
+
135
+ <template>
136
+ <div :class="['settings-page', { 'settings-page--editor': !props.onboarding }]" :style="primaryColorStyle">
137
+ <div v-if="props.onboarding && showColorModeSwitcher" class="settings-toolbar"><UColorModeButton /></div>
138
+ <div :class="['settings-workbench', { 'settings-workbench--editor': !props.onboarding }]">
139
+ <div v-if="status === 'pending'" class="settings-state">
140
+ <UIcon name="i-lucide-loader-circle" class="animate-spin" /> {{ t('saasSettings.editor.loading') }}
141
+ </div>
142
+ <UAlert
143
+ v-else-if="loadError"
144
+ color="error"
145
+ :title="t('saasSettings.editor.unavailable')"
146
+ :description="loadError.message"
147
+ />
148
+ <template v-else-if="state">
149
+ <nav v-if="props.onboarding" class="settings-rail" :aria-label="t('saasSettings.editor.configurationSections')">
150
+ <p class="settings-rail__label">{{ t('saasSettings.editor.setupProgress') }}</p>
151
+ <button
152
+ v-for="item in portalOnboardingSteps"
153
+ :key="item"
154
+ type="button"
155
+ :class="['settings-step', { 'settings-step--active': step === item }]"
156
+ :disabled="portalOnboardingSteps.indexOf(item) > stepIndex"
157
+ @click="step = item"
158
+ >
159
+ <span>{{ portalOnboardingSteps.indexOf(item) + 1 }}</span
160
+ >{{ stepLabels[item] }}
161
+ </button>
162
+ </nav>
163
+ <section class="settings-panel">
164
+ <header v-if="props.onboarding">
165
+ <p class="eyebrow">{{ stepIndex + 1 }} / {{ portalOnboardingSteps.length }}</p>
166
+ <h1>{{ stepLabels[step] }}</h1>
167
+ </header>
168
+ <header v-else class="editor-header">
169
+ <div class="editor-title">
170
+ <UIcon name="i-lucide-palette" class="size-6" />
171
+ <h1>{{ t('saasSettings.title') }}</h1>
172
+ </div>
173
+ <nav class="editor-tabs" :aria-label="t('saasSettings.editor.configurationSections')">
174
+ <UButton
175
+ v-for="item in editorSteps"
176
+ :key="item"
177
+ type="button"
178
+ color="neutral"
179
+ :variant="step === item ? 'soft' : 'ghost'"
180
+ @click="step = item"
181
+ >
182
+ {{ stepLabels[item] }}
183
+ </UButton>
184
+ </nav>
185
+ <h2>{{ stepLabels[step] }}</h2>
186
+ </header>
187
+ <UForm
188
+ class="settings-form"
189
+ :state="state"
190
+ :schema="portalSettingsSchema"
191
+ @submit="submitForm"
192
+ @error="showError(t('saasSettings.editor.messages.reviewSettings'))"
193
+ >
194
+ <PortalSettingsBrandingStep v-if="step === 'branding'" v-model="state" @error="showError" />
195
+ <PortalSettingsModulesStep v-else-if="step === 'modules'" v-model="state" />
196
+ <PortalSettingsHomeStep v-else-if="step === 'home'" v-model="state" />
197
+ <PortalSettingsLegalStep v-else-if="step === 'legal'" v-model="state" />
198
+ <PortalSettingsReviewStep v-else :state="state" />
199
+ <footer class="settings-actions">
200
+ <UButton
201
+ v-if="props.onboarding && stepIndex > 0"
202
+ type="button"
203
+ color="neutral"
204
+ variant="outline"
205
+ icon="i-lucide-arrow-left"
206
+ @click="step = portalOnboardingSteps[stepIndex - 1]!"
207
+ >
208
+ {{ t('saasSettings.editor.actions.back') }} </UButton
209
+ ><span /><UButton v-if="!props.onboarding" type="submit" icon="i-lucide-save" :loading="busy">
210
+ {{ t('saasSettings.editor.actions.save') }} </UButton
211
+ ><UButton
212
+ v-else-if="step !== 'review'"
213
+ type="submit"
214
+ :loading="busy"
215
+ trailing-icon="i-lucide-arrow-right"
216
+ >
217
+ {{ t('saasSettings.editor.actions.saveContinue') }} </UButton
218
+ ><UButton v-else type="submit" :loading="busy" icon="i-lucide-check">
219
+ {{ t('saasSettings.editor.actions.complete') }}
220
+ </UButton>
221
+ </footer>
222
+ </UForm>
223
+ </section>
224
+ </template>
225
+ </div>
226
+ </div>
227
+ </template>
228
+
229
+ <style scoped>
230
+ .settings-page {
231
+ width: min(100%, 76rem);
232
+ margin: 0 auto;
233
+ }
234
+ .settings-toolbar {
235
+ display: flex;
236
+ justify-content: flex-end;
237
+ padding-bottom: 0.75rem;
238
+ }
239
+ .settings-page .settings-workbench {
240
+ width: 100%;
241
+ margin: 0;
242
+ }
243
+ .settings-workbench--editor {
244
+ display: block;
245
+ min-height: 0;
246
+ border: 0;
247
+ background: transparent;
248
+ }
249
+ .settings-workbench--editor .settings-panel {
250
+ padding: 0;
251
+ }
252
+ .editor-header {
253
+ display: grid;
254
+ gap: 1.5rem;
255
+ margin-bottom: 2rem;
256
+ }
257
+ .editor-header h1 {
258
+ margin-bottom: 0;
259
+ }
260
+ .editor-header h2 {
261
+ font-size: 1.35rem;
262
+ font-weight: 750;
263
+ }
264
+ .editor-tabs {
265
+ display: flex;
266
+ flex-wrap: wrap;
267
+ gap: 0.25rem;
268
+ padding-bottom: 1rem;
269
+ border-bottom: 1px solid var(--ui-border);
270
+ }
271
+ .settings-workbench {
272
+ display: grid;
273
+ grid-template-columns: 15rem minmax(0, 1fr);
274
+ width: min(100%, 76rem);
275
+ min-height: calc(100vh - 8rem);
276
+ margin: 0 auto;
277
+ border: 1px solid var(--ui-border);
278
+ background: var(--ui-bg);
279
+ }
280
+ .settings-rail {
281
+ padding: 1.5rem;
282
+ border-right: 1px solid var(--ui-border);
283
+ background: var(--ui-bg-muted);
284
+ }
285
+ .settings-rail__label,
286
+ .eyebrow {
287
+ text-transform: uppercase;
288
+ letter-spacing: 0.12em;
289
+ font-size: 0.7rem;
290
+ font-weight: 800;
291
+ color: var(--ui-text-muted);
292
+ margin-bottom: 1rem;
293
+ }
294
+ .settings-step {
295
+ display: flex;
296
+ width: 100%;
297
+ gap: 0.75rem;
298
+ align-items: center;
299
+ padding: 0.7rem;
300
+ border-radius: 0.5rem;
301
+ text-align: left;
302
+ color: var(--ui-text-muted);
303
+ }
304
+ .settings-step:hover:not(:disabled),
305
+ .settings-step--active {
306
+ background: var(--ui-bg-accented);
307
+ color: var(--ui-text-highlighted);
308
+ }
309
+ .settings-step:focus-visible {
310
+ outline: 2px solid var(--portal-primary);
311
+ outline-offset: 2px;
312
+ }
313
+ .settings-step:disabled {
314
+ opacity: 0.45;
315
+ }
316
+ .settings-step span {
317
+ display: grid;
318
+ place-items: center;
319
+ width: 1.65rem;
320
+ height: 1.65rem;
321
+ border: 1px solid var(--ui-border);
322
+ border-radius: 999px;
323
+ }
324
+ .settings-panel {
325
+ padding: clamp(1.25rem, 4vw, 3rem);
326
+ min-width: 0;
327
+ }
328
+ .settings-panel h1 {
329
+ font-size: clamp(2rem, 5vw, 3.5rem);
330
+ font-weight: 800;
331
+ overflow-wrap: anywhere;
332
+ margin-bottom: 2rem;
333
+ }
334
+ .settings-form {
335
+ width: 100%;
336
+ min-width: 0;
337
+ }
338
+ .settings-alert {
339
+ margin-top: 1.25rem;
340
+ }
341
+ .settings-actions {
342
+ display: grid;
343
+ grid-template-columns: auto 1fr auto;
344
+ gap: 1rem;
345
+ margin-top: 2rem;
346
+ padding-top: 1.5rem;
347
+ border-top: 1px solid var(--ui-border);
348
+ }
349
+ .settings-state {
350
+ display: flex;
351
+ gap: 0.5rem;
352
+ align-items: center;
353
+ padding: 2rem;
354
+ }
355
+ @media (max-width: 768px) {
356
+ .settings-workbench {
357
+ grid-template-columns: 1fr;
358
+ }
359
+ .settings-rail {
360
+ display: flex;
361
+ overflow-x: auto;
362
+ border-right: 0;
363
+ border-bottom: 1px solid var(--ui-border);
364
+ }
365
+ .settings-rail__label {
366
+ display: none;
367
+ }
368
+ .settings-step {
369
+ min-width: max-content;
370
+ }
371
+ .settings-panel {
372
+ padding: 1rem;
373
+ }
374
+ .settings-actions :deep(button) {
375
+ white-space: nowrap;
376
+ }
377
+ }
378
+ @media (max-width: 414px) {
379
+ .settings-step {
380
+ font-size: 0;
381
+ }
382
+ .settings-step span {
383
+ font-size: 0.8rem;
384
+ }
385
+ .settings-actions {
386
+ grid-template-columns: 1fr;
387
+ }
388
+ .settings-actions span {
389
+ display: none;
390
+ }
391
+ .settings-actions :deep(button) {
392
+ width: 100%;
393
+ }
394
+ }
395
+ .settings-page .settings-workbench.settings-workbench--editor {
396
+ display: block;
397
+ min-height: 0;
398
+ border: 0;
399
+ background: transparent;
400
+ }
401
+ .settings-page .settings-workbench--editor .settings-panel {
402
+ width: 100%;
403
+ padding: 0;
404
+ }
405
+ .settings-page--editor {
406
+ width: 100%;
407
+ max-width: none;
408
+ height: 100%;
409
+ min-height: 0;
410
+ overflow-y: auto;
411
+ overscroll-behavior: contain;
412
+ padding: 1rem;
413
+ }
414
+ .settings-panel .editor-header {
415
+ gap: 1.5rem;
416
+ margin-bottom: 2rem;
417
+ }
418
+ .settings-panel .editor-title {
419
+ display: flex;
420
+ align-items: center;
421
+ gap: 0.5rem;
422
+ }
423
+ .settings-panel .editor-title > svg {
424
+ width: 1.5rem;
425
+ height: 1.5rem;
426
+ }
427
+ .settings-panel .editor-header h1 {
428
+ margin: 0;
429
+ font-size: 1.5rem;
430
+ line-height: 2rem;
431
+ font-weight: 600;
432
+ letter-spacing: normal;
433
+ }
434
+ .settings-panel .editor-header h2 {
435
+ font-size: 1rem;
436
+ line-height: 1.5rem;
437
+ font-weight: 600;
438
+ }
439
+ .settings-page--editor .settings-actions {
440
+ margin-top: 1.5rem;
441
+ }
442
+ @media (min-width: 640px) {
443
+ .settings-page--editor {
444
+ padding: 1.5rem;
445
+ }
446
+ }
447
+ @media (min-width: 1024px) {
448
+ .settings-page--editor {
449
+ padding: 2rem;
450
+ }
451
+ }
452
+ </style>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ const props = withDefaults(defineProps<{ compact?: boolean }>(), { compact: false })
3
+ const settings = useState<{
4
+ branding?: { portalName?: string; markLight?: string; markDark?: string; logoLight?: string; logoDark?: string }
5
+ } | null>('portal-runtime-settings', () => null)
6
+ const colorMode = useColorMode()
7
+ const source = computed(() => {
8
+ const branding = settings.value?.branding
9
+ if (!branding) {
10
+ return ''
11
+ }
12
+ const kind = props.compact ? 'mark' : 'logo'
13
+ return colorMode.value === 'dark'
14
+ ? branding[`${kind}Dark`] || branding[`${kind}Light`] || ''
15
+ : branding[`${kind}Light`] || branding[`${kind}Dark`] || ''
16
+ })
17
+ </script>
18
+
19
+ <template>
20
+ <img
21
+ v-if="source"
22
+ :src="source"
23
+ :alt="compact ? '' : settings?.branding?.portalName"
24
+ :class="compact ? 'size-10 rounded-lg object-contain' : 'h-12 max-w-64 object-contain'"
25
+ /><strong v-else class="text-xl">{{ settings?.branding?.portalName || 'Customer Portal' }}</strong>
26
+ </template>
@@ -0,0 +1,198 @@
1
+ <script setup lang="ts">
2
+ import type { PortalSettings } from '../../../shared/settings'
3
+
4
+ const state = defineModel<PortalSettings>({ required: true })
5
+ const emit = defineEmits<{ error: [message: string] }>()
6
+ const { t } = useI18n()
7
+ type BrandAssetField = 'markLight' | 'markDark' | 'logoLight' | 'logoDark'
8
+ const assetErrors = reactive<Partial<Record<BrandAssetField, string>>>({})
9
+ const assetFields = computed<BrandAssetField[]>(() => {
10
+ if (state.value.appearance.colorMode === 'light-only') {
11
+ return ['markLight', 'logoLight']
12
+ }
13
+ if (state.value.appearance.colorMode === 'dark-only') {
14
+ return ['markDark', 'logoDark']
15
+ }
16
+ return ['markLight', 'markDark', 'logoLight', 'logoDark']
17
+ })
18
+
19
+ function readAsDataUrl(file: File) {
20
+ return new Promise<string>((resolve, reject) => {
21
+ const reader = new FileReader()
22
+ reader.onload = () => resolve(String(reader.result))
23
+ reader.onerror = () => reject(new Error('Could not read image'))
24
+ reader.readAsDataURL(file)
25
+ })
26
+ }
27
+
28
+ function readDimensions(source: string) {
29
+ return new Promise<{ width: number; height: number }>((resolve, reject) => {
30
+ const image = new Image()
31
+ image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight })
32
+ image.onerror = () => reject(new Error('Could not decode image'))
33
+ image.src = source
34
+ })
35
+ }
36
+
37
+ function rejectImage(field: BrandAssetField, message: string) {
38
+ assetErrors[field] = message
39
+ emit('error', message)
40
+ }
41
+
42
+ async function readImage(file: File | null | undefined, field: BrandAssetField) {
43
+ if (!file) {
44
+ return
45
+ }
46
+ assetErrors[field] = undefined
47
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 2_000_000) {
48
+ rejectImage(field, t('saasSettings.editor.messages.invalidImage'))
49
+ return
50
+ }
51
+ try {
52
+ const source = await readAsDataUrl(file)
53
+ const { width, height } = await readDimensions(source)
54
+ if (width > 2400 || height > 2400) {
55
+ rejectImage(field, t('saasSettings.editor.messages.imageTooLarge'))
56
+ return
57
+ }
58
+ if (field.startsWith('mark') && (width < 64 || height < 64)) {
59
+ rejectImage(field, t('saasSettings.editor.messages.markTooSmall'))
60
+ return
61
+ }
62
+ state.value.branding[field] = source
63
+ } catch {
64
+ rejectImage(field, t('saasSettings.editor.messages.invalidImage'))
65
+ }
66
+ }
67
+
68
+ function removeImage(field: BrandAssetField) {
69
+ assetErrors[field] = undefined
70
+ state.value.branding[field] = ''
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <div class="settings-grid">
76
+ <UFormField name="branding.portalName" required :label="t('saasSettings.editor.fields.portalName')">
77
+ <UInput v-model="state.branding.portalName" class="w-full" />
78
+ </UFormField>
79
+ <UFormField name="branding.tagline" :label="t('saasSettings.editor.fields.tagline')">
80
+ <UInput v-model="state.branding.tagline" class="w-full" />
81
+ </UFormField>
82
+ <UFormField name="branding.supportEmail" :label="t('saasSettings.editor.fields.supportEmail')">
83
+ <UInput v-model="state.branding.supportEmail" type="email" class="w-full" />
84
+ </UFormField>
85
+ <UFormField name="branding.supportUrl" :label="t('saasSettings.editor.fields.supportUrl')">
86
+ <UInput v-model="state.branding.supportUrl" class="w-full" />
87
+ </UFormField>
88
+ <h2 class="section-heading">{{ t('saasSettings.editor.appearanceSection') }}</h2>
89
+ <UFormField name="appearance.theme" required :label="t('saasSettings.editor.fields.theme')">
90
+ <USelect
91
+ v-model="state.appearance.theme"
92
+ :items="[
93
+ { label: t('saasSettings.editor.themes.apex'), value: 'apex' },
94
+ { label: t('saasSettings.editor.themes.brutal'), value: 'brutal' }
95
+ ]"
96
+ class="w-full"
97
+ />
98
+ </UFormField>
99
+ <UFormField name="appearance.colorMode" required :label="t('saasSettings.editor.fields.colorMode')">
100
+ <USelect
101
+ v-model="state.appearance.colorMode"
102
+ :items="[
103
+ { label: t('saasSettings.editor.colorModes.userChoice'), value: 'user-choice' },
104
+ { label: t('saasSettings.editor.colorModes.lightOnly'), value: 'light-only' },
105
+ { label: t('saasSettings.editor.colorModes.darkOnly'), value: 'dark-only' }
106
+ ]"
107
+ class="w-full"
108
+ />
109
+ </UFormField>
110
+ <UFormField
111
+ v-if="state.appearance.colorMode !== 'dark-only'"
112
+ name="appearance.primaryLight"
113
+ required
114
+ :label="t('saasSettings.editor.fields.primaryLight')"
115
+ >
116
+ <input v-model="state.appearance.primaryLight" type="color" class="color-input" />
117
+ </UFormField>
118
+ <UFormField
119
+ v-if="state.appearance.colorMode !== 'light-only'"
120
+ name="appearance.primaryDark"
121
+ required
122
+ :label="t('saasSettings.editor.fields.primaryDark')"
123
+ >
124
+ <input v-model="state.appearance.primaryDark" type="color" class="color-input" />
125
+ </UFormField>
126
+ <h2 class="section-heading">{{ t('saasSettings.editor.logoSection') }}</h2>
127
+ <UFormField
128
+ v-for="field in assetFields"
129
+ :key="field"
130
+ :name="`branding.${field}`"
131
+ :label="t(`saasSettings.editor.fields.${field}`)"
132
+ :description="t(`saasSettings.editor.assetDescriptions.${field}`)"
133
+ :error="assetErrors[field]"
134
+ >
135
+ <div class="asset-field">
136
+ <img v-if="state.branding[field]" :src="state.branding[field]" alt="" /><UFileUpload
137
+ variant="button"
138
+ accept="image/png,image/jpeg,image/webp"
139
+ reset
140
+ :label="t('saasSettings.editor.actions.chooseImage')"
141
+ @update:model-value="readImage($event, field)"
142
+ /><UButton
143
+ v-if="state.branding[field]"
144
+ type="button"
145
+ color="neutral"
146
+ variant="ghost"
147
+ size="xs"
148
+ @click="removeImage(field)"
149
+ >
150
+ {{ t('saasSettings.editor.actions.remove') }}
151
+ </UButton>
152
+ </div>
153
+ </UFormField>
154
+ </div>
155
+ </template>
156
+
157
+ <style scoped>
158
+ .settings-grid {
159
+ display: grid;
160
+ grid-template-columns: repeat(2, minmax(0, 1fr));
161
+ gap: 1.25rem;
162
+ }
163
+ .section-heading {
164
+ grid-column: 1/-1;
165
+ margin-top: 0.75rem;
166
+ padding-top: 1.5rem;
167
+ border-top: 1px solid var(--ui-border);
168
+ font-size: 1rem;
169
+ font-weight: 700;
170
+ }
171
+ .asset-field {
172
+ display: flex;
173
+ flex-wrap: wrap;
174
+ align-items: center;
175
+ gap: 0.75rem;
176
+ min-height: 4rem;
177
+ }
178
+ .asset-field img {
179
+ width: 4rem;
180
+ height: 4rem;
181
+ object-fit: contain;
182
+ border: 1px solid var(--ui-border);
183
+ border-radius: 0.5rem;
184
+ }
185
+ .color-input {
186
+ width: 100%;
187
+ height: 2.75rem;
188
+ border: 1px solid var(--ui-border);
189
+ border-radius: 0.5rem;
190
+ padding: 0.25rem;
191
+ background: var(--ui-bg);
192
+ }
193
+ @media (max-width: 768px) {
194
+ .settings-grid {
195
+ grid-template-columns: 1fr;
196
+ }
197
+ }
198
+ </style>