@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,146 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const portalModuleIds = ['timesheets', 'invoices', 'service-requests', 'invoice-timesheets'] as const
|
|
4
|
+
export type PortalModuleId = (typeof portalModuleIds)[number]
|
|
5
|
+
export const portalThemeNames = ['apex', 'brutal'] as const
|
|
6
|
+
export type PortalThemeName = (typeof portalThemeNames)[number]
|
|
7
|
+
export const portalColorModePolicies = ['light-only', 'dark-only', 'user-choice'] as const
|
|
8
|
+
export type PortalColorModePolicy = (typeof portalColorModePolicies)[number]
|
|
9
|
+
export const portalOnboardingSteps = ['branding', 'modules', 'home', 'legal', 'review'] as const
|
|
10
|
+
export type PortalOnboardingStep = (typeof portalOnboardingSteps)[number]
|
|
11
|
+
|
|
12
|
+
const text = (maximum: number) => z.string().trim().max(maximum)
|
|
13
|
+
const color = z.string().regex(/^#[0-9a-f]{6}$/i, 'Use a six-digit hexadecimal color')
|
|
14
|
+
const image = z
|
|
15
|
+
.string()
|
|
16
|
+
.max(2_800_000)
|
|
17
|
+
.refine(
|
|
18
|
+
(value) => !value || /^data:image\/(png|jpeg|webp);base64,[a-z0-9+/=]+$/i.test(value),
|
|
19
|
+
'Use a PNG, JPEG, or WebP image'
|
|
20
|
+
)
|
|
21
|
+
const link = z
|
|
22
|
+
.string()
|
|
23
|
+
.trim()
|
|
24
|
+
.max(500)
|
|
25
|
+
.refine((value) => !value || value.startsWith('/') || z.url().safeParse(value).success, 'Use a valid URL or path')
|
|
26
|
+
|
|
27
|
+
export const portalBrandingSchema = z.object({
|
|
28
|
+
portalName: text(100).min(2),
|
|
29
|
+
tagline: text(160),
|
|
30
|
+
supportEmail: z.email().or(z.literal('')),
|
|
31
|
+
supportUrl: link,
|
|
32
|
+
markLight: image,
|
|
33
|
+
markDark: image,
|
|
34
|
+
logoLight: image,
|
|
35
|
+
logoDark: image
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const featureSchema = z.object({ title: text(120), description: text(500), visible: z.boolean() })
|
|
39
|
+
const homeSchema = z.object({
|
|
40
|
+
heroTitle: text(160),
|
|
41
|
+
heroDescription: text(1000),
|
|
42
|
+
heroActionLabel: text(80),
|
|
43
|
+
heroActionUrl: link,
|
|
44
|
+
introductionTitle: text(160),
|
|
45
|
+
introductionBody: text(4000),
|
|
46
|
+
features: z.array(featureSchema).max(6),
|
|
47
|
+
supportTitle: text(160),
|
|
48
|
+
supportBody: text(2000),
|
|
49
|
+
supportVisible: z.boolean()
|
|
50
|
+
})
|
|
51
|
+
const legalSchema = z.object({ title: text(160), body: text(30000) })
|
|
52
|
+
const localizedContentSchema = z.object({ home: homeSchema, terms: legalSchema, privacy: legalSchema })
|
|
53
|
+
|
|
54
|
+
export const portalSettingsSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
branding: portalBrandingSchema,
|
|
57
|
+
appearance: z.object({
|
|
58
|
+
theme: z.enum(portalThemeNames),
|
|
59
|
+
colorMode: z.enum(portalColorModePolicies),
|
|
60
|
+
primaryLight: color,
|
|
61
|
+
primaryDark: color
|
|
62
|
+
}),
|
|
63
|
+
enabledModules: z
|
|
64
|
+
.array(z.enum(portalModuleIds))
|
|
65
|
+
.min(1)
|
|
66
|
+
.transform((values) => [...new Set(values)]),
|
|
67
|
+
content: z.object({ en: localizedContentSchema, nl: localizedContentSchema })
|
|
68
|
+
})
|
|
69
|
+
.superRefine((value, context) => {
|
|
70
|
+
if (
|
|
71
|
+
value.enabledModules.includes('invoice-timesheets') &&
|
|
72
|
+
(!value.enabledModules.includes('timesheets') || !value.enabledModules.includes('invoices'))
|
|
73
|
+
) {
|
|
74
|
+
context.addIssue({
|
|
75
|
+
code: 'custom',
|
|
76
|
+
path: ['enabledModules'],
|
|
77
|
+
message: 'Invoice from timesheets requires both Timesheets and Invoices'
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
export type PortalBranding = z.infer<typeof portalBrandingSchema>
|
|
83
|
+
export type PortalContent = z.infer<typeof localizedContentSchema>
|
|
84
|
+
export type PortalSettings = z.infer<typeof portalSettingsSchema>
|
|
85
|
+
export interface PortalOnboardingState {
|
|
86
|
+
adminExists: boolean
|
|
87
|
+
completed: boolean
|
|
88
|
+
step: PortalOnboardingStep
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const defaultLocaleContent = (locale: 'en' | 'nl'): PortalContent =>
|
|
92
|
+
locale === 'nl'
|
|
93
|
+
? {
|
|
94
|
+
home: {
|
|
95
|
+
heroTitle: 'Uw klantenportaal',
|
|
96
|
+
heroDescription: 'Eén veilige plek voor samenwerking met uw klanten.',
|
|
97
|
+
heroActionLabel: 'Inloggen',
|
|
98
|
+
heroActionUrl: '/login',
|
|
99
|
+
introductionTitle: 'Welkom',
|
|
100
|
+
introductionBody: 'Configureer deze pagina in de portaalinstellingen.',
|
|
101
|
+
features: [],
|
|
102
|
+
supportTitle: 'Ondersteuning',
|
|
103
|
+
supportBody: 'Neem contact met ons op wanneer u hulp nodig heeft.',
|
|
104
|
+
supportVisible: true
|
|
105
|
+
},
|
|
106
|
+
terms: { title: 'Algemene voorwaarden', body: 'Voeg hier uw algemene voorwaarden toe.' },
|
|
107
|
+
privacy: { title: 'Privacybeleid', body: 'Voeg hier uw privacybeleid toe.' }
|
|
108
|
+
}
|
|
109
|
+
: {
|
|
110
|
+
home: {
|
|
111
|
+
heroTitle: 'Your customer portal',
|
|
112
|
+
heroDescription: 'One secure place to collaborate with your customers.',
|
|
113
|
+
heroActionLabel: 'Sign in',
|
|
114
|
+
heroActionUrl: '/login',
|
|
115
|
+
introductionTitle: 'Welcome',
|
|
116
|
+
introductionBody: 'Configure this page in the portal settings.',
|
|
117
|
+
features: [],
|
|
118
|
+
supportTitle: 'Support',
|
|
119
|
+
supportBody: 'Contact us whenever you need help.',
|
|
120
|
+
supportVisible: true
|
|
121
|
+
},
|
|
122
|
+
terms: { title: 'Terms of service', body: 'Add your terms of service here.' },
|
|
123
|
+
privacy: { title: 'Privacy policy', body: 'Add your privacy policy here.' }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const defaultPortalSettings = (name = 'Customer Portal'): PortalSettings => ({
|
|
127
|
+
branding: {
|
|
128
|
+
portalName: name,
|
|
129
|
+
tagline: 'Customer workspace',
|
|
130
|
+
supportEmail: '',
|
|
131
|
+
supportUrl: '',
|
|
132
|
+
markLight: '',
|
|
133
|
+
markDark: '',
|
|
134
|
+
logoLight: '',
|
|
135
|
+
logoDark: ''
|
|
136
|
+
},
|
|
137
|
+
appearance: { theme: 'apex', colorMode: 'user-choice', primaryLight: '#ea580c', primaryDark: '#fb923c' },
|
|
138
|
+
enabledModules: ['timesheets', 'invoices', 'invoice-timesheets'],
|
|
139
|
+
content: { en: defaultLocaleContent('en'), nl: defaultLocaleContent('nl') }
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
export const resolveBrandAsset = (branding: PortalBranding, kind: 'mark' | 'logo', dark: boolean) => {
|
|
143
|
+
const preferred = branding[`${kind}${dark ? 'Dark' : 'Light'}` as keyof PortalBranding]
|
|
144
|
+
const fallback = branding[`${kind}${dark ? 'Light' : 'Dark'}` as keyof PortalBranding]
|
|
145
|
+
return String(preferred || fallback || '')
|
|
146
|
+
}
|
package/shared/theme.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ThemeProps } from '@nuxt/ui/components/Theme.vue'
|
|
2
|
+
|
|
3
|
+
type ThemeDefaults = NonNullable<ThemeProps['props']>
|
|
4
|
+
type ThemeUI = NonNullable<ThemeProps['ui']>
|
|
5
|
+
|
|
6
|
+
export const portalThemeNames = ['apex', 'brutal'] as const
|
|
7
|
+
|
|
8
|
+
export type PortalThemeName = (typeof portalThemeNames)[number]
|
|
9
|
+
|
|
10
|
+
export interface PortalThemeDefinition {
|
|
11
|
+
name: PortalThemeName
|
|
12
|
+
browserThemeColor: {
|
|
13
|
+
light: string
|
|
14
|
+
dark: string
|
|
15
|
+
}
|
|
16
|
+
props: ThemeDefaults
|
|
17
|
+
ui: ThemeUI
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const apexTheme = {
|
|
21
|
+
name: 'apex',
|
|
22
|
+
browserThemeColor: {
|
|
23
|
+
light: '#ffffff',
|
|
24
|
+
dark: '#0a0f1a'
|
|
25
|
+
},
|
|
26
|
+
props: {},
|
|
27
|
+
ui: {}
|
|
28
|
+
} satisfies PortalThemeDefinition
|
|
29
|
+
|
|
30
|
+
const brutalTheme = {
|
|
31
|
+
name: 'brutal',
|
|
32
|
+
browserThemeColor: {
|
|
33
|
+
light: 'oklch(96.5% 0.015 85)',
|
|
34
|
+
dark: 'oklch(14.5% 0.014 25)'
|
|
35
|
+
},
|
|
36
|
+
props: {},
|
|
37
|
+
ui: {
|
|
38
|
+
alert: {
|
|
39
|
+
root: 'brutal-alert'
|
|
40
|
+
},
|
|
41
|
+
authForm: {
|
|
42
|
+
root: 'brutal-auth-form'
|
|
43
|
+
},
|
|
44
|
+
badge: {
|
|
45
|
+
base: 'brutal-badge'
|
|
46
|
+
},
|
|
47
|
+
button: {
|
|
48
|
+
base: 'brutal-button'
|
|
49
|
+
},
|
|
50
|
+
card: {
|
|
51
|
+
root: 'brutal-card',
|
|
52
|
+
header: 'brutal-card-header',
|
|
53
|
+
footer: 'brutal-card-footer'
|
|
54
|
+
},
|
|
55
|
+
dashboardNavbar: {
|
|
56
|
+
root: 'brutal-dashboard-navbar'
|
|
57
|
+
},
|
|
58
|
+
dashboardPanel: {
|
|
59
|
+
root: 'brutal-dashboard-panel'
|
|
60
|
+
},
|
|
61
|
+
dashboardSidebar: {
|
|
62
|
+
root: 'brutal-dashboard-sidebar'
|
|
63
|
+
},
|
|
64
|
+
dropdownMenu: {
|
|
65
|
+
content: 'brutal-overlay-surface',
|
|
66
|
+
item: 'brutal-menu-item'
|
|
67
|
+
},
|
|
68
|
+
input: {
|
|
69
|
+
base: 'brutal-input'
|
|
70
|
+
},
|
|
71
|
+
modal: {
|
|
72
|
+
content: 'brutal-overlay-surface',
|
|
73
|
+
overlay: 'brutal-overlay'
|
|
74
|
+
},
|
|
75
|
+
navigationMenu: {
|
|
76
|
+
link: 'brutal-navigation-link'
|
|
77
|
+
},
|
|
78
|
+
pageCard: {
|
|
79
|
+
root: 'brutal-card'
|
|
80
|
+
},
|
|
81
|
+
popover: {
|
|
82
|
+
content: 'brutal-overlay-surface'
|
|
83
|
+
},
|
|
84
|
+
select: {
|
|
85
|
+
base: 'brutal-input',
|
|
86
|
+
content: 'brutal-overlay-surface',
|
|
87
|
+
item: 'brutal-menu-item'
|
|
88
|
+
},
|
|
89
|
+
selectMenu: {
|
|
90
|
+
base: 'brutal-input',
|
|
91
|
+
content: 'brutal-overlay-surface',
|
|
92
|
+
item: 'brutal-menu-item'
|
|
93
|
+
},
|
|
94
|
+
skeleton: {
|
|
95
|
+
base: 'brutal-skeleton'
|
|
96
|
+
},
|
|
97
|
+
slideover: {
|
|
98
|
+
content: 'brutal-overlay-surface',
|
|
99
|
+
overlay: 'brutal-overlay'
|
|
100
|
+
},
|
|
101
|
+
table: {
|
|
102
|
+
root: 'brutal-table',
|
|
103
|
+
thead: 'brutal-table-head',
|
|
104
|
+
th: 'brutal-table-heading',
|
|
105
|
+
tr: 'brutal-table-row'
|
|
106
|
+
},
|
|
107
|
+
tabs: {
|
|
108
|
+
list: 'brutal-tabs-list',
|
|
109
|
+
trigger: 'brutal-tabs-trigger',
|
|
110
|
+
indicator: 'brutal-tabs-indicator'
|
|
111
|
+
},
|
|
112
|
+
textarea: {
|
|
113
|
+
base: 'brutal-input'
|
|
114
|
+
},
|
|
115
|
+
toast: {
|
|
116
|
+
root: 'brutal-toast'
|
|
117
|
+
},
|
|
118
|
+
tooltip: {
|
|
119
|
+
content: 'brutal-tooltip'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} satisfies PortalThemeDefinition
|
|
123
|
+
|
|
124
|
+
export const portalThemes: Record<PortalThemeName, PortalThemeDefinition> = {
|
|
125
|
+
apex: apexTheme,
|
|
126
|
+
brutal: brutalTheme
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const isPortalThemeName = (value: unknown): value is PortalThemeName =>
|
|
130
|
+
typeof value === 'string' && portalThemeNames.includes(value as PortalThemeName)
|
|
131
|
+
|
|
132
|
+
export const resolvePortalThemeName = (value: unknown): PortalThemeName => (isPortalThemeName(value) ? value : 'apex')
|
|
133
|
+
|
|
134
|
+
export const resolvePortalTheme = (value: unknown): PortalThemeDefinition => portalThemes[resolvePortalThemeName(value)]
|