@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,103 @@
1
+ import { useState } from 'react'
2
+ import { getNestedValue } from '../../runtime/module-config-runtime'
3
+ import { isFieldVisible } from '../../schema/visibility'
4
+ import { HsOccurrenceGroupEditor } from '../hubspot/HsOccurrenceGroupEditor'
5
+ import { HsToggleRow } from '../hubspot/HsToggleRow'
6
+ import { hsTypography } from '../hubspot/module-config-typography'
7
+ import { ModuleConfigAnimatedPresence } from '../ModuleConfigAnimatedPresence'
8
+ import { ModuleConfigCollapsible } from '../ModuleConfigCollapsible'
9
+ import type { HubSpotFieldDefinition, ModuleFieldValues } from '../../schema/types'
10
+
11
+ type MenuOptionsEditorProps = {
12
+ field: HubSpotFieldDefinition
13
+ moduleValues: ModuleFieldValues
14
+ onChange: (path: string, value: unknown) => void
15
+ }
16
+
17
+ export function MenuOptionsEditor({ field, moduleValues, onChange }: MenuOptionsEditorProps) {
18
+ const [open, setOpen] = useState(false)
19
+ const menuPath = field.name
20
+ const menuValues = (getNestedValue(moduleValues, menuPath) as ModuleFieldValues) ?? {}
21
+ const children = field.children ?? []
22
+
23
+ const standardToggle = children.find((c) => c.name === 'hubspot_defined_object')
24
+ const objectsField = children.find((c) => c.name === 'objects')
25
+ const customToggle = children.find((c) => c.name === 'hubspot_custom_object')
26
+ const customObjectsField = children.find((c) => c.name === 'hubspot_custom_objects')
27
+
28
+ const standardEnabled = Boolean(menuValues.hubspot_defined_object)
29
+ const customEnabled = Boolean(menuValues.hubspot_custom_object)
30
+ const objects = Array.isArray(menuValues.objects) ? (menuValues.objects as Array<ModuleFieldValues>) : []
31
+ const customObjects = Array.isArray(menuValues.hubspot_custom_objects)
32
+ ? (menuValues.hubspot_custom_objects as Array<ModuleFieldValues>)
33
+ : []
34
+
35
+ const setMenuValue = (key: string, value: unknown) => {
36
+ onChange(`${menuPath}.${key}`, value)
37
+ }
38
+
39
+ return (
40
+ <ModuleConfigCollapsible
41
+ open={open}
42
+ onToggle={() => setOpen((value) => !value)}
43
+ label={field.label}
44
+ headerClassName={hsTypography.groupHeader}
45
+ >
46
+ {standardToggle ? (
47
+ <HsToggleRow
48
+ label={standardToggle.label}
49
+ helpText={standardToggle.help_text}
50
+ required={standardToggle.required}
51
+ checked={standardEnabled}
52
+ onChange={(checked) => setMenuValue('hubspot_defined_object', checked)}
53
+ />
54
+ ) : null}
55
+
56
+ <ModuleConfigAnimatedPresence
57
+ show={Boolean(standardEnabled && objectsField && isFieldVisible(objectsField, moduleValues))}
58
+ enterFrom="bottom"
59
+ >
60
+ {objectsField ? (
61
+ <HsOccurrenceGroupEditor
62
+ field={objectsField}
63
+ items={objects}
64
+ moduleValues={moduleValues}
65
+ path={`${menuPath}.objects`}
66
+ onChange={onChange}
67
+ labelField="list_object_name"
68
+ sectionLabel="Menu Options"
69
+ visibilityContext={{ moduleValues }}
70
+ />
71
+ ) : null}
72
+ </ModuleConfigAnimatedPresence>
73
+
74
+ {customToggle ? (
75
+ <HsToggleRow
76
+ label={customToggle.label}
77
+ helpText={customToggle.help_text}
78
+ required={customToggle.required}
79
+ checked={customEnabled}
80
+ onChange={(checked) => setMenuValue('hubspot_custom_object', checked)}
81
+ />
82
+ ) : null}
83
+
84
+ <ModuleConfigAnimatedPresence
85
+ show={Boolean(customEnabled && customObjectsField && isFieldVisible(customObjectsField, moduleValues))}
86
+ enterFrom="bottom"
87
+ >
88
+ {customObjectsField ? (
89
+ <HsOccurrenceGroupEditor
90
+ field={customObjectsField}
91
+ items={customObjects}
92
+ moduleValues={moduleValues}
93
+ path={`${menuPath}.hubspot_custom_objects`}
94
+ onChange={onChange}
95
+ labelField="list_object_name"
96
+ sectionLabel="Custom Object Menu"
97
+ visibilityContext={{ moduleValues }}
98
+ />
99
+ ) : null}
100
+ </ModuleConfigAnimatedPresence>
101
+ </ModuleConfigCollapsible>
102
+ )
103
+ }
@@ -0,0 +1,106 @@
1
+ import { useMemo, useRef, useState } from 'react'
2
+ import { HsFieldLabel } from './HsFieldLabel'
3
+ import { hsTypography } from './module-config-typography'
4
+ import { HsColorPickerPopover } from './HsColorPickerPopover'
5
+ import { useModuleConfigUi } from '../ModuleConfigUiContext'
6
+
7
+ export type HsColorValue = {
8
+ color?: string
9
+ opacity?: number | string
10
+ }
11
+
12
+ type HsColorFieldProps = {
13
+ label: string
14
+ required?: boolean
15
+ helpText?: string
16
+ value: HsColorValue
17
+ onChange: (value: HsColorValue) => void
18
+ }
19
+
20
+ function normalizeHex(color: string | undefined): string {
21
+ const raw = (color ?? '').replace('#', '').trim()
22
+ if (/^[0-9a-fA-F]{6}$/.test(raw)) return `#${raw.toUpperCase()}`
23
+ if (/^[0-9a-fA-F]{3}$/.test(raw)) {
24
+ return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`.toUpperCase()
25
+ }
26
+ return '#000000'
27
+ }
28
+
29
+ function previewRgba(color: string | undefined, opacity: number | string | undefined): string {
30
+ const hex = normalizeHex(color)
31
+ const r = Number.parseInt(hex.slice(1, 3), 16)
32
+ const g = Number.parseInt(hex.slice(3, 5), 16)
33
+ const b = Number.parseInt(hex.slice(5, 7), 16)
34
+ const alpha = Math.min(100, Math.max(0, Number(opacity ?? 100))) / 100
35
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
36
+ }
37
+
38
+ export function HsColorField({ label, required, helpText, value, onChange }: HsColorFieldProps) {
39
+ const swatchRef = useRef<HTMLButtonElement>(null)
40
+ const [pickerOpen, setPickerOpen] = useState(false)
41
+ const { colorPickerPlacement, theme } = useModuleConfigUi()
42
+ const color = value?.color ?? ''
43
+ const opacity = value?.opacity ?? 100
44
+ const swatchColor = useMemo(() => previewRgba(color, opacity), [color, opacity])
45
+
46
+ const setHex = (nextRaw: string) => {
47
+ const cleaned = nextRaw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6)
48
+ onChange({ ...value, color: cleaned ? `#${cleaned.toUpperCase()}` : '' })
49
+ }
50
+
51
+ const setOpacity = (next: number) => {
52
+ const clamped = Math.min(100, Math.max(0, Number.isFinite(next) ? next : 0))
53
+ onChange({ ...value, opacity: clamped })
54
+ }
55
+
56
+ const setColor = (nextHex: string) => {
57
+ onChange({ ...value, color: normalizeHex(nextHex) })
58
+ }
59
+
60
+ return (
61
+ <div className="mb-4">
62
+ <HsFieldLabel label={label} required={required} helpText={helpText} />
63
+ <div className="flex items-center gap-2">
64
+ <span className={hsTypography.helper}>#</span>
65
+ <input
66
+ className={`w-24 rounded px-2 py-2 uppercase mc-input ${hsTypography.input}`}
67
+ value={color.replace('#', '')}
68
+ onChange={(event) => setHex(event.target.value)}
69
+ spellCheck={false}
70
+ aria-label={`${label} hex value`}
71
+ />
72
+ <input
73
+ type="number"
74
+ min={0}
75
+ max={100}
76
+ className={`w-16 rounded px-2 py-2 mc-input ${hsTypography.input}`}
77
+ value={opacity}
78
+ onChange={(event) => setOpacity(Number(event.target.value))}
79
+ aria-label={`${label} opacity`}
80
+ />
81
+ <span className={hsTypography.helper}>%</span>
82
+ <div className="relative shrink-0">
83
+ <button
84
+ ref={swatchRef}
85
+ type="button"
86
+ className="relative h-8 w-8 rounded-full p-0 focus:outline-none"
87
+ style={{ backgroundColor: swatchColor }}
88
+ aria-label={`${label} color picker`}
89
+ aria-expanded={pickerOpen}
90
+ onClick={() => setPickerOpen((open) => !open)}
91
+ />
92
+ {pickerOpen ? (
93
+ <HsColorPickerPopover
94
+ value={color}
95
+ onChange={setColor}
96
+ onClose={() => setPickerOpen(false)}
97
+ anchorRef={swatchRef}
98
+ preferredPlacement={colorPickerPlacement}
99
+ theme={theme}
100
+ />
101
+ ) : null}
102
+ </div>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,210 @@
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { hsTypography } from './module-config-typography'
4
+ import { HUBSPOT_SIMPLE_COLOR_ROWS } from './hs-color-palette'
5
+ import { mcSurface } from '../module-config-surfaces'
6
+ import type { ColorPickerPlacement, ModuleConfigToolbarTheme } from '../ModuleConfigUiContext'
7
+
8
+ type HsColorPickerPopoverProps = {
9
+ value: string
10
+ onChange: (hex: string) => void
11
+ onClose: () => void
12
+ anchorRef: RefObject<HTMLButtonElement | null>
13
+ preferredPlacement?: ColorPickerPlacement
14
+ theme: ModuleConfigToolbarTheme
15
+ }
16
+
17
+ type PickerTab = 'simple' | 'advanced'
18
+
19
+ type PopoverPosition = {
20
+ top: number
21
+ left: number
22
+ placement: 'left' | 'right'
23
+ }
24
+
25
+ const POPOVER_WIDTH = 252
26
+ const GAP = 10
27
+ const VIEWPORT_PADDING = 8
28
+
29
+ function normalizeHex(color: string): string {
30
+ const raw = color.replace('#', '').trim()
31
+ if (/^[0-9a-fA-F]{6}$/.test(raw)) return `#${raw.toUpperCase()}`
32
+ if (/^[0-9a-fA-F]{3}$/.test(raw)) {
33
+ return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`.toUpperCase()
34
+ }
35
+ return '#000000'
36
+ }
37
+
38
+ function computePopoverPosition(
39
+ anchor: DOMRect,
40
+ popoverWidth: number,
41
+ popoverHeight: number,
42
+ preferredPlacement: ColorPickerPlacement,
43
+ ): PopoverPosition {
44
+ const spaceRight = window.innerWidth - anchor.right - GAP - VIEWPORT_PADDING
45
+ const spaceLeft = anchor.left - GAP - VIEWPORT_PADDING
46
+ const canLeft = spaceLeft >= popoverWidth
47
+ const canRight = spaceRight >= popoverWidth
48
+
49
+ let openRight: boolean
50
+ if (preferredPlacement === 'left') {
51
+ openRight = !canLeft && canRight
52
+ } else {
53
+ openRight = canRight || !canLeft
54
+ }
55
+
56
+ let left = openRight ? anchor.right + GAP : anchor.left - GAP - popoverWidth
57
+ left = Math.max(
58
+ VIEWPORT_PADDING,
59
+ Math.min(left, window.innerWidth - popoverWidth - VIEWPORT_PADDING),
60
+ )
61
+
62
+ let top = anchor.top + anchor.height / 2
63
+ const halfHeight = popoverHeight / 2
64
+ top = Math.max(
65
+ VIEWPORT_PADDING + halfHeight,
66
+ Math.min(top, window.innerHeight - VIEWPORT_PADDING - halfHeight),
67
+ )
68
+
69
+ return {
70
+ top,
71
+ left,
72
+ placement: openRight ? 'right' : 'left',
73
+ }
74
+ }
75
+
76
+ export function HsColorPickerPopover({
77
+ value,
78
+ onChange,
79
+ onClose,
80
+ anchorRef,
81
+ preferredPlacement = 'right',
82
+ theme,
83
+ }: HsColorPickerPopoverProps) {
84
+ const popoverRef = useRef<HTMLDivElement>(null)
85
+ const [tab, setTab] = useState<PickerTab>('simple')
86
+ const [position, setPosition] = useState<PopoverPosition | null>(null)
87
+ const selected = normalizeHex(value)
88
+ const panelId = useId()
89
+
90
+ const updatePosition = useCallback(() => {
91
+ const anchor = anchorRef.current
92
+ if (!anchor) return
93
+
94
+ const popover = popoverRef.current
95
+ const width = popover?.offsetWidth ?? POPOVER_WIDTH
96
+ const height = popover?.offsetHeight ?? (tab === 'simple' ? 280 : 180)
97
+ setPosition(
98
+ computePopoverPosition(anchor.getBoundingClientRect(), width, height, preferredPlacement),
99
+ )
100
+ }, [anchorRef, preferredPlacement, tab])
101
+
102
+ useLayoutEffect(() => {
103
+ updatePosition()
104
+ window.addEventListener('resize', updatePosition)
105
+ window.addEventListener('scroll', updatePosition, true)
106
+ return () => {
107
+ window.removeEventListener('resize', updatePosition)
108
+ window.removeEventListener('scroll', updatePosition, true)
109
+ }
110
+ }, [updatePosition])
111
+
112
+ useEffect(() => {
113
+ updatePosition()
114
+ }, [tab, updatePosition])
115
+
116
+ useEffect(() => {
117
+ const onPointerDown = (event: MouseEvent) => {
118
+ const target = event.target as Node
119
+ if (popoverRef.current?.contains(target)) return
120
+ if (anchorRef.current?.contains(target)) return
121
+ onClose()
122
+ }
123
+
124
+ const onKeyDown = (event: KeyboardEvent) => {
125
+ if (event.key === 'Escape') onClose()
126
+ }
127
+
128
+ document.addEventListener('mousedown', onPointerDown)
129
+ document.addEventListener('keydown', onKeyDown)
130
+ return () => {
131
+ document.removeEventListener('mousedown', onPointerDown)
132
+ document.removeEventListener('keydown', onKeyDown)
133
+ }
134
+ }, [anchorRef, onClose])
135
+
136
+ if (!position) return null
137
+
138
+ return createPortal(
139
+ <div
140
+ ref={popoverRef}
141
+ id={panelId}
142
+ role="dialog"
143
+ aria-label="Color picker"
144
+ data-mc-theme={theme}
145
+ className="mc-modal-panel fixed z-[9999] w-[252px] -translate-y-1/2"
146
+ style={{ top: position.top, left: position.left }}
147
+ >
148
+ <div className={`${mcSurface.tabBar} !flex-shrink`}>
149
+ {(['simple', 'advanced'] as Array<PickerTab>).map((item) => (
150
+ <button
151
+ key={item}
152
+ type="button"
153
+ className={tab === item ? mcSurface.tabButtonActive : mcSurface.tabButton}
154
+ onClick={() => setTab(item)}
155
+ >
156
+ {item}
157
+ </button>
158
+ ))}
159
+ </div>
160
+
161
+ {tab === 'simple' ? (
162
+ <div className="p-2">
163
+ <div className="grid grid-cols-9 gap-1">
164
+ {HUBSPOT_SIMPLE_COLOR_ROWS.flat().map((swatch) => {
165
+ const isSelected = normalizeHex(swatch) === selected
166
+ return (
167
+ <button
168
+ key={swatch}
169
+ type="button"
170
+ title={swatch}
171
+ aria-label={swatch}
172
+ aria-pressed={isSelected}
173
+ className={`h-6 w-6 rounded-sm ${isSelected ? 'mc-color-swatch-selected' : ''}`}
174
+ style={{ backgroundColor: swatch }}
175
+ onClick={() => {
176
+ onChange(swatch)
177
+ onClose()
178
+ }}
179
+ />
180
+ )
181
+ })}
182
+ </div>
183
+ </div>
184
+ ) : (
185
+ <div className="space-y-3 p-3">
186
+ <input
187
+ type="color"
188
+ className="h-10 w-full cursor-pointer rounded mc-input"
189
+ value={selected}
190
+ onChange={(event) => onChange(event.target.value.toUpperCase())}
191
+ aria-label="Advanced color picker"
192
+ />
193
+ <div className="flex items-center gap-2">
194
+ <span className={hsTypography.helper}>#</span>
195
+ <input
196
+ className={`flex-1 rounded px-2 py-1.5 uppercase mc-input ${hsTypography.input}`}
197
+ value={selected.replace('#', '')}
198
+ onChange={(event) => {
199
+ const cleaned = event.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6)
200
+ if (cleaned.length === 6) onChange(`#${cleaned.toUpperCase()}`)
201
+ }}
202
+ spellCheck={false}
203
+ />
204
+ </div>
205
+ </div>
206
+ )}
207
+ </div>,
208
+ document.body,
209
+ )
210
+ }
@@ -0,0 +1,24 @@
1
+ import { hsTypography, stripHtml } from './module-config-typography'
2
+
3
+ type HsFieldLabelProps = {
4
+ label: string
5
+ required?: boolean
6
+ helpText?: string
7
+ htmlFor?: string
8
+ }
9
+
10
+ export function HsFieldLabel({ label, required, helpText, htmlFor }: HsFieldLabelProps) {
11
+ return (
12
+ <div className="mb-1 flex items-center gap-1">
13
+ <label htmlFor={htmlFor} className={hsTypography.fieldLabel}>
14
+ {label}
15
+ {required ? <span className="text-[var(--mc-error-text)]"> *</span> : null}
16
+ </label>
17
+ {helpText ? (
18
+ <span title={stripHtml(helpText)} className={hsTypography.infoIcon}>
19
+ i
20
+ </span>
21
+ ) : null}
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,248 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { HsFieldLabel } from './HsFieldLabel'
3
+ import { hsTypography } from './module-config-typography'
4
+ import { mcMotion } from '../module-config-motion'
5
+ import { mcSurface } from '../module-config-surfaces'
6
+ import { useModuleConfigUi } from '../ModuleConfigUiContext'
7
+
8
+ type HsLinkFieldProps = {
9
+ label: string
10
+ required?: boolean
11
+ helpText?: string
12
+ value: {
13
+ url?: { href?: string; type?: string }
14
+ open_in_new_tab?: boolean
15
+ no_follow?: boolean
16
+ }
17
+ onChange: (value: HsLinkFieldProps['value']) => void
18
+ }
19
+
20
+ export function HsLinkField({ label, required, helpText, value, onChange }: HsLinkFieldProps) {
21
+ const href = value.url?.href ?? ''
22
+
23
+ return (
24
+ <div className="mb-4">
25
+ <HsFieldLabel label={label} required={required} helpText={helpText} />
26
+ <input
27
+ className={`w-full rounded px-3 py-2 focus:outline-none ${hsTypography.input} mc-input ${mcMotion.fieldControl}`}
28
+ placeholder="https://…"
29
+ value={href}
30
+ onChange={(event) =>
31
+ onChange({
32
+ ...value,
33
+ url: { href: event.target.value, type: value.url?.type ?? 'EXTERNAL' },
34
+ })
35
+ }
36
+ />
37
+ <div className="mt-2 flex flex-col gap-2">
38
+ <label className={`flex items-center gap-2 ${hsTypography.body}`}>
39
+ <input
40
+ type="checkbox"
41
+ checked={Boolean(value.open_in_new_tab)}
42
+ onChange={(event) => onChange({ ...value, open_in_new_tab: event.target.checked })}
43
+ className="h-4 w-4 mc-checkbox"
44
+ />
45
+ Open in new tab
46
+ </label>
47
+ <label className={`flex items-center gap-2 ${hsTypography.body}`}>
48
+ <input
49
+ type="checkbox"
50
+ checked={Boolean(value.no_follow)}
51
+ onChange={(event) => onChange({ ...value, no_follow: event.target.checked })}
52
+ className="h-4 w-4 mc-checkbox"
53
+ />
54
+ No follow
55
+ </label>
56
+ </div>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ type HsRichTextFieldProps = {
62
+ label: string
63
+ required?: boolean
64
+ helpText?: string
65
+ value: string
66
+ onChange: (value: string) => void
67
+ }
68
+
69
+ function wrapSelection(value: string, before: string, after: string): string {
70
+ return `${before}${value}${after}`
71
+ }
72
+
73
+ export function HsRichTextField({ label, required, helpText, value, onChange }: HsRichTextFieldProps) {
74
+ const [expanded, setExpanded] = useState(false)
75
+ const [draft, setDraft] = useState(value)
76
+ const { theme } = useModuleConfigUi()
77
+
78
+ const toolbar = useMemo(
79
+ () => [
80
+ { label: 'B', action: () => onChange(wrapSelection(value, '<strong>', '</strong>')) },
81
+ { label: 'I', action: () => onChange(wrapSelection(value, '<em>', '</em>')) },
82
+ { label: 'U', action: () => onChange(wrapSelection(value, '<u>', '</u>')) },
83
+ {
84
+ label: 'Link',
85
+ action: () => {
86
+ const url = window.prompt('Link URL')
87
+ if (!url) return
88
+ onChange(wrapSelection(value, `<a href="${url}">`, '</a>'))
89
+ },
90
+ },
91
+ ],
92
+ [onChange, value],
93
+ )
94
+
95
+ return (
96
+ <div className="mb-4">
97
+ <HsFieldLabel label={label} required={required} helpText={helpText} />
98
+ <div className={`overflow-hidden ${mcSurface.panel}`}>
99
+ <div className="mc-richtext-toolbar flex flex-wrap items-center gap-1 px-2 py-1.5">
100
+ {toolbar.map((item) => (
101
+ <button
102
+ key={item.label}
103
+ type="button"
104
+ className={`rounded px-2 py-1 text-xs font-semibold ${mcSurface.text} ${mcSurface.hover}`}
105
+ onClick={item.action}
106
+ >
107
+ {item.label}
108
+ </button>
109
+ ))}
110
+ </div>
111
+ <textarea
112
+ className={`min-h-[120px] w-full resize-y px-3 py-2 text-sm leading-relaxed ${mcSurface.text} bg-transparent focus:outline-none`}
113
+ value={value}
114
+ onChange={(event) => onChange(event.target.value)}
115
+ />
116
+ </div>
117
+ <button
118
+ type="button"
119
+ className={`mt-2 text-sm ${mcSurface.textMuted} hover:text-[var(--mc-text)]`}
120
+ onClick={() => {
121
+ setDraft(value)
122
+ setExpanded(true)
123
+ }}
124
+ >
125
+ Edit in expanded view
126
+ </button>
127
+
128
+ {expanded ? (
129
+ <div
130
+ data-mc-theme={theme}
131
+ className="fixed inset-0 z-[10000] flex items-center justify-center bg-[var(--mc-modal-backdrop)] p-4"
132
+ >
133
+ <div className="mc-modal-panel flex h-[min(80vh,640px)] w-[min(960px,100%)] flex-col">
134
+ <div className="mc-modal-section px-4 py-3">
135
+ <h3 className={`text-lg ${mcSurface.text}`}>{label}</h3>
136
+ </div>
137
+ <textarea
138
+ className="flex-1 resize-none px-4 py-3 font-mono text-sm leading-relaxed mc-input bg-transparent focus:outline-none"
139
+ value={draft}
140
+ onChange={(event) => setDraft(event.target.value)}
141
+ />
142
+ <div className="mc-modal-section flex justify-end gap-2 px-4 py-3">
143
+ <button type="button" className={`${mcSurface.footerButton} px-4 py-2`} onClick={() => setExpanded(false)}>
144
+ Cancel
145
+ </button>
146
+ <button
147
+ type="button"
148
+ className={`mc-btn-primary px-4 py-2 text-sm`}
149
+ onClick={() => {
150
+ onChange(draft)
151
+ setExpanded(false)
152
+ }}
153
+ >
154
+ Apply
155
+ </button>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ ) : null}
160
+ </div>
161
+ )
162
+ }
163
+
164
+ type HsTextFieldProps = {
165
+ label: string
166
+ required?: boolean
167
+ helpText?: string
168
+ value: string
169
+ onChange: (value: string) => void
170
+ multiline?: boolean
171
+ monospace?: boolean
172
+ darkEditor?: boolean
173
+ }
174
+
175
+ export function HsTextField({
176
+ label,
177
+ required,
178
+ helpText,
179
+ value,
180
+ onChange,
181
+ multiline = false,
182
+ monospace = false,
183
+ darkEditor = false,
184
+ }: HsTextFieldProps) {
185
+ const [dark, setDark] = useState(false)
186
+ const inputClass = `w-full rounded px-3 py-2 text-sm focus:outline-none mc-input ${mcMotion.fieldControl} ${
187
+ monospace ? 'font-mono text-xs leading-relaxed' : ''
188
+ } ${dark ? 'bg-[var(--mc-bg-hard)] text-[var(--mc-text-muted)]' : mcSurface.text}`
189
+
190
+ return (
191
+ <div className="mb-4">
192
+ <HsFieldLabel label={label} required={required} helpText={helpText} />
193
+ {multiline ? (
194
+ <textarea
195
+ className={`${inputClass} min-h-[120px]`}
196
+ value={value}
197
+ onChange={(event) => onChange(event.target.value)}
198
+ />
199
+ ) : (
200
+ <input className={inputClass} value={value} onChange={(event) => onChange(event.target.value)} />
201
+ )}
202
+ {darkEditor ? (
203
+ <button
204
+ type="button"
205
+ className={`mt-2 text-sm ${mcSurface.textMuted} hover:text-[var(--mc-text)]`}
206
+ onClick={() => setDark((current) => !current)}
207
+ >
208
+ {dark ? 'Turn off dark editor' : 'Turn on dark editor'}
209
+ </button>
210
+ ) : null}
211
+ {label.toLowerCase().includes('svg') ? (
212
+ <p className={`mt-1 text-xs ${mcSurface.textMuted}`}>
213
+ You can search SVG{' '}
214
+ <a href="https://icons.hubspot.com/" target="_blank" rel="noreferrer" className={`${mcSurface.textMuted} hover:text-[var(--mc-text)]`}>
215
+ Here
216
+ </a>{' '}
217
+ and copy only the SVG start to end and paste it here.
218
+ </p>
219
+ ) : null}
220
+ </div>
221
+ )
222
+ }
223
+
224
+ export function HsNumberField({
225
+ label,
226
+ required,
227
+ helpText,
228
+ value,
229
+ onChange,
230
+ }: {
231
+ label: string
232
+ required?: boolean
233
+ helpText?: string
234
+ value: number | string
235
+ onChange: (value: number) => void
236
+ }) {
237
+ return (
238
+ <div className="mb-4">
239
+ <HsFieldLabel label={label} required={required} helpText={helpText} />
240
+ <input
241
+ type="number"
242
+ className={`w-full rounded px-3 py-2 focus:outline-none ${hsTypography.input} mc-input ${mcMotion.fieldControl}`}
243
+ value={value}
244
+ onChange={(event) => onChange(Number(event.target.value))}
245
+ />
246
+ </div>
247
+ )
248
+ }