@woodsportal/hubspot-kit 1.0.4 → 1.0.26

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 (217) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +4 -2
  3. package/dist/{chunk-JDGIAYHX.js → chunk-GTTIJVAR.js} +43 -3
  4. package/dist/chunk-GTTIJVAR.js.map +1 -0
  5. package/dist/cli.js +1400 -338
  6. package/dist/cli.js.map +1 -1
  7. package/dist/exec-U2OJATR3.js +12 -0
  8. package/dist/index.d.ts +20 -0
  9. package/dist/index.js +8 -1
  10. package/dist/index.js.map +1 -1
  11. package/examples/greenfield/module-hybrid-cdn/README.md +40 -3
  12. package/examples/greenfield/module-hybrid-cdn/public/fields.json +44 -1
  13. package/examples/greenfield/module-hybrid-cdn/src/app.tsx +8 -0
  14. package/examples/greenfield/module-hybrid-cdn/src/assets/css/main.dev.css +6 -1
  15. package/examples/greenfield/module-hybrid-cdn/src/cdn/app-entry.ts +32 -0
  16. package/examples/greenfield/module-hybrid-cdn/src/cdn/vendor-entry.ts +28 -0
  17. package/examples/greenfield/module-hybrid-cdn/src/components/layout/section-card.tsx +20 -0
  18. package/examples/greenfield/module-hybrid-cdn/src/components/layout/starter-nav.tsx +28 -0
  19. package/examples/greenfield/module-hybrid-cdn/src/components/layout/starter-shell.tsx +18 -0
  20. package/examples/greenfield/module-hybrid-cdn/src/config/router.ts +4 -0
  21. package/examples/greenfield/module-hybrid-cdn/src/config/routes.ts +5 -0
  22. package/examples/greenfield/module-hybrid-cdn/src/defaultData.ts +2 -7
  23. package/examples/greenfield/module-hybrid-cdn/src/features/starter/components/starter-guide-page.tsx +93 -0
  24. package/examples/greenfield/module-hybrid-cdn/src/features/starter/components/starter-home-page.tsx +21 -0
  25. package/examples/greenfield/module-hybrid-cdn/src/features/starter/components/starter-home.tsx +52 -0
  26. package/examples/greenfield/module-hybrid-cdn/src/features/starter/components/starter-single-page.tsx +17 -0
  27. package/examples/greenfield/module-hybrid-cdn/src/features/starter/content/woodscli-guide-content.ts +86 -0
  28. package/examples/greenfield/module-hybrid-cdn/src/features/starter/hooks/use-starter-preview.ts +47 -0
  29. package/examples/greenfield/module-hybrid-cdn/src/features/starter/index.ts +6 -0
  30. package/examples/greenfield/module-hybrid-cdn/src/hubspot/bootstrap.ts +10 -0
  31. package/examples/greenfield/module-hybrid-cdn/src/hubspot/default-data.ts +7 -0
  32. package/examples/greenfield/module-hybrid-cdn/src/integrations/tanstack-router/router.tsx +25 -0
  33. package/examples/greenfield/module-hybrid-cdn/src/main.tsx +4 -3
  34. package/examples/greenfield/module-hybrid-cdn/src/portal/mount-dev-preview.tsx +8 -0
  35. package/examples/greenfield/module-hybrid-cdn/src/portal/mount-portal.tsx +12 -0
  36. package/examples/greenfield/module-hybrid-cdn/src/routeTree.gen.ts +77 -0
  37. package/examples/greenfield/module-hybrid-cdn/src/routes/__root.tsx +15 -0
  38. package/examples/greenfield/module-hybrid-cdn/src/routes/guide.tsx +7 -0
  39. package/examples/greenfield/module-hybrid-cdn/src/routes/index.tsx +7 -0
  40. package/examples/greenfield/module-hybrid-cdn/tsconfig.json +20 -0
  41. package/examples/greenfield/module-monolith/public/fields.json +44 -1
  42. package/examples/greenfield/module-monolith/src/app.tsx +8 -0
  43. package/examples/greenfield/module-monolith/src/assets/css/main.dev.css +8 -0
  44. package/examples/greenfield/module-monolith/src/components/layout/section-card.tsx +20 -0
  45. package/examples/greenfield/module-monolith/src/components/layout/starter-nav.tsx +28 -0
  46. package/examples/greenfield/module-monolith/src/components/layout/starter-shell.tsx +18 -0
  47. package/examples/greenfield/module-monolith/src/config/router.ts +4 -0
  48. package/examples/greenfield/module-monolith/src/config/routes.ts +5 -0
  49. package/examples/greenfield/module-monolith/src/defaultData.ts +2 -7
  50. package/examples/greenfield/module-monolith/src/features/starter/components/starter-guide-page.tsx +93 -0
  51. package/examples/greenfield/module-monolith/src/features/starter/components/starter-home-page.tsx +21 -0
  52. package/examples/greenfield/module-monolith/src/features/starter/components/starter-home.tsx +52 -0
  53. package/examples/greenfield/module-monolith/src/features/starter/components/starter-single-page.tsx +17 -0
  54. package/examples/greenfield/module-monolith/src/features/starter/content/woodscli-guide-content.ts +86 -0
  55. package/examples/greenfield/module-monolith/src/features/starter/hooks/use-starter-preview.ts +47 -0
  56. package/examples/greenfield/module-monolith/src/features/starter/index.ts +6 -0
  57. package/examples/greenfield/module-monolith/src/hubspot/default-data.ts +7 -0
  58. package/examples/greenfield/module-monolith/src/hubspot/types.ts +17 -0
  59. package/examples/greenfield/module-monolith/src/integrations/tanstack-router/router.tsx +25 -0
  60. package/examples/greenfield/module-monolith/src/main.tsx +5 -0
  61. package/examples/greenfield/module-monolith/src/portal/mount-dev-preview.tsx +8 -0
  62. package/examples/greenfield/module-monolith/src/portal/mount-portal.tsx +12 -0
  63. package/examples/greenfield/module-monolith/src/routeTree.gen.ts +77 -0
  64. package/examples/greenfield/module-monolith/src/routes/__root.tsx +15 -0
  65. package/examples/greenfield/module-monolith/src/routes/guide.tsx +7 -0
  66. package/examples/greenfield/module-monolith/src/routes/index.tsx +7 -0
  67. package/examples/greenfield/module-monolith/tsconfig.json +20 -0
  68. package/package.json +4 -1
  69. package/scripts/check-hubspot-bundle-size.mjs +5 -2
  70. package/scripts/ensure-wphs-workspace.mjs +2 -1
  71. package/scripts/hubspot-module-postbuild.mjs +1 -1
  72. package/scripts/read-woodsportal-config.mjs +4 -1
  73. package/scripts/run-hubspot-module-postbuild.mjs +9 -4
  74. package/scripts/vite/bootstrap-shim-resolver.js +47 -0
  75. package/scripts/vite/ensure-dev-kit-shims.js +7 -1
  76. package/scripts/vite/react-dev-plugin-options.js +2 -1
  77. package/scripts/watch-hubspot.mjs +107 -17
  78. package/scripts/wphs-paths.mjs +1 -0
  79. package/scripts/write-audit-artifacts.mjs +2 -1
  80. package/src/dev/bootstrap-greenfield.tsx +59 -0
  81. package/src/dev/bootstrap.tsx +46 -0
  82. package/src/dev-module-config/__tests__/fixtures/fields.json +3014 -0
  83. package/src/dev-module-config/__tests__/fixtures/golden-module.fixture.ts +102 -0
  84. package/src/dev-module-config/__tests__/module-config-storage.test.ts +40 -0
  85. package/src/dev-module-config/__tests__/module-config-transform.test.ts +55 -0
  86. package/src/dev-module-config/__tests__/normalize-repeater-items.test.ts +46 -0
  87. package/src/dev-module-config/dev-module-display-name.ts +5 -0
  88. package/src/dev-module-config/runtime/module-config-bridge.ts +27 -0
  89. package/src/dev-module-config/runtime/module-config-runtime.ts +185 -0
  90. package/src/dev-module-config/schema/build-default-item.ts +7 -0
  91. package/src/dev-module-config/schema/deep-merge.ts +27 -0
  92. package/src/dev-module-config/schema/extract-defaults.ts +62 -0
  93. package/src/dev-module-config/schema/normalize-choices.ts +18 -0
  94. package/src/dev-module-config/schema/normalize-repeater-items.ts +31 -0
  95. package/src/dev-module-config/schema/parse-fields-json.ts +2 -0
  96. package/src/dev-module-config/schema/types.ts +75 -0
  97. package/src/dev-module-config/schema/validate-module-config.ts +81 -0
  98. package/src/dev-module-config/schema/visibility.ts +130 -0
  99. package/src/dev-module-config/storage/config-json.ts +46 -0
  100. package/src/dev-module-config/storage/workspace-store.ts +139 -0
  101. package/src/dev-module-config/transform/build-hubspot-data.ts +409 -0
  102. package/src/dev-module-config/ui/FieldRenderer.tsx +353 -0
  103. package/src/dev-module-config/ui/ModuleConfigAnimatedPresence.tsx +72 -0
  104. package/src/dev-module-config/ui/ModuleConfigCollapsible.tsx +43 -0
  105. package/src/dev-module-config/ui/ModuleConfigDockSideIcon.tsx +27 -0
  106. package/src/dev-module-config/ui/ModuleConfigOverlay.tsx +134 -0
  107. package/src/dev-module-config/ui/ModuleConfigPersistenceStatus.tsx +89 -0
  108. package/src/dev-module-config/ui/ModuleConfigShell.tsx +355 -0
  109. package/src/dev-module-config/ui/ModuleConfigThemeToggle.tsx +32 -0
  110. package/src/dev-module-config/ui/ModuleConfigUiContext.tsx +65 -0
  111. package/src/dev-module-config/ui/editors/DeveloperOptionsEditor.tsx +61 -0
  112. package/src/dev-module-config/ui/editors/MenuOptionsEditor.tsx +103 -0
  113. package/src/dev-module-config/ui/hubspot/HsColorField.tsx +106 -0
  114. package/src/dev-module-config/ui/hubspot/HsColorPickerPopover.tsx +210 -0
  115. package/src/dev-module-config/ui/hubspot/HsFieldLabel.tsx +24 -0
  116. package/src/dev-module-config/ui/hubspot/HsFieldWidgets.tsx +248 -0
  117. package/src/dev-module-config/ui/hubspot/HsImageField.tsx +112 -0
  118. package/src/dev-module-config/ui/hubspot/HsOccurrenceGroupEditor.tsx +243 -0
  119. package/src/dev-module-config/ui/hubspot/HsRepeaterList.tsx +178 -0
  120. package/src/dev-module-config/ui/hubspot/HsSectionHeader.tsx +20 -0
  121. package/src/dev-module-config/ui/hubspot/HsToggle.tsx +39 -0
  122. package/src/dev-module-config/ui/hubspot/HsToggleRow.tsx +30 -0
  123. package/src/dev-module-config/ui/hubspot/hs-color-palette.ts +11 -0
  124. package/src/dev-module-config/ui/hubspot/module-config-typography.ts +26 -0
  125. package/src/dev-module-config/ui/module-config-motion.css +134 -0
  126. package/src/dev-module-config/ui/module-config-motion.ts +28 -0
  127. package/src/dev-module-config/ui/module-config-overlay-side.ts +29 -0
  128. package/src/dev-module-config/ui/module-config-surfaces.ts +27 -0
  129. package/src/dev-module-config/ui/module-config-theme.css +489 -0
  130. package/src/dev-module-config/ui/module-config-toolbar-theme.ts +35 -0
  131. package/src/dev-module-config/ui/navigation/ModuleConfigNavContext.tsx +194 -0
  132. package/src/dev-module-config/ui/use-dev-module-config-live-revision.ts +22 -0
  133. package/src/dev-module-config/ui/use-hubspot-live-exports.ts +27 -0
  134. package/src/dev-module-config/ui/use-module-config.ts +124 -0
  135. package/src/routing/build-api-routes.ts +37 -0
  136. package/src/routing/route-format.ts +19 -0
  137. package/templates/module-hybrid-cdn/README.md +53 -3
  138. package/templates/module-hybrid-cdn/index.html +12 -1
  139. package/templates/module-hybrid-cdn/package.json +6 -2
  140. package/templates/module-hybrid-cdn/public/fields.json +44 -1
  141. package/templates/module-hybrid-cdn/src/app.tsx +8 -0
  142. package/templates/module-hybrid-cdn/src/assets/css/main.dev.css +6 -1
  143. package/templates/module-hybrid-cdn/src/cdn/app-entry.ts +32 -0
  144. package/templates/module-hybrid-cdn/src/cdn/vendor-entry.ts +28 -0
  145. package/templates/module-hybrid-cdn/src/components/layout/section-card.tsx +20 -0
  146. package/templates/module-hybrid-cdn/src/components/layout/starter-nav.tsx +28 -0
  147. package/templates/module-hybrid-cdn/src/components/layout/starter-shell.tsx +18 -0
  148. package/templates/module-hybrid-cdn/src/config/router.ts +4 -0
  149. package/templates/module-hybrid-cdn/src/config/routes.ts +5 -0
  150. package/templates/module-hybrid-cdn/src/defaultData.ts +2 -7
  151. package/templates/module-hybrid-cdn/src/features/starter/components/starter-guide-page.tsx +93 -0
  152. package/templates/module-hybrid-cdn/src/features/starter/components/starter-home-page.tsx +21 -0
  153. package/templates/module-hybrid-cdn/src/features/starter/components/starter-home.tsx +52 -0
  154. package/templates/module-hybrid-cdn/src/features/starter/components/starter-single-page.tsx +17 -0
  155. package/templates/module-hybrid-cdn/src/features/starter/content/woodscli-guide-content.ts +86 -0
  156. package/templates/module-hybrid-cdn/src/features/starter/hooks/starter-preview-values.ts +42 -0
  157. package/templates/module-hybrid-cdn/src/features/starter/hooks/use-starter-preview.dev.ts +17 -0
  158. package/templates/module-hybrid-cdn/src/features/starter/hooks/use-starter-preview.ts +30 -0
  159. package/templates/module-hybrid-cdn/src/features/starter/index.ts +6 -0
  160. package/templates/module-hybrid-cdn/src/hubspot/bootstrap.ts +10 -0
  161. package/templates/module-hybrid-cdn/src/hubspot/default-data.ts +7 -0
  162. package/templates/module-hybrid-cdn/src/hubspot/module-entry.tsx +9 -0
  163. package/templates/module-hybrid-cdn/src/integrations/tanstack-router/router.tsx +25 -0
  164. package/templates/module-hybrid-cdn/src/main.tsx +4 -3
  165. package/templates/module-hybrid-cdn/src/portal/mount-dev-preview.tsx +8 -0
  166. package/templates/module-hybrid-cdn/src/portal/mount-portal.tsx +12 -0
  167. package/templates/module-hybrid-cdn/src/routeTree.gen.ts +77 -0
  168. package/templates/module-hybrid-cdn/src/routes/__root.tsx +15 -0
  169. package/templates/module-hybrid-cdn/src/routes/guide.tsx +7 -0
  170. package/templates/module-hybrid-cdn/src/routes/index.tsx +7 -0
  171. package/templates/module-hybrid-cdn/tsconfig.json +20 -0
  172. package/templates/module-hybrid-cdn/vite.config.monolith.mjs +8 -0
  173. package/templates/module-monolith/README.md +59 -0
  174. package/templates/module-monolith/index.html +12 -0
  175. package/templates/module-monolith/package.json +10 -3
  176. package/templates/module-monolith/public/fields.json +44 -1
  177. package/templates/module-monolith/public/module.html +1 -0
  178. package/templates/module-monolith/src/app.tsx +8 -0
  179. package/templates/module-monolith/src/assets/css/main.dev.css +6 -1
  180. package/templates/module-monolith/src/components/layout/section-card.tsx +20 -0
  181. package/templates/module-monolith/src/components/layout/starter-nav.tsx +28 -0
  182. package/templates/module-monolith/src/components/layout/starter-shell.tsx +18 -0
  183. package/templates/module-monolith/src/config/router.ts +4 -0
  184. package/templates/module-monolith/src/config/routes.ts +5 -0
  185. package/templates/module-monolith/src/defaultData.ts +2 -7
  186. package/templates/module-monolith/src/features/starter/components/starter-guide-page.tsx +93 -0
  187. package/templates/module-monolith/src/features/starter/components/starter-home-page.tsx +21 -0
  188. package/templates/module-monolith/src/features/starter/components/starter-home.tsx +52 -0
  189. package/templates/module-monolith/src/features/starter/components/starter-single-page.tsx +17 -0
  190. package/templates/module-monolith/src/features/starter/content/woodscli-guide-content.ts +86 -0
  191. package/templates/module-monolith/src/features/starter/hooks/starter-preview-values.ts +42 -0
  192. package/templates/module-monolith/src/features/starter/hooks/use-starter-preview.dev.ts +17 -0
  193. package/templates/module-monolith/src/features/starter/hooks/use-starter-preview.ts +30 -0
  194. package/templates/module-monolith/src/features/starter/index.ts +6 -0
  195. package/templates/module-monolith/src/hubspot/default-data.ts +7 -0
  196. package/templates/module-monolith/src/hubspot/module-entry.tsx +9 -0
  197. package/templates/module-monolith/src/integrations/tanstack-router/router.tsx +25 -0
  198. package/templates/module-monolith/src/main.tsx +4 -3
  199. package/templates/module-monolith/src/portal/mount-dev-preview.tsx +8 -0
  200. package/templates/module-monolith/src/portal/mount-portal.tsx +12 -0
  201. package/templates/module-monolith/src/routeTree.gen.ts +77 -0
  202. package/templates/module-monolith/src/routes/__root.tsx +15 -0
  203. package/templates/module-monolith/src/routes/guide.tsx +7 -0
  204. package/templates/module-monolith/src/routes/index.tsx +7 -0
  205. package/templates/module-monolith/tsconfig.json +20 -0
  206. package/templates/module-monolith/vite.config.monolith.mjs +8 -0
  207. package/templates/module-monolith/woodscli.json +1 -0
  208. package/templates/shared/.env.dev.example +5 -0
  209. package/templates/shared/.env.prod.example +5 -0
  210. package/templates/shared/.env.stg.example +3 -0
  211. package/templates/shared/portal.cdn.json.example +8 -0
  212. package/templates/theme-starter/package.json +5 -2
  213. package/vite/vite.config.dev.mjs +9 -1
  214. package/vite/vite.config.monolith.mjs +22 -17
  215. package/dist/chunk-JDGIAYHX.js.map +0 -1
  216. package/dist/exec-K5C6LZP4.js +0 -10
  217. /package/dist/{exec-K5C6LZP4.js.map → exec-U2OJATR3.js.map} +0 -0
