@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,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
+ }
@@ -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)]