@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/app/assets/css/main.css +439 -0
- package/app/assets/css/themes/brutal.css +453 -0
- package/app/assets/css/tokens.css +136 -0
- package/app/components/PortalSettingsEditor.vue +452 -0
- package/app/components/PublicPortalLogo.vue +26 -0
- package/app/components/portal-settings/BrandingStep.vue +198 -0
- package/app/components/portal-settings/HomeStep.vue +116 -0
- package/app/components/portal-settings/LegalStep.vue +44 -0
- package/app/components/portal-settings/ModulesStep.vue +88 -0
- package/app/components/portal-settings/ReviewStep.vue +54 -0
- package/app/composables/usePortalSettings.ts +25 -0
- package/app/middleware/00-portal-onboarding.global.ts +52 -0
- package/app/pages/admin/portal-settings.vue +6 -0
- package/app/pages/index.vue +170 -0
- package/app/pages/onboarding.vue +8 -0
- package/app/pages/privacy.vue +46 -0
- package/app/pages/terms.vue +46 -0
- package/app/plugins/00-portal-settings.ts +26 -0
- package/app/plugins/saas-configuration-feature.ts +3 -0
- package/i18n/locales/en.json +101 -0
- package/i18n/locales/nl.json +105 -0
- package/migrations/0000_portal_settings.sql +9 -0
- package/nuxt.config.ts +15 -0
- package/package.json +50 -0
- package/portal.manifest.mjs +7 -0
- package/server/api/admin/portal-settings/complete.post.ts +5 -0
- package/server/api/admin/portal-settings/index.get.ts +4 -0
- package/server/api/admin/portal-settings/index.put.ts +5 -0
- package/server/api/portal/bootstrap.get.ts +1 -0
- package/server/api/portal/public.get.ts +10 -0
- package/server/middleware/01-portal-bootstrap-gate.ts +50 -0
- package/server/utils/portal-settings.ts +138 -0
- package/shared/feature.ts +30 -0
- package/shared/primary-contrast.ts +12 -0
- package/shared/settings.ts +146 -0
- 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,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>
|