@@ -0,0 +1,75 @@
1
+ export type HubSpotFieldTab = 'CONTENT' | 'STYLE'
2
+
3
+ export type HubSpotFieldVisibility = {
4
+ controlling_field?: string | null
5
+ controlling_field_path?: string | null
6
+ controlling_value_regex?: string | null
7
+ property?: string | null
8
+ operator?: 'EQUAL' | 'NOT_EQUAL' | string | null
9
+ access?: string | null
10
+ }
11
+
12
+ export type HubSpotFieldDefinition = {
13
+ id?: string
14
+ name: string
15
+ label: string
16
+ help_text?: string
17
+ inline_help_text?: string
18
+ required?: boolean
19
+ locked?: boolean
20
+ type: string
21
+ tab?: HubSpotFieldTab
22
+ display?: string
23
+ display_width?: string | null
24
+ expanded?: boolean
25
+ children?: Array<HubSpotFieldDefinition>
26
+ choices?: Array<{ label: string; value: string } | [string, string]>
27
+ default?: unknown
28
+ visibility?: HubSpotFieldVisibility
29
+ occurrence?: {
30
+ min?: number | null
31
+ max?: number | null
32
+ default?: number | null
33
+ }
34
+ group_occurrence_meta?: unknown
35
+ }
36
+
37
+ /** Nested module field values keyed like HubSpot `module.*` paths */
38
+ export type ModuleFieldValues = Record<string, unknown>
39
+
40
+ export const MODULE_CONFIG_CHANGED = 'woodsportal:module-config-changed'
41
+
42
+ export const HUBSPOT_ONLY_FIELD_NAMES = new Set([
43
+ 'portal_cdn_base_url',
44
+ 'portal_build_id',
45
+ 'portal_vendor_script',
46
+ 'portal_app_script',
47
+ 'portal_app_css',
48
+ 'hubspot_data_source',
49
+ ])
50
+
51
+ /** HubSpot stores these as booleans but renders them as group expand/collapse in the native editor. */
52
+ export const MODULE_CONFIG_UI_HIDDEN_FIELD_NAMES = new Set([
53
+ 'developer_option',
54
+ 'backend_server_url',
55
+ ])
56
+
57
+ /** Top-level HubSpot sections that should start collapsed in the dev module config UI. */
58
+ export const MODULE_CONFIG_DEFAULT_COLLAPSED_SECTION_NAMES = new Set([
59
+ 'home',
60
+ 'menu_options',
61
+ 'other_options',
62
+ 'sso_buttons_customize_options',
63
+ ])
64
+
65
+ export function isModuleConfigSectionDefaultCollapsed(fieldName: string): boolean {
66
+ return MODULE_CONFIG_DEFAULT_COLLAPSED_SECTION_NAMES.has(fieldName)
67
+ }
68
+
69
+ /** Shown on Content tab only — fields without `tab` in fields.json would otherwise appear on Styles too. */
70
+ export const MODULE_CONFIG_CONTENT_TAB_FIELD_NAMES = new Set([
71
+ 'add_home_tab',
72
+ 'developerMode',
73
+ 'developer_options',
74
+ 'home',
75
+ ])
@@ -0,0 +1,81 @@
1
+ import { isFieldVisible, type VisibilityContext } from '../schema/visibility'
2
+ import { getNestedValue } from '../runtime/module-config-runtime'
3
+ import type { HubSpotFieldDefinition, ModuleFieldValues } from '../schema/types'
4
+
5
+ export type ValidationError = {
6
+ path: string
7
+ label: string
8
+ message: string
9
+ }
10
+
11
+ function isEmptyValue(value: unknown): boolean {
12
+ if (value === null || value === undefined) return true
13
+ if (typeof value === 'string') return value.trim().length === 0
14
+ if (typeof value === 'boolean' || typeof value === 'number') return false
15
+ if (Array.isArray(value)) return value.length === 0
16
+ if (typeof value === 'object') {
17
+ const record = value as Record<string, unknown>
18
+ if ('src' in record) return !String(record.src ?? '').trim()
19
+ if ('url' in record) {
20
+ const url = record.url as { href?: string } | undefined
21
+ return !String(url?.href ?? '').trim()
22
+ }
23
+ if ('color' in record) return !String(record.color ?? '').trim()
24
+ return Object.keys(record).length === 0
25
+ }
26
+ return false
27
+ }
28
+
29
+ function validateFieldsAtPath(
30
+ fields: Array<HubSpotFieldDefinition>,
31
+ moduleValues: ModuleFieldValues,
32
+ pathPrefix: string,
33
+ context: VisibilityContext,
34
+ errors: Array<ValidationError>,
35
+ ): void {
36
+ for (const field of fields) {
37
+ if (!isFieldVisible(field, context)) continue
38
+
39
+ const path = pathPrefix ? `${pathPrefix}.${field.name}` : field.name
40
+ const value = getNestedValue(moduleValues, path)
41
+
42
+ if (field.required && isEmptyValue(value)) {
43
+ errors.push({
44
+ path,
45
+ label: field.label,
46
+ message: `${field.label} is required`,
47
+ })
48
+ }
49
+
50
+ if (field.type === 'group' && field.children?.length) {
51
+ if (field.occurrence && Array.isArray(value)) {
52
+ value.forEach((item, index) => {
53
+ const itemPath = `${path}.${index}`
54
+ validateFieldsAtPath(field.children ?? [], moduleValues, itemPath, {
55
+ ...context,
56
+ repeaterPath: path,
57
+ itemIndex: index,
58
+ itemValues: item as ModuleFieldValues,
59
+ }, errors)
60
+ })
61
+ } else if (!field.occurrence) {
62
+ validateFieldsAtPath(field.children, moduleValues, path, context, errors)
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ export function validateModuleConfig(
69
+ schema: Array<HubSpotFieldDefinition>,
70
+ moduleValues: ModuleFieldValues,
71
+ ): Array<ValidationError> {
72
+ const errors: Array<ValidationError> = []
73
+ validateFieldsAtPath(schema, moduleValues, '', { moduleValues }, errors)
74
+
75
+ const seen = new Set<string>()
76
+ return errors.filter((error) => {
77
+ if (seen.has(error.path)) return false
78
+ seen.add(error.path)
79
+ return true
80
+ })
81
+ }
@@ -0,0 +1,130 @@
1
+ import type { HubSpotFieldDefinition, ModuleFieldValues } from './types'
2
+
3
+ export type VisibilityContext = {
4
+ /** Full module values */
5
+ moduleValues: ModuleFieldValues
6
+ /** e.g. menu_options.objects */
7
+ repeaterPath?: string
8
+ /** Index within repeater array */
9
+ itemIndex?: number
10
+ /** Current item values when editing a repeater row */
11
+ itemValues?: ModuleFieldValues
12
+ }
13
+
14
+ function getByPath(obj: ModuleFieldValues, path: string): unknown {
15
+ const parts = path.split('.')
16
+ let current: unknown = obj
17
+ for (const part of parts) {
18
+ if (current === null || current === undefined || typeof current !== 'object') {
19
+ return undefined
20
+ }
21
+ if (/^\d+$/.test(part)) {
22
+ current = (current as Array<unknown>)[Number(part)]
23
+ } else {
24
+ current = (current as Record<string, unknown>)[part]
25
+ }
26
+ }
27
+ return current
28
+ }
29
+
30
+ function resolveControlValue(
31
+ controllingPath: string,
32
+ context: VisibilityContext,
33
+ ): unknown {
34
+ const { moduleValues, repeaterPath, itemIndex, itemValues } = context
35
+
36
+ if (repeaterPath && itemValues && controllingPath.startsWith(`${repeaterPath}.`)) {
37
+ const relative = controllingPath.slice(repeaterPath.length + 1)
38
+ const topKey = relative.split('.')[0]
39
+ if (topKey in itemValues) {
40
+ return getByPath(itemValues, relative)
41
+ }
42
+ }
43
+
44
+ if (repeaterPath && itemIndex !== undefined && controllingPath.startsWith(`${repeaterPath}.`)) {
45
+ const relative = controllingPath.slice(repeaterPath.length + 1)
46
+ const list = getByPath(moduleValues, repeaterPath)
47
+ if (Array.isArray(list) && list[itemIndex]) {
48
+ return getByPath(list[itemIndex] as ModuleFieldValues, relative)
49
+ }
50
+ }
51
+
52
+ return getByPath(moduleValues, controllingPath)
53
+ }
54
+
55
+ function regexMatches(value: unknown, regex: string): boolean {
56
+ if (regex === 'true') return value === true || value === 'true'
57
+ if (regex === 'false') return value === false || value === 'false'
58
+ try {
59
+ return new RegExp(regex).test(String(value ?? ''))
60
+ } catch {
61
+ return String(value ?? '') === regex
62
+ }
63
+ }
64
+
65
+ function isVisibilityContext(
66
+ value: ModuleFieldValues | VisibilityContext,
67
+ ): value is VisibilityContext {
68
+ return (
69
+ typeof value === 'object' &&
70
+ value !== null &&
71
+ 'moduleValues' in value &&
72
+ typeof (value as VisibilityContext).moduleValues === 'object'
73
+ )
74
+ }
75
+
76
+ export function isFieldVisible(
77
+ field: HubSpotFieldDefinition,
78
+ moduleValuesOrContext: ModuleFieldValues | VisibilityContext,
79
+ ): boolean {
80
+ const context: VisibilityContext = isVisibilityContext(moduleValuesOrContext)
81
+ ? moduleValuesOrContext
82
+ : { moduleValues: moduleValuesOrContext }
83
+
84
+ const visibility = field.visibility
85
+ if (!visibility?.controlling_field_path) return true
86
+
87
+ const controlValue = resolveControlValue(visibility.controlling_field_path, context)
88
+ const regex = visibility.controlling_value_regex ?? 'true'
89
+
90
+ // HubSpot-only field hidden locally — treat as false so CRM-specific fields stay hidden.
91
+ if (
92
+ visibility.controlling_field_path === 'hubspot_data_source' &&
93
+ (controlValue === undefined || controlValue === null || controlValue === '')
94
+ ) {
95
+ const matchesFalse = regexMatches(false, regex)
96
+ if (visibility.operator === 'NOT_EQUAL') return !matchesFalse
97
+ return matchesFalse
98
+ }
99
+
100
+ const matches = regexMatches(controlValue, regex)
101
+
102
+ if (visibility.operator === 'NOT_EQUAL') return !matches
103
+ return matches
104
+ }
105
+
106
+ export function flattenVisibleFields(
107
+ fields: Array<HubSpotFieldDefinition>,
108
+ moduleValues: ModuleFieldValues,
109
+ tab?: 'CONTENT' | 'STYLE',
110
+ context?: Omit<VisibilityContext, 'moduleValues'>,
111
+ ): Array<HubSpotFieldDefinition> {
112
+ const result: Array<HubSpotFieldDefinition> = []
113
+ const visibilityContext: VisibilityContext = { moduleValues, ...context }
114
+
115
+ for (const field of fields) {
116
+ if (tab && field.tab && field.tab !== tab) continue
117
+ if (!isFieldVisible(field, visibilityContext)) continue
118
+ result.push(field)
119
+ }
120
+
121
+ return result
122
+ }
123
+
124
+ export function chunkFields<T>(items: Array<T>, size: number): Array<Array<T>> {
125
+ const pages: Array<Array<T>> = []
126
+ for (let i = 0; i < items.length; i += size) {
127
+ pages.push(items.slice(i, i + size))
128
+ }
129
+ return pages.length ? pages : [[]]
130
+ }
@@ -0,0 +1,46 @@
1
+ import type { ModuleFieldValues } from '../schema/types'
2
+
3
+ export type WorkspaceConfigRecord = {
4
+ version: number
5
+ module: ModuleFieldValues
6
+ updatedAt: string | null
7
+ source?: 'local' | 'example' | 'empty'
8
+ }
9
+
10
+ export function exportConfigJson(moduleValues: ModuleFieldValues): string {
11
+ return JSON.stringify({ version: 1, module: moduleValues }, null, 2)
12
+ }
13
+
14
+ export function importConfigJson(raw: string): ModuleFieldValues {
15
+ const parsed = JSON.parse(raw) as { module?: ModuleFieldValues } | ModuleFieldValues
16
+ if ('module' in parsed && parsed.module && typeof parsed.module === 'object') {
17
+ return parsed.module as ModuleFieldValues
18
+ }
19
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
20
+ return parsed as ModuleFieldValues
21
+ }
22
+ throw new Error('Invalid module config JSON')
23
+ }
24
+
25
+ export async function uploadBrandingAsset(file: File): Promise<string> {
26
+ const buffer = await file.arrayBuffer()
27
+ const bytes = new Uint8Array(buffer)
28
+ let binary = ''
29
+ for (let i = 0; i < bytes.length; i++) {
30
+ binary += String.fromCharCode(bytes[i]!)
31
+ }
32
+ const data = btoa(binary)
33
+
34
+ const response = await fetch('/__dev/branding-asset', {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ filename: file.name, data }),
38
+ })
39
+
40
+ if (!response.ok) {
41
+ throw new Error(`Upload failed: ${response.status}`)
42
+ }
43
+
44
+ const payload = (await response.json()) as { src: string }
45
+ return payload.src
46
+ }
@@ -0,0 +1,139 @@
1
+ import { deepMerge } from '../schema/deep-merge'
2
+ import type { ModuleFieldValues } from '../schema/types'
3
+ import type { WorkspaceConfigRecord } from './config-json'
4
+
5
+ const DB_NAME = 'woodsportal-module-config'
6
+ const DB_VERSION = 1
7
+ const OVERRIDES_KEY = 'active'
8
+
9
+ type OverridesRecord = {
10
+ version: number
11
+ patches: ModuleFieldValues
12
+ updatedAt: string
13
+ }
14
+
15
+ export { exportConfigJson, importConfigJson, uploadBrandingAsset } from './config-json'
16
+
17
+ export async function loadWorkspaceMeta(): Promise<WorkspaceConfigRecord['source']> {
18
+ try {
19
+ const response = await fetch('/__dev/module-config')
20
+ if (!response.ok) return 'empty'
21
+ const record = (await response.json()) as WorkspaceConfigRecord
22
+ return record.source ?? 'empty'
23
+ } catch {
24
+ return 'empty'
25
+ }
26
+ }
27
+
28
+ export async function loadOverrides(): Promise<ModuleFieldValues> {
29
+ try {
30
+ const migrated = await migrateFromIndexedDbIfNeeded()
31
+ if (migrated) return migrated
32
+
33
+ const response = await fetch('/__dev/module-config')
34
+ if (!response.ok) return {}
35
+ const record = (await response.json()) as WorkspaceConfigRecord
36
+ return record.module ?? {}
37
+ } catch {
38
+ return {}
39
+ }
40
+ }
41
+
42
+ export async function saveOverrides(patches: ModuleFieldValues): Promise<void> {
43
+ const record: WorkspaceConfigRecord = {
44
+ version: 1,
45
+ module: patches,
46
+ updatedAt: new Date().toISOString(),
47
+ source: 'local',
48
+ }
49
+
50
+ const response = await fetch('/__dev/module-config', {
51
+ method: 'PUT',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify(record),
54
+ })
55
+
56
+ if (!response.ok) {
57
+ throw new Error(`Failed to save module config: ${response.status}`)
58
+ }
59
+ }
60
+
61
+ export async function clearOverrides(): Promise<void> {
62
+ const response = await fetch('/__dev/module-config', { method: 'DELETE' })
63
+ if (!response.ok && response.status !== 204) {
64
+ throw new Error(`Failed to clear module config: ${response.status}`)
65
+ }
66
+ }
67
+
68
+ export async function mergeWithDefaults(
69
+ defaults: ModuleFieldValues,
70
+ patches: ModuleFieldValues,
71
+ ): Promise<ModuleFieldValues> {
72
+ return deepMerge(defaults, patches)
73
+ }
74
+
75
+ async function migrateFromIndexedDbIfNeeded(): Promise<ModuleFieldValues | null> {
76
+ if (typeof indexedDB === 'undefined') return null
77
+
78
+ try {
79
+ const response = await fetch('/__dev/module-config')
80
+ if (!response.ok) return null
81
+ const record = (await response.json()) as WorkspaceConfigRecord
82
+ if (record.source === 'local') return null
83
+
84
+ const idbPatches = await readIndexedDbOverrides()
85
+ if (!idbPatches || Object.keys(idbPatches).length === 0) return null
86
+
87
+ await saveOverrides(idbPatches)
88
+ await clearIndexedDbOverrides()
89
+ console.info('[module-config] migrated IndexedDB overrides → .dev/module-config.local.json')
90
+ return idbPatches
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ function openDb(): Promise<IDBDatabase> {
97
+ return new Promise((resolve, reject) => {
98
+ const request = indexedDB.open(DB_NAME, DB_VERSION)
99
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed'))
100
+ request.onsuccess = () => resolve(request.result)
101
+ request.onupgradeneeded = () => {
102
+ const db = request.result
103
+ if (!db.objectStoreNames.contains('overrides')) {
104
+ db.createObjectStore('overrides')
105
+ }
106
+ }
107
+ })
108
+ }
109
+
110
+ async function readIndexedDbOverrides(): Promise<ModuleFieldValues | null> {
111
+ try {
112
+ const db = await openDb()
113
+ return await new Promise((resolve, reject) => {
114
+ const tx = db.transaction('overrides', 'readonly')
115
+ const request = tx.objectStore('overrides').get(OVERRIDES_KEY)
116
+ request.onerror = () => reject(request.error)
117
+ request.onsuccess = () => {
118
+ const record = request.result as OverridesRecord | undefined
119
+ resolve(record?.patches ?? null)
120
+ }
121
+ })
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ async function clearIndexedDbOverrides(): Promise<void> {
128
+ try {
129
+ const db = await openDb()
130
+ await new Promise<void>((resolve, reject) => {
131
+ const tx = db.transaction('overrides', 'readwrite')
132
+ const request = tx.objectStore('overrides').delete(OVERRIDES_KEY)
133
+ request.onerror = () => reject(request.error)
134
+ request.onsuccess = () => resolve()
135
+ })
136
+ } catch {
137
+ // ignore migration cleanup errors
138
+ }
139
+ }