@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,116 @@
1
+ <script setup lang="ts">
2
+ import type { PortalSettings } from '../../../shared/settings'
3
+
4
+ const state = defineModel<PortalSettings>({ required: true })
5
+ const locale = ref<'en' | 'nl'>('en')
6
+ const { t } = useI18n()
7
+ const section = computed(() => state.value.content[locale.value])
8
+ function addFeature() {
9
+ section.value.home.features.push({ title: '', description: '', visible: true })
10
+ }
11
+ </script>
12
+
13
+ <template>
14
+ <div class="content-editor">
15
+ <div class="locale-tabs">
16
+ <UButton
17
+ v-for="code in ['en', 'nl'] as const"
18
+ :key="code"
19
+ type="button"
20
+ :variant="locale === code ? 'solid' : 'outline'"
21
+ @click="locale = code"
22
+ >
23
+ {{ code.toUpperCase() }}
24
+ </UButton>
25
+ </div>
26
+ <UFormField :name="`content.${locale}.home.heroTitle`" :label="t('saasSettings.editor.fields.heroTitle')">
27
+ <UInput v-model="section.home.heroTitle" class="w-full" />
28
+ </UFormField>
29
+ <UFormField
30
+ :name="`content.${locale}.home.heroDescription`"
31
+ :label="t('saasSettings.editor.fields.heroDescription')"
32
+ >
33
+ <UTextarea v-model="section.home.heroDescription" :rows="4" class="w-full" />
34
+ </UFormField>
35
+ <div class="settings-grid">
36
+ <UFormField :name="`content.${locale}.home.heroActionLabel`" :label="t('saasSettings.editor.fields.actionLabel')">
37
+ <UInput v-model="section.home.heroActionLabel" class="w-full" /> </UFormField
38
+ ><UFormField :name="`content.${locale}.home.heroActionUrl`" :label="t('saasSettings.editor.fields.actionUrl')">
39
+ <UInput v-model="section.home.heroActionUrl" class="w-full" />
40
+ </UFormField>
41
+ </div>
42
+ <UFormField
43
+ :name="`content.${locale}.home.introductionTitle`"
44
+ :label="t('saasSettings.editor.fields.introductionTitle')"
45
+ >
46
+ <UInput v-model="section.home.introductionTitle" class="w-full" />
47
+ </UFormField>
48
+ <UFormField :name="`content.${locale}.home.introductionBody`" :label="t('saasSettings.editor.fields.introduction')">
49
+ <UTextarea v-model="section.home.introductionBody" :rows="5" class="w-full" />
50
+ </UFormField>
51
+ <div v-for="(feature, index) in section.home.features" :key="index" class="feature-editor">
52
+ <UFormField :name="`content.${locale}.home.features.${index}.title`">
53
+ <UInput v-model="feature.title" :placeholder="t('saasSettings.editor.featureTitle')" /> </UFormField
54
+ ><UFormField :name="`content.${locale}.home.features.${index}.description`">
55
+ <UTextarea
56
+ v-model="feature.description"
57
+ :placeholder="t('saasSettings.editor.featureDescription')"
58
+ /> </UFormField
59
+ ><USwitch v-model="feature.visible" /><UButton
60
+ type="button"
61
+ icon="i-lucide-trash-2"
62
+ color="neutral"
63
+ variant="ghost"
64
+ @click="section.home.features.splice(index, 1)"
65
+ />
66
+ </div>
67
+ <UButton
68
+ v-if="section.home.features.length < 6"
69
+ type="button"
70
+ icon="i-lucide-plus"
71
+ color="neutral"
72
+ variant="outline"
73
+ @click="addFeature"
74
+ >
75
+ {{ t('saasSettings.editor.actions.addFeature') }}
76
+ </UButton>
77
+ <UFormField :name="`content.${locale}.home.supportTitle`" :label="t('saasSettings.editor.fields.supportTitle')">
78
+ <UInput v-model="section.home.supportTitle" class="w-full" />
79
+ </UFormField>
80
+ <UFormField :name="`content.${locale}.home.supportBody`" :label="t('saasSettings.editor.fields.supportText')">
81
+ <UTextarea v-model="section.home.supportBody" class="w-full" />
82
+ </UFormField>
83
+ <UCheckbox v-model="section.home.supportVisible" :label="t('saasSettings.editor.showSupport')" />
84
+ </div>
85
+ </template>
86
+
87
+ <style scoped>
88
+ .content-editor {
89
+ display: grid;
90
+ gap: 1.25rem;
91
+ }
92
+ .locale-tabs {
93
+ display: flex;
94
+ gap: 0.5rem;
95
+ }
96
+ .settings-grid {
97
+ display: grid;
98
+ grid-template-columns: repeat(2, minmax(0, 1fr));
99
+ gap: 1.25rem;
100
+ }
101
+ .feature-editor {
102
+ display: grid;
103
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto auto;
104
+ gap: 0.75rem;
105
+ align-items: center;
106
+ padding: 1rem;
107
+ background: var(--ui-bg-muted);
108
+ border-radius: 0.65rem;
109
+ }
110
+ @media (max-width: 768px) {
111
+ .settings-grid,
112
+ .feature-editor {
113
+ grid-template-columns: 1fr;
114
+ }
115
+ }
116
+ </style>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import type { PortalSettings } from '../../../shared/settings'
3
+
4
+ const state = defineModel<PortalSettings>({ required: true })
5
+ const locale = ref<'en' | 'nl'>('en')
6
+ const { t } = useI18n()
7
+ const section = computed(() => state.value.content[locale.value])
8
+ </script>
9
+
10
+ <template>
11
+ <div class="content-editor">
12
+ <div class="locale-tabs">
13
+ <UButton
14
+ v-for="code in ['en', 'nl'] as const"
15
+ :key="code"
16
+ type="button"
17
+ :variant="locale === code ? 'solid' : 'outline'"
18
+ @click="locale = code"
19
+ >
20
+ {{ code.toUpperCase() }}
21
+ </UButton>
22
+ </div>
23
+ <UFormField :name="`content.${locale}.terms.title`" :label="t('saasSettings.editor.fields.termsTitle')">
24
+ <UInput v-model="section.terms.title" class="w-full" /> </UFormField
25
+ ><UFormField :name="`content.${locale}.terms.body`" :label="t('saasSettings.editor.fields.termsText')">
26
+ <UTextarea v-model="section.terms.body" :rows="12" class="w-full" /> </UFormField
27
+ ><UFormField :name="`content.${locale}.privacy.title`" :label="t('saasSettings.editor.fields.privacyTitle')">
28
+ <UInput v-model="section.privacy.title" class="w-full" /> </UFormField
29
+ ><UFormField :name="`content.${locale}.privacy.body`" :label="t('saasSettings.editor.fields.privacyText')">
30
+ <UTextarea v-model="section.privacy.body" :rows="12" class="w-full" />
31
+ </UFormField>
32
+ </div>
33
+ </template>
34
+
35
+ <style scoped>
36
+ .content-editor {
37
+ display: grid;
38
+ gap: 1.25rem;
39
+ }
40
+ .locale-tabs {
41
+ display: flex;
42
+ gap: 0.5rem;
43
+ }
44
+ </style>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import type { PortalModuleId, PortalSettings } from '../../../shared/settings'
3
+ import { portalModuleIds } from '../../../shared/settings'
4
+
5
+ const state = defineModel<PortalSettings>({ required: true })
6
+ const { t } = useI18n()
7
+ const modules = computed<Record<PortalModuleId, { label: string; description: string; icon: string }>>(() => ({
8
+ timesheets: {
9
+ label: t('saasSettings.editor.modules.timesheets'),
10
+ description: t('saasSettings.editor.modules.timesheetsDescription'),
11
+ icon: 'i-lucide-clock-3'
12
+ },
13
+ invoices: {
14
+ label: t('saasSettings.editor.modules.invoices'),
15
+ description: t('saasSettings.editor.modules.invoicesDescription'),
16
+ icon: 'i-lucide-receipt-text'
17
+ },
18
+ 'service-requests': {
19
+ label: t('saasSettings.editor.modules.serviceRequests'),
20
+ description: t('saasSettings.editor.modules.serviceRequestsDescription'),
21
+ icon: 'i-lucide-messages-square'
22
+ },
23
+ 'invoice-timesheets': {
24
+ label: t('saasSettings.editor.modules.invoiceTimesheets'),
25
+ description: t('saasSettings.editor.modules.invoiceTimesheetsDescription'),
26
+ icon: 'i-lucide-workflow'
27
+ }
28
+ }))
29
+ function toggle(moduleId: PortalModuleId, enabled: boolean) {
30
+ state.value.enabledModules = enabled
31
+ ? [...new Set([...state.value.enabledModules, moduleId])]
32
+ : state.value.enabledModules.filter((value) => value !== moduleId)
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <UFormField name="enabledModules">
38
+ <div class="module-list">
39
+ <label v-for="moduleId in portalModuleIds" :key="moduleId" class="module-option"
40
+ ><UIcon :name="modules[moduleId].icon" class="module-option__icon" /><span
41
+ ><strong>{{ modules[moduleId].label }}</strong
42
+ ><small>{{ modules[moduleId].description }}</small
43
+ ><small class="module-option__preserved">{{ t('saasSettings.editor.modules.preserved') }}</small></span
44
+ ><USwitch :model-value="state.enabledModules.includes(moduleId)" @update:model-value="toggle(moduleId, $event)"
45
+ /></label>
46
+ </div>
47
+ </UFormField>
48
+ </template>
49
+
50
+ <style scoped>
51
+ .module-list {
52
+ display: grid;
53
+ gap: 1.25rem;
54
+ }
55
+ .module-option {
56
+ display: grid;
57
+ grid-template-columns: auto minmax(0, 1fr) auto;
58
+ align-items: center;
59
+ gap: 1rem;
60
+ padding: 1rem;
61
+ border: 1px solid var(--ui-border);
62
+ border-radius: 0.65rem;
63
+ }
64
+ .module-option__icon {
65
+ width: 1.5rem;
66
+ height: 1.5rem;
67
+ color: var(--ui-primary);
68
+ }
69
+ .module-option span {
70
+ display: grid;
71
+ gap: 0.2rem;
72
+ }
73
+ .module-option small {
74
+ color: var(--ui-text-muted);
75
+ }
76
+ .module-option__preserved {
77
+ font-size: 0.7rem;
78
+ }
79
+ @media (max-width: 480px) {
80
+ .module-option {
81
+ grid-template-columns: auto minmax(0, 1fr);
82
+ }
83
+ .module-option :deep(button) {
84
+ grid-column: 2;
85
+ justify-self: start;
86
+ }
87
+ }
88
+ </style>
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ import type { PortalSettings } from '../../../shared/settings'
3
+
4
+ defineProps<{ state: PortalSettings }>()
5
+ const { t } = useI18n()
6
+ </script>
7
+
8
+ <template>
9
+ <div class="review-grid">
10
+ <div>
11
+ <span>{{ t('saasSettings.editor.review.portal') }}</span
12
+ ><strong>{{ state.branding.portalName }}</strong>
13
+ </div>
14
+ <div>
15
+ <span>{{ t('saasSettings.editor.review.theme') }}</span
16
+ ><strong>{{ state.appearance.theme }}</strong>
17
+ </div>
18
+ <div>
19
+ <span>{{ t('saasSettings.editor.review.colorMode') }}</span
20
+ ><strong>{{ state.appearance.colorMode }}</strong>
21
+ </div>
22
+ <div>
23
+ <span>{{ t('saasSettings.editor.review.activeModules') }}</span
24
+ ><strong>{{ state.enabledModules.length }}</strong>
25
+ </div>
26
+ <p>{{ t('saasSettings.editor.review.description') }}</p>
27
+ </div>
28
+ </template>
29
+
30
+ <style scoped>
31
+ .review-grid {
32
+ display: grid;
33
+ grid-template-columns: repeat(2, minmax(0, 1fr));
34
+ gap: 1rem;
35
+ }
36
+ .review-grid div {
37
+ display: grid;
38
+ padding: 1rem;
39
+ border: 1px solid var(--ui-border);
40
+ border-radius: 0.65rem;
41
+ }
42
+ .review-grid span {
43
+ color: var(--ui-text-muted);
44
+ font-size: 0.8rem;
45
+ }
46
+ .review-grid p {
47
+ grid-column: 1/-1;
48
+ }
49
+ @media (max-width: 768px) {
50
+ .review-grid {
51
+ grid-template-columns: 1fr;
52
+ }
53
+ }
54
+ </style>
@@ -0,0 +1,25 @@
1
+ import type { PortalOnboardingState, PortalSettings } from '../../shared/settings'
2
+
3
+ export interface PublicPortalSettings extends Pick<
4
+ PortalSettings,
5
+ 'branding' | 'appearance' | 'enabledModules' | 'content'
6
+ > {
7
+ completed: boolean
8
+ }
9
+
10
+ export const usePortalSettings = () => {
11
+ const settings = useState<PublicPortalSettings | null>('portal-runtime-settings', () => null)
12
+ const enabledModules = useState<string[] | null>('portal-enabled-modules', () => null)
13
+ const bootstrap = useState<PortalOnboardingState | null>('portal-bootstrap-state', () => null)
14
+
15
+ const refreshPublicSettings = async () => {
16
+ settings.value = await $fetch<PublicPortalSettings>('/api/portal/public')
17
+ enabledModules.value = settings.value.enabledModules
18
+ return settings.value
19
+ }
20
+ const refreshBootstrap = async () => {
21
+ bootstrap.value = await $fetch<PortalOnboardingState>('/api/portal/bootstrap')
22
+ return bootstrap.value
23
+ }
24
+ return { settings, bootstrap, enabledModules, refreshPublicSettings, refreshBootstrap }
25
+ }
@@ -0,0 +1,52 @@
1
+ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
2
+
3
+ export default defineNuxtRouteMiddleware(async (to) => {
4
+ const { bootstrap, refreshBootstrap } = usePortalSettings()
5
+ if (!bootstrap.value) {
6
+ await refreshBootstrap().catch(() => null)
7
+ }
8
+ const state = bootstrap.value
9
+ if (!state) {
10
+ return
11
+ }
12
+
13
+ const bootstrapRoutes = ['/signup', '/verify-email', '/login', '/forgot-password', '/reset-password']
14
+ const publicConfiguredRoutes = ['/', '/terms', '/privacy', '/contact']
15
+
16
+ if (!state.adminExists) {
17
+ if (['/signup', '/verify-email'].includes(to.path)) {
18
+ return
19
+ }
20
+ return navigateTo('/signup', { replace: true })
21
+ }
22
+
23
+ if (!state.completed) {
24
+ const { data: session } = await authClient.useSession(useFetch)
25
+ if (to.path === '/onboarding' || bootstrapRoutes.includes(to.path)) {
26
+ return
27
+ }
28
+ if (session.value?.user?.role === 'admin') {
29
+ return navigateTo('/onboarding', { replace: true })
30
+ }
31
+ return navigateTo('/login', { replace: true })
32
+ }
33
+
34
+ if (to.path === '/onboarding') {
35
+ return navigateTo('/admin/portal-settings', { replace: true })
36
+ }
37
+ if (publicConfiguredRoutes.includes(to.path)) {
38
+ return
39
+ }
40
+
41
+ const settings = useState<{ enabledModules?: string[] } | null>('portal-runtime-settings')
42
+ const routeModules: Record<string, string[]> = {
43
+ timesheets: ['/timesheets', '/admin/timesheets'],
44
+ invoices: ['/invoices', '/admin/invoices'],
45
+ 'service-requests': ['/requests', '/admin/requests']
46
+ }
47
+ for (const [moduleId, prefixes] of Object.entries(routeModules)) {
48
+ if (!settings.value?.enabledModules?.includes(moduleId) && prefixes.some((prefix) => to.path.startsWith(prefix))) {
49
+ return navigateTo('/dashboard', { replace: true })
50
+ }
51
+ }
52
+ })
@@ -0,0 +1,6 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n()
3
+ useSeoMeta({ title: () => t('saasSettings.title') })
4
+ </script>
5
+
6
+ <template><PortalSettingsEditor /></template>
@@ -0,0 +1,170 @@
1
+ <!-- eslint-disable vue/multi-word-component-names -->
2
+ <script setup lang="ts">
3
+ import type { PublicPortalSettings } from '../composables/usePortalSettings'
4
+
5
+ definePageMeta({ layout: 'portal', public: true })
6
+ const { locale, t } = useI18n()
7
+ const { isAuthenticated } = usePortalSession()
8
+ const settings = useState<PublicPortalSettings | null>('portal-runtime-settings')
9
+ const content = computed(() => settings.value?.content?.[locale.value as 'en' | 'nl'] || settings.value?.content?.en)
10
+ const heroActionLabel = computed(() =>
11
+ isAuthenticated.value ? t('saasSettings.public.myDashboard') : content.value?.home.heroActionLabel
12
+ )
13
+ const heroActionUrl = computed(() =>
14
+ isAuthenticated.value ? '/dashboard' : content.value?.home.heroActionUrl || '/login'
15
+ )
16
+ useSeoMeta({ title: () => content.value?.home.heroTitle || settings.value?.branding?.portalName || 'Customer Portal' })
17
+ </script>
18
+
19
+ <template>
20
+ <main v-if="content" class="public-home">
21
+ <section class="public-hero">
22
+ <div>
23
+ <p class="eyebrow">{{ settings?.branding?.tagline }}</p>
24
+ <h1>{{ content.home.heroTitle }}</h1>
25
+ <p>{{ content.home.heroDescription }}</p>
26
+ <UButton v-if="heroActionLabel" :to="heroActionUrl" size="xl" trailing-icon="i-lucide-arrow-right">
27
+ {{ heroActionLabel }}
28
+ </UButton>
29
+ </div>
30
+ <aside>
31
+ <PublicPortalLogo />
32
+ <p v-if="settings?.branding?.supportEmail">{{ settings.branding.supportEmail }}</p>
33
+ </aside>
34
+ </section>
35
+ <section class="public-intro">
36
+ <h2>{{ content.home.introductionTitle }}</h2>
37
+ <p>{{ content.home.introductionBody }}</p>
38
+ </section>
39
+ <section v-if="content.home.features.some((item: any) => item.visible)" class="public-features">
40
+ <article v-for="(feature, index) in content.home.features.filter((item: any) => item.visible)" :key="index">
41
+ <span>0{{ Number(index) + 1 }}</span>
42
+ <h3>{{ feature.title }}</h3>
43
+ <p>{{ feature.description }}</p>
44
+ </article>
45
+ </section>
46
+ <section v-if="content.home.supportVisible" class="public-support">
47
+ <h2>{{ content.home.supportTitle }}</h2>
48
+ <p>{{ content.home.supportBody }}</p>
49
+ <UButton
50
+ v-if="settings?.branding?.supportUrl"
51
+ :to="settings.branding.supportUrl"
52
+ color="neutral"
53
+ variant="outline"
54
+ >
55
+ Contact support
56
+ </UButton>
57
+ </section>
58
+ </main>
59
+ </template>
60
+
61
+ <style scoped>
62
+ /* Hallmark · genre: modern-minimal · macrostructure: split masthead + numbered ledger · theme: portal-native
63
+ * pre-emit critique: P5 H5 E4 S5 R5 V5 · mobile: pass (34,49–57)
64
+ */
65
+ .public-home {
66
+ padding: clamp(1rem, 4vw, 4rem);
67
+ display: grid;
68
+ gap: clamp(3rem, 8vw, 7rem);
69
+ overflow-x: clip;
70
+ }
71
+ .public-hero {
72
+ min-height: 34rem;
73
+ display: grid;
74
+ grid-template-columns: minmax(0, 1.5fr) minmax(16rem, 0.5fr);
75
+ gap: 3rem;
76
+ align-items: end;
77
+ border-bottom: 1px solid var(--ui-border);
78
+ padding-bottom: 3rem;
79
+ }
80
+ .public-hero h1 {
81
+ font-size: clamp(3rem, 8vw, 7.5rem);
82
+ line-height: 0.92;
83
+ letter-spacing: -0.06em;
84
+ font-weight: 900;
85
+ max-width: 12ch;
86
+ overflow-wrap: anywhere;
87
+ }
88
+ .public-hero p {
89
+ max-width: 45rem;
90
+ font-size: 1.15rem;
91
+ color: var(--ui-text-muted);
92
+ margin: 1.5rem 0;
93
+ }
94
+ .public-hero aside {
95
+ align-self: start;
96
+ display: grid;
97
+ gap: 1rem;
98
+ justify-items: start;
99
+ padding-top: 1rem;
100
+ }
101
+ .eyebrow {
102
+ text-transform: uppercase;
103
+ letter-spacing: 0.14em;
104
+ font-size: 0.75rem;
105
+ font-weight: 800;
106
+ color: var(--portal-primary) !important;
107
+ }
108
+ .public-intro {
109
+ display: grid;
110
+ grid-template-columns: minmax(0, 0.65fr) minmax(0, 1.35fr);
111
+ gap: 3rem;
112
+ }
113
+ .public-intro h2,
114
+ .public-support h2 {
115
+ font-size: clamp(2rem, 5vw, 4rem);
116
+ font-weight: 800;
117
+ overflow-wrap: anywhere;
118
+ }
119
+ .public-intro p,
120
+ .public-support p {
121
+ font-size: 1.2rem;
122
+ white-space: pre-line;
123
+ color: var(--ui-text-muted);
124
+ }
125
+ .public-features {
126
+ display: grid;
127
+ grid-template-columns: repeat(3, minmax(0, 1fr));
128
+ border-top: 1px solid var(--ui-border);
129
+ }
130
+ .public-features article {
131
+ padding: 1.5rem;
132
+ border-right: 1px solid var(--ui-border);
133
+ }
134
+ .public-features span {
135
+ font-variant-numeric: tabular-nums;
136
+ color: var(--portal-primary);
137
+ font-weight: 800;
138
+ }
139
+ .public-features h3 {
140
+ font-size: 1.4rem;
141
+ font-weight: 750;
142
+ margin: 0.75rem 0;
143
+ }
144
+ .public-features p {
145
+ color: var(--ui-text-muted);
146
+ }
147
+ .public-support {
148
+ display: grid;
149
+ gap: 1.25rem;
150
+ max-width: 48rem;
151
+ padding-bottom: 3rem;
152
+ }
153
+ @media (max-width: 768px) {
154
+ .public-hero,
155
+ .public-intro {
156
+ grid-template-columns: 1fr;
157
+ min-height: auto;
158
+ }
159
+ .public-hero {
160
+ align-items: start;
161
+ }
162
+ .public-features {
163
+ grid-template-columns: 1fr;
164
+ }
165
+ .public-features article {
166
+ border-right: 0;
167
+ border-bottom: 1px solid var(--ui-border);
168
+ }
169
+ }
170
+ </style>
@@ -0,0 +1,8 @@
1
+ <!-- eslint-disable vue/multi-word-component-names -->
2
+ <script setup lang="ts">
3
+ definePageMeta({ layout: 'centerform' })
4
+ const { t } = useI18n()
5
+ useSeoMeta({ title: () => t('saasSettings.onboardingTitle') })
6
+ </script>
7
+
8
+ <template><PortalSettingsEditor onboarding /></template>
@@ -0,0 +1,46 @@
1
+ <!-- eslint-disable vue/multi-word-component-names -->
2
+ <script setup lang="ts">
3
+ import type { PublicPortalSettings } from '../composables/usePortalSettings'
4
+
5
+ definePageMeta({ layout: 'portal', public: true })
6
+ const { locale } = useI18n()
7
+ const settings = useState<PublicPortalSettings | null>('portal-runtime-settings')
8
+ const page = computed(
9
+ () => (settings.value?.content?.[locale.value as 'en' | 'nl'] || settings.value?.content?.en)?.privacy
10
+ )
11
+ useSeoMeta({ title: () => page.value?.title || 'Privacy' })
12
+ </script>
13
+
14
+ <template>
15
+ <article v-if="page" class="legal-page">
16
+ <p class="eyebrow">Legal</p>
17
+ <h1>{{ page.title }}</h1>
18
+ <div>{{ page.body }}</div>
19
+ </article>
20
+ </template>
21
+
22
+ <style scoped>
23
+ .legal-page {
24
+ max-width: 52rem;
25
+ margin: 0 auto;
26
+ padding: clamp(2rem, 7vw, 6rem) 1rem;
27
+ }
28
+ .legal-page h1 {
29
+ font-size: clamp(2.5rem, 7vw, 5rem);
30
+ font-weight: 850;
31
+ overflow-wrap: anywhere;
32
+ margin-bottom: 3rem;
33
+ }
34
+ .legal-page div {
35
+ white-space: pre-wrap;
36
+ line-height: 1.75;
37
+ color: var(--ui-text-muted);
38
+ }
39
+ .eyebrow {
40
+ text-transform: uppercase;
41
+ letter-spacing: 0.14em;
42
+ font-size: 0.75rem;
43
+ font-weight: 800;
44
+ color: var(--portal-primary);
45
+ }
46
+ </style>
@@ -0,0 +1,46 @@
1
+ <!-- eslint-disable vue/multi-word-component-names -->
2
+ <script setup lang="ts">
3
+ import type { PublicPortalSettings } from '../composables/usePortalSettings'
4
+
5
+ definePageMeta({ layout: 'portal', public: true })
6
+ const { locale } = useI18n()
7
+ const settings = useState<PublicPortalSettings | null>('portal-runtime-settings')
8
+ const page = computed(
9
+ () => (settings.value?.content?.[locale.value as 'en' | 'nl'] || settings.value?.content?.en)?.terms
10
+ )
11
+ useSeoMeta({ title: () => page.value?.title || 'Terms' })
12
+ </script>
13
+
14
+ <template>
15
+ <article v-if="page" class="legal-page">
16
+ <p class="eyebrow">Legal</p>
17
+ <h1>{{ page.title }}</h1>
18
+ <div>{{ page.body }}</div>
19
+ </article>
20
+ </template>
21
+
22
+ <style scoped>
23
+ .legal-page {
24
+ max-width: 52rem;
25
+ margin: 0 auto;
26
+ padding: clamp(2rem, 7vw, 6rem) 1rem;
27
+ }
28
+ .legal-page h1 {
29
+ font-size: clamp(2.5rem, 7vw, 5rem);
30
+ font-weight: 850;
31
+ overflow-wrap: anywhere;
32
+ margin-bottom: 3rem;
33
+ }
34
+ .legal-page div {
35
+ white-space: pre-wrap;
36
+ line-height: 1.75;
37
+ color: var(--ui-text-muted);
38
+ }
39
+ .eyebrow {
40
+ text-transform: uppercase;
41
+ letter-spacing: 0.14em;
42
+ font-size: 0.75rem;
43
+ font-weight: 800;
44
+ color: var(--portal-primary);
45
+ }
46
+ </style>