@developer_tribe/react-builder 1.0.8 → 1.0.9

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 (216) hide show
  1. package/dist/build-components/BIcon/BIconProps.generated.d.ts +3 -0
  2. package/dist/build-components/BackgroundImage/BackgroundImageProps.generated.d.ts +1 -0
  3. package/dist/build-components/Button/ButtonProps.generated.d.ts +1 -0
  4. package/dist/build-components/Carousel/CarouselProps.generated.d.ts +5 -0
  5. package/dist/build-components/CarouselButtons/CarouselButtonsProps.generated.d.ts +1 -0
  6. package/dist/build-components/CarouselDots/CarouselDotsProps.generated.d.ts +1 -0
  7. package/dist/build-components/CarouselItem/CarouselItemProps.generated.d.ts +1 -0
  8. package/dist/build-components/CarouselProvider/CarouselProviderProps.generated.d.ts +1 -0
  9. package/dist/build-components/Image/ImageProps.generated.d.ts +1 -0
  10. package/dist/build-components/Main/MainProps.generated.d.ts +1 -1
  11. package/dist/build-components/Onboard/OnboardProps.generated.d.ts +1 -0
  12. package/dist/build-components/OnboardButton/OnboardButtonProps.generated.d.ts +1 -0
  13. package/dist/build-components/OnboardButtons/OnboardButtonsProps.generated.d.ts +1 -0
  14. package/dist/build-components/OnboardDot/OnboardDotProps.generated.d.ts +1 -0
  15. package/dist/build-components/OnboardFooter/OnboardFooterProps.generated.d.ts +3 -0
  16. package/dist/build-components/OnboardImage/OnboardImageProps.generated.d.ts +1 -0
  17. package/dist/build-components/OnboardItem/OnboardItemProps.generated.d.ts +1 -0
  18. package/dist/build-components/OnboardProvider/OnboardProviderProps.generated.d.ts +3 -0
  19. package/dist/build-components/OnboardSubtitle/OnboardSubtitleProps.generated.d.ts +3 -0
  20. package/dist/build-components/OnboardTitle/OnboardTitleProps.generated.d.ts +3 -0
  21. package/dist/build-components/PaywallBackground/PaywallBackgroundProps.generated.d.ts +1 -1
  22. package/dist/build-components/PaywallCloseButton/PaywallCloseButtonProps.generated.d.ts +3 -1
  23. package/dist/build-components/PaywallOptions/PaywallOptionsProps.generated.d.ts +1 -1
  24. package/dist/build-components/PaywallProvider/PaywallContext.d.ts +12 -0
  25. package/dist/build-components/PaywallProvider/PaywallProviderProps.generated.d.ts +1 -1
  26. package/dist/build-components/PaywallSubscribeButton/PaywallSubscribeButtonProps.generated.d.ts +1 -0
  27. package/dist/build-components/RadioButton/RadioButtonProps.generated.d.ts +1 -1
  28. package/dist/build-components/Text/TextProps.generated.d.ts +3 -0
  29. package/dist/build-components/View/ViewProps.generated.d.ts +1 -0
  30. package/dist/build-components/patterns.generated.d.ts +372 -374
  31. package/dist/components/BuilderProvider.d.ts +2 -0
  32. package/dist/components/ParamsProvider.d.ts +5 -0
  33. package/dist/components/RenderErrorBoundary.d.ts +28 -0
  34. package/dist/hooks/useSyncHtmlThemeClass.d.ts +7 -0
  35. package/dist/index.cjs.js +5 -5
  36. package/dist/index.cjs.js.map +1 -1
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.esm.js +3 -3
  39. package/dist/index.esm.js.map +1 -1
  40. package/dist/index.native.cjs.js +4 -4
  41. package/dist/index.native.cjs.js.map +1 -1
  42. package/dist/index.native.d.ts +1 -0
  43. package/dist/index.native.esm.js +4 -4
  44. package/dist/index.native.esm.js.map +1 -1
  45. package/dist/migrations/migratePipe.d.ts +14 -0
  46. package/dist/migrations/migrations/1.1.0_normalize_style_attributes.d.ts +2 -0
  47. package/dist/migrations/semver.d.ts +8 -0
  48. package/dist/migrations/types.d.ts +8 -0
  49. package/dist/mockOS/components/SubscriptionModal.d.ts +7 -0
  50. package/dist/mockOS/context/MockOSContextBase.d.ts +1 -0
  51. package/dist/mockOS/hooks/useMockIap.d.ts +3 -0
  52. package/dist/mockOS/index.d.ts +4 -0
  53. package/dist/mockOS/managers/mockOSIapManager.d.ts +6 -0
  54. package/dist/mockOS/managers/subscriptionManager.d.ts +10 -0
  55. package/dist/pages/ProjectDebug.d.ts +14 -0
  56. package/dist/pages/ProjectMigrationPage.d.ts +23 -0
  57. package/dist/pages/ProjectValidationPage.d.ts +15 -0
  58. package/dist/styles.css +1 -1
  59. package/dist/types/Device.d.ts +5 -0
  60. package/dist/utils/__special_exceptions.d.ts +7 -0
  61. package/dist/utils/getImage.d.ts +23 -0
  62. package/dist/utils/pasteNode.d.ts +15 -0
  63. package/dist/utils/patterns.d.ts +1 -2
  64. package/package.json +6 -2
  65. package/scripts/migrate-patterns-to-v2.mjs +131 -0
  66. package/scripts/migrate-samples-to-current.ts +79 -0
  67. package/scripts/prebuild/utils/createGeneratedProps.js +4 -5
  68. package/scripts/prebuild/utils/validateAllComponentsOrThrow.js +32 -21
  69. package/scripts/prebuild/utils/validatePatternJson.js +12 -10
  70. package/src/.DS_Store +0 -0
  71. package/src/AttributesEditor.tsx +41 -11
  72. package/src/RenderPage.tsx +55 -0
  73. package/src/assets/.DS_Store +0 -0
  74. package/src/assets/devices.json +91 -0
  75. package/src/assets/samples/carousel-sample.json +141 -29
  76. package/src/assets/samples/getSamples.ts +9 -0
  77. package/src/assets/samples/paywall-1.json +119 -71
  78. package/src/assets/samples/simple-1.json +28 -16
  79. package/src/assets/samples/simple-2.json +157 -82
  80. package/src/assets/samples/unmigrated-builder1.json +42 -0
  81. package/src/assets/samples/unvalidated-builder1.json +49 -0
  82. package/src/assets/samples/unvalidated-crash1.json +19 -0
  83. package/src/assets/samples/unvalidated-crashcomponent1.json +16 -0
  84. package/src/assets/samples/vpn-onboard-1.json +91 -51
  85. package/src/assets/samples/vpn-onboard-2.json +318 -278
  86. package/src/assets/samples/vpn-onboard-3.json +286 -252
  87. package/src/assets/samples/vpn-onboard-4.json +286 -252
  88. package/src/assets/samples/vpn-onboard-5.json +434 -374
  89. package/src/assets/samples/vpn-onboard-6.json +290 -250
  90. package/src/attributes-editor/Field.tsx +1 -1
  91. package/src/attributes-editor/LayoutPreviewPicker.tsx +5 -2
  92. package/src/build-components/BIcon/BIconProps.generated.ts +3 -0
  93. package/src/build-components/BIcon/pattern.json +12 -9
  94. package/src/build-components/BackgroundImage/BackgroundImage.tsx +3 -1
  95. package/src/build-components/BackgroundImage/BackgroundImageProps.generated.ts +1 -0
  96. package/src/build-components/BackgroundImage/pattern.json +25 -16
  97. package/src/build-components/Button/Button.tsx +26 -3
  98. package/src/build-components/Button/ButtonProps.generated.ts +1 -0
  99. package/src/build-components/Button/pattern.json +10 -6
  100. package/src/build-components/Carousel/CarouselProps.generated.ts +5 -0
  101. package/src/build-components/Carousel/pattern.json +19 -8
  102. package/src/build-components/CarouselButtons/CarouselButtonsProps.generated.ts +1 -0
  103. package/src/build-components/CarouselButtons/pattern.json +11 -5
  104. package/src/build-components/CarouselDots/CarouselDotsProps.generated.ts +1 -0
  105. package/src/build-components/CarouselDots/pattern.json +5 -4
  106. package/src/build-components/CarouselItem/CarouselItemProps.generated.ts +1 -0
  107. package/src/build-components/CarouselItem/pattern.json +5 -4
  108. package/src/build-components/CarouselProvider/CarouselProvider.tsx +44 -2
  109. package/src/build-components/CarouselProvider/CarouselProviderProps.generated.ts +1 -0
  110. package/src/build-components/Image/Image.tsx +2 -1
  111. package/src/build-components/Image/ImageProps.generated.ts +1 -0
  112. package/src/build-components/Image/pattern.json +11 -5
  113. package/src/build-components/Main/MainProps.generated.ts +1 -1
  114. package/src/build-components/Main/pattern.json +12 -9
  115. package/src/build-components/Onboard/OnboardProps.generated.ts +1 -0
  116. package/src/build-components/Onboard/pattern.json +14 -9
  117. package/src/build-components/OnboardButton/OnboardButtonProps.generated.ts +1 -0
  118. package/src/build-components/OnboardButton/pattern.json +5 -4
  119. package/src/build-components/OnboardButtons/OnboardButtonsProps.generated.ts +1 -0
  120. package/src/build-components/OnboardButtons/pattern.json +5 -4
  121. package/src/build-components/OnboardDot/OnboardDotProps.generated.ts +1 -0
  122. package/src/build-components/OnboardDot/pattern.json +5 -4
  123. package/src/build-components/OnboardFooter/OnboardFooterProps.generated.ts +3 -0
  124. package/src/build-components/OnboardFooter/pattern.json +8 -5
  125. package/src/build-components/OnboardImage/OnboardImageProps.generated.ts +1 -0
  126. package/src/build-components/OnboardImage/pattern.json +7 -4
  127. package/src/build-components/OnboardItem/OnboardItemProps.generated.ts +1 -0
  128. package/src/build-components/OnboardItem/pattern.json +18 -9
  129. package/src/build-components/OnboardProvider/OnboardProviderProps.generated.ts +3 -0
  130. package/src/build-components/OnboardProvider/pattern.json +21 -6
  131. package/src/build-components/OnboardSubtitle/OnboardSubtitleProps.generated.ts +3 -0
  132. package/src/build-components/OnboardSubtitle/pattern.json +10 -6
  133. package/src/build-components/OnboardTitle/OnboardTitleProps.generated.ts +3 -0
  134. package/src/build-components/OnboardTitle/pattern.json +11 -7
  135. package/src/build-components/PaywallBackground/PaywallBackgroundProps.generated.ts +1 -1
  136. package/src/build-components/PaywallBackground/pattern.json +5 -4
  137. package/src/build-components/PaywallCloseButton/PaywallCloseButton.tsx +6 -1
  138. package/src/build-components/PaywallCloseButton/PaywallCloseButtonProps.generated.ts +3 -1
  139. package/src/build-components/PaywallCloseButton/pattern.json +15 -12
  140. package/src/build-components/PaywallOptions/PaywallOptionButton.tsx +0 -1
  141. package/src/build-components/PaywallOptions/PaywallOptions.tsx +3 -2
  142. package/src/build-components/PaywallOptions/PaywallOptionsProps.generated.ts +1 -1
  143. package/src/build-components/PaywallOptions/pattern.json +14 -11
  144. package/src/build-components/PaywallProvider/PaywallContext.ts +25 -0
  145. package/src/build-components/PaywallProvider/PaywallProvider.tsx +102 -5
  146. package/src/build-components/PaywallProvider/PaywallProviderProps.generated.ts +1 -1
  147. package/src/build-components/PaywallProvider/pattern.json +11 -8
  148. package/src/build-components/PaywallSubscribeButton/PaywallSubscribeButton.tsx +7 -0
  149. package/src/build-components/PaywallSubscribeButton/PaywallSubscribeButtonProps.generated.ts +1 -0
  150. package/src/build-components/PaywallSubscribeButton/pattern.json +16 -13
  151. package/src/build-components/RadioButton/RadioButtonProps.generated.ts +1 -1
  152. package/src/build-components/RadioButton/pattern.json +5 -4
  153. package/src/build-components/Text/Text.tsx +107 -4
  154. package/src/build-components/Text/TextProps.generated.ts +3 -0
  155. package/src/build-components/Text/pattern.json +19 -4
  156. package/src/build-components/View/ViewProps.generated.ts +1 -0
  157. package/src/build-components/View/pattern.json +28 -13
  158. package/src/build-components/other.tsx +15 -0
  159. package/src/build-components/patterns.generated.ts +340 -235
  160. package/src/build-components/useNode.ts +22 -3
  161. package/src/components/Builder.tsx +20 -6
  162. package/src/components/BuilderButton.tsx +75 -38
  163. package/src/components/BuilderProvider.tsx +22 -2
  164. package/src/components/DeviceButton.tsx +12 -5
  165. package/src/components/EditorHeader.tsx +296 -38
  166. package/src/components/ParamsProvider.tsx +7 -0
  167. package/src/components/RenderErrorBoundary.tsx +200 -0
  168. package/src/hooks/useParams.ts +5 -1
  169. package/src/hooks/useSyncHtmlThemeClass.ts +19 -0
  170. package/src/index.native.ts +7 -0
  171. package/src/index.ts +8 -0
  172. package/src/migrations/migratePipe.ts +59 -0
  173. package/src/migrations/migrations/1.1.0_normalize_style_attributes.ts +80 -0
  174. package/src/migrations/semver.ts +24 -0
  175. package/src/migrations/types.ts +9 -0
  176. package/src/mockOS/components/PermissionModal.tsx +3 -2
  177. package/src/mockOS/components/SubscriptionModal.tsx +400 -0
  178. package/src/mockOS/context/MockOSContext.tsx +61 -10
  179. package/src/mockOS/context/MockOSContextBase.ts +1 -0
  180. package/src/mockOS/hooks/useMockIap.ts +11 -0
  181. package/src/mockOS/index.ts +7 -0
  182. package/src/mockOS/managers/mockOSIapManager.ts +10 -0
  183. package/src/mockOS/managers/subscriptionManager.ts +36 -0
  184. package/src/modals/IconPickerModal.tsx +1 -1
  185. package/src/pages/ProjectDebug.tsx +331 -0
  186. package/src/pages/ProjectMigrationPage.tsx +92 -0
  187. package/src/pages/ProjectPage.tsx +313 -161
  188. package/src/pages/ProjectValidationPage.tsx +54 -0
  189. package/src/styles/base/_global.scss +58 -11
  190. package/src/styles/components/_attributes-editor.scss +1 -1
  191. package/src/styles/components/_bottom-bar.scss +7 -4
  192. package/src/styles/components/_editor-shell.scss +126 -4
  193. package/src/styles/components/_mockos-router.scss +3 -2
  194. package/src/styles/components/_ui-components.scss +10 -5
  195. package/src/styles/foundation/_colors.scss +78 -11
  196. package/src/styles/foundation/_mixins.scss +4 -1
  197. package/src/styles/foundation/_sizes.scss +4 -2
  198. package/src/styles/index.scss +1 -0
  199. package/src/styles/layout/_builder.scss +61 -0
  200. package/src/styles/layout/_project-validation.scss +214 -0
  201. package/src/styles/modals/_add-component.scss +4 -2
  202. package/src/styles/modals/_color-modal.scss +4 -2
  203. package/src/styles/modals/_modal-shell.scss +3 -1
  204. package/src/types/Device.ts +5 -0
  205. package/src/utils/__special_exceptions.ts +88 -0
  206. package/src/utils/analyseNode.ts +8 -2
  207. package/src/utils/analyseNodeByPatterns.ts +43 -9
  208. package/src/utils/extractTextStyle.ts +19 -6
  209. package/src/utils/extractViewStyle.ts +68 -59
  210. package/src/utils/getImage.ts +76 -0
  211. package/src/utils/novaToJson.ts +2 -1
  212. package/src/utils/pasteNode.ts +172 -0
  213. package/src/utils/patterns.ts +4 -3
  214. package/dist/android.svg +0 -43
  215. package/dist/apple.svg +0 -16
  216. package/dist/background.jpg +0 -0
@@ -7,9 +7,28 @@ export default function useNode<
7
7
  const type = node?.type;
8
8
  const defaults = getDefaultsForType(type) as Partial<T> | undefined;
9
9
  if (!defaults) return node;
10
- const mergedAttributes: T = {
11
- ...(defaults as T),
12
- ...((node.attributes as T) ?? ({} as T)),
10
+ const nodeAttributes = ((node.attributes as T) ?? ({} as T)) as T & {
11
+ style?: Record<string, unknown>;
12
+ };
13
+ const defaultAttributes = defaults as T as T & {
14
+ style?: Record<string, unknown>;
13
15
  };
16
+ const mergedAttributes: T = {
17
+ ...(defaultAttributes as T),
18
+ ...(nodeAttributes as T),
19
+ // Deep merge `style` so default style values aren't lost when the node provides partial style overrides.
20
+ style: {
21
+ ...(defaultAttributes?.style ?? {}),
22
+ ...(nodeAttributes?.style ?? {}),
23
+ },
24
+ } as T;
25
+ if (
26
+ mergedAttributes &&
27
+ typeof (mergedAttributes as any).style === 'object' &&
28
+ (mergedAttributes as any).style != null &&
29
+ Object.keys((mergedAttributes as any).style).length === 0
30
+ ) {
31
+ delete (mergedAttributes as any).style;
32
+ }
14
33
  return { ...node, attributes: mergedAttributes };
15
34
  }
@@ -386,23 +386,37 @@ export function Builder({
386
386
  function createDefaultNode(type: string): NodeData<NodeDefaultAttribute> {
387
387
  const pattern = getPatternByType(type)?.pattern;
388
388
  const defaults = getDefaultsForType(type) ?? {};
389
- let children: Node = '';
390
389
  const childrenSchema = pattern?.children as unknown;
390
+
391
+ // Special-case: CarouselProvider MUST contain a Carousel container inside the viewport
392
+ // otherwise embla-carousel will crash (it expects viewport.firstChild.children).
393
+ if (type === 'CarouselProvider') {
394
+ return {
395
+ type,
396
+ children: createDefaultNode('Carousel'),
397
+ attributes: { ...defaults },
398
+ } as NodeData<NodeDefaultAttribute>;
399
+ }
400
+
401
+ let children: Node = null;
391
402
  if (childrenSchema === 'never') {
392
- children = '';
403
+ children = null;
393
404
  } else if (childrenSchema === 'string') {
394
405
  children = '';
395
406
  } else if (
396
407
  childrenSchema === 'node' ||
397
408
  (Array.isArray(childrenSchema) && childrenSchema.includes('node'))
398
409
  ) {
399
- children = [];
410
+ // Default to "no children yet". Using [] here is truthy and can mount
411
+ // child-dependent widgets (e.g. Embla) with an empty DOM, causing crashes.
412
+ children = null;
400
413
  } else if (typeof childrenSchema === 'string') {
401
- // Specific child type like 'carouselItem' – initialize as empty array to allow multiple
402
- children = [];
414
+ // Specific child type like 'CarouselItem' – seed with one child to match the pattern.
415
+ children = [createDefaultNode(childrenSchema)];
403
416
  } else {
404
- children = '';
417
+ children = null;
405
418
  }
419
+
406
420
  return {
407
421
  type,
408
422
  children,
@@ -1,7 +1,8 @@
1
- import { useRef } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { isNodeNullOrUndefined, isNodeString } from '../utils/analyseNode';
3
3
  import type { Node, NodeData, NodeDefaultAttribute } from '../types/Node';
4
4
  import { getPatternByType } from '../utils/patterns';
5
+ import { Icon } from './Icon.generated';
5
6
 
6
7
  export type BuilderButtonProps = {
7
8
  node: Node;
@@ -26,8 +27,12 @@ export function BuilderButton({
26
27
  }
27
28
  const nodeData = node as NodeData<NodeDefaultAttribute>;
28
29
 
29
- const longPressTimeoutRef = useRef<number | null>(null);
30
- const longPressTriggeredRef = useRef(false);
30
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
31
+ const actionsRef = useRef<HTMLDivElement | null>(null);
32
+ const menuId = useMemo(
33
+ () => `builder-node-actions-${Math.random().toString(36).slice(2, 9)}`,
34
+ [],
35
+ );
31
36
 
32
37
  const handleDelete = () => {
33
38
  if (onDelete) {
@@ -35,34 +40,31 @@ export function BuilderButton({
35
40
  }
36
41
  };
37
42
 
38
- const clearLongPress = () => {
39
- if (longPressTimeoutRef.current !== null) {
40
- window.clearTimeout(longPressTimeoutRef.current);
41
- longPressTimeoutRef.current = null;
42
- }
43
- };
43
+ // Copy/Paste intentionally removed for now.
44
44
 
45
- const handlePressStart = () => {
46
- longPressTriggeredRef.current = false;
47
- longPressTimeoutRef.current = window.setTimeout(() => {
48
- longPressTriggeredRef.current = true;
49
- const shouldDelete = window.confirm('Do you want to delete');
50
- if (shouldDelete) {
51
- handleDelete();
52
- }
53
- }, 600);
54
- };
45
+ useEffect(() => {
46
+ if (!isMenuOpen) return;
55
47
 
56
- const handlePressEnd = () => {
57
- if (!longPressTriggeredRef.current) {
58
- onClick();
59
- }
60
- clearLongPress();
61
- };
48
+ const handlePointerDown = (e: MouseEvent | TouchEvent) => {
49
+ const el = actionsRef.current;
50
+ if (!el) return;
51
+ if (e.target instanceof Element && el.contains(e.target)) return;
52
+ setIsMenuOpen(false);
53
+ };
62
54
 
63
- const handlePressCancel = () => {
64
- clearLongPress();
65
- };
55
+ const handleKeyDown = (e: KeyboardEvent) => {
56
+ if (e.key === 'Escape') setIsMenuOpen(false);
57
+ };
58
+
59
+ document.addEventListener('mousedown', handlePointerDown);
60
+ document.addEventListener('touchstart', handlePointerDown);
61
+ document.addEventListener('keydown', handleKeyDown);
62
+ return () => {
63
+ document.removeEventListener('mousedown', handlePointerDown);
64
+ document.removeEventListener('touchstart', handlePointerDown);
65
+ document.removeEventListener('keydown', handleKeyDown);
66
+ };
67
+ }, [isMenuOpen]);
66
68
 
67
69
  let extra = '';
68
70
  if (nodeData.attributes?.condition) {
@@ -103,17 +105,52 @@ export function BuilderButton({
103
105
  </button>
104
106
  </div>
105
107
  )}
106
- <a
107
- className="builder__button-link"
108
- onMouseDown={handlePressStart}
109
- onMouseUp={handlePressEnd}
110
- onMouseLeave={handlePressCancel}
111
- onTouchStart={handlePressStart}
112
- onTouchEnd={handlePressEnd}
113
- onTouchCancel={handlePressCancel}
114
- >
108
+ <button type="button" className="builder__button-link" onClick={onClick}>
115
109
  {baseLabel}
116
- </a>
110
+ </button>
111
+ <div className="builder__button-actions" ref={actionsRef}>
112
+ <button
113
+ type="button"
114
+ className="builder__button-actions-trigger"
115
+ aria-label="Open node actions"
116
+ aria-haspopup="menu"
117
+ aria-expanded={isMenuOpen}
118
+ aria-controls={menuId}
119
+ onClick={(event) => {
120
+ event.stopPropagation();
121
+ setIsMenuOpen((v) => !v);
122
+ }}
123
+ >
124
+ <Icon iconType="chevron-right" size={16} />
125
+ </button>
126
+ {isMenuOpen && (
127
+ <ul
128
+ id={menuId}
129
+ className="builder__button-actions-menu"
130
+ role="menu"
131
+ aria-label="Node actions"
132
+ >
133
+ <li role="none">
134
+ <button
135
+ type="button"
136
+ className="builder__button-actions-item builder__button-actions-item--danger"
137
+ role="menuitem"
138
+ disabled={!onDelete}
139
+ onClick={(event) => {
140
+ event.stopPropagation();
141
+ if (!onDelete) return;
142
+ const shouldDelete = window.confirm('Do you want to delete?');
143
+ if (!shouldDelete) return;
144
+ handleDelete();
145
+ setIsMenuOpen(false);
146
+ }}
147
+ >
148
+ Delete
149
+ </button>
150
+ </li>
151
+ </ul>
152
+ )}
153
+ </div>
117
154
  {conditionLabel && (
118
155
  <span className="builder__button-condition">{conditionLabel}</span>
119
156
  )}
@@ -1,6 +1,7 @@
1
1
  import React, { createContext, useContext, useMemo } from 'react';
2
2
  import type { Product } from '../paywall/types/paywall-types';
3
3
  import type { PaywallBenefits } from '../paywall/types/benefits';
4
+ import { RenderErrorBoundary } from './RenderErrorBoundary';
4
5
 
5
6
  // NOTE: We keep this context intentionally tiny.
6
7
  // IMPORTANT: This provider may be mounted once but consumed by multiple `build-components`
@@ -12,6 +13,8 @@ export type Products = Product;
12
13
  export type BuilderProviderParams = {
13
14
  products: Products[];
14
15
  benefits: PaywallBenefits;
16
+ onPaywallClose?: () => void;
17
+ onPaywallSubscribe?: (product?: Product) => void | boolean | Promise<boolean>;
15
18
  };
16
19
 
17
20
  type BuilderProviderProps = {
@@ -31,12 +34,29 @@ export function BuilderProvider({ params, children }: BuilderProviderProps) {
31
34
  params?.benefits && typeof params.benefits === 'object'
32
35
  ? (params.benefits as PaywallBenefits)
33
36
  : {},
37
+ onPaywallClose:
38
+ typeof params?.onPaywallClose === 'function'
39
+ ? params.onPaywallClose
40
+ : undefined,
41
+ onPaywallSubscribe:
42
+ typeof params?.onPaywallSubscribe === 'function'
43
+ ? params.onPaywallSubscribe
44
+ : undefined,
34
45
  }),
35
- [params?.benefits, params?.products],
46
+ [
47
+ params?.benefits,
48
+ params?.products,
49
+ params?.onPaywallClose,
50
+ params?.onPaywallSubscribe,
51
+ ],
36
52
  );
37
53
 
38
54
  return (
39
- <BuilderContext.Provider value={value}>{children}</BuilderContext.Provider>
55
+ <BuilderContext.Provider value={value}>
56
+ <RenderErrorBoundary subtitle="caught by BuilderProvider">
57
+ {children}
58
+ </RenderErrorBoundary>
59
+ </BuilderContext.Provider>
40
60
  );
41
61
  }
42
62
 
@@ -1,11 +1,10 @@
1
1
  import React from 'react';
2
2
  import { Device } from '../types/Device';
3
- import androidIcon from '../assets/images/android.svg';
4
- import iosIcon from '../assets/images/apple.svg';
3
+ import { getImage, TribeAssetName } from '../utils/getImage';
5
4
 
6
5
  const platformIcons: Record<string, string> = {
7
- android: androidIcon,
8
- ios: iosIcon,
6
+ android: getImage(TribeAssetName.Android),
7
+ ios: getImage(TribeAssetName.Apple),
9
8
  };
10
9
 
11
10
  type DeviceButtonProps = {
@@ -20,6 +19,14 @@ export function DeviceButton({
20
19
  onSelect,
21
20
  }: DeviceButtonProps) {
22
21
  const platformIcon = platformIcons[device.platform];
22
+ const aspect =
23
+ device.aspect ??
24
+ (() => {
25
+ const r = device.height / device.width;
26
+ if (r >= 2.05) return 'tall' as const;
27
+ if (r <= 1.75) return 'wide' as const;
28
+ return 'regular' as const;
29
+ })();
23
30
 
24
31
  return (
25
32
  <button
@@ -30,7 +37,7 @@ export function DeviceButton({
30
37
  onClick={() => onSelect(device)}
31
38
  >
32
39
  {device.name} <br />
33
- {device.width}x{device.height}
40
+ {device.width}×{device.height} ({aspect})
34
41
  {platformIcon && <img src={platformIcon} alt="" aria-hidden="true" />}
35
42
  </button>
36
43
  );
@@ -1,4 +1,4 @@
1
- import { useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { Device } from '../types/Device';
3
3
  import type { Node } from '../types/Node';
4
4
  import { copyNode } from '../utils/copyNode';
@@ -7,6 +7,9 @@ import { useRenderStore } from '../store';
7
7
  import { useLogRender } from '../utils/useLogRender';
8
8
  import { DeviceButton } from './DeviceButton';
9
9
  import { DeviceSelectorModal } from '../modals/DeviceSelectorModal';
10
+ import { toast } from 'react-toastify';
11
+ import { getSamples } from '../assets/samples/getSamples';
12
+ import type { Project } from '../types/Project';
10
13
 
11
14
  const devices = getDevices();
12
15
  interface EditorHeaderProps {
@@ -26,17 +29,67 @@ export function EditorHeader({
26
29
  }: EditorHeaderProps) {
27
30
  useLogRender('EditorHeader');
28
31
  const [isDevicesModalOpen, setIsDevicesModalOpen] = useState(false);
32
+ const [isActionsOpen, setIsActionsOpen] = useState(false);
33
+ const actionsRef = useRef<HTMLDivElement | null>(null);
29
34
  const copiedNode = useRenderStore((s) => s.copiedNode);
30
35
  const {
31
36
  device: selectedDevice,
32
37
  setDevice,
33
38
  setCurrent,
39
+ setAppConfig,
40
+ setProjectColors,
34
41
  } = useRenderStore((s) => ({
35
42
  device: s.device,
36
43
  setDevice: s.setDevice,
37
44
  setCurrent: s.setCurrent,
45
+ setAppConfig: s.setAppConfig,
46
+ setProjectColors: s.setProjectColors,
38
47
  }));
39
48
 
49
+ const sortedSamples = useMemo(() => {
50
+ const weight = (t?: Project['type']) => {
51
+ if (t === 'paywall') return 0;
52
+ if (t === 'onboard') return 1;
53
+ return 2;
54
+ };
55
+ return getSamples()
56
+ .slice()
57
+ .sort((a, b) => {
58
+ const w = weight(a.type) - weight(b.type);
59
+ if (w !== 0) return w;
60
+ return a.name.localeCompare(b.name);
61
+ });
62
+ }, []);
63
+
64
+ const actionsMenuId = useMemo(
65
+ () => `editor-actions-menu-${Math.random().toString(36).slice(2, 9)}`,
66
+ [],
67
+ );
68
+
69
+ useEffect(() => {
70
+ if (!isActionsOpen) return;
71
+
72
+ const handlePointerDown = (e: MouseEvent | TouchEvent) => {
73
+ const el = actionsRef.current;
74
+ if (!el) return;
75
+ if (e.target instanceof Element && el.contains(e.target)) return;
76
+ setIsActionsOpen(false);
77
+ };
78
+
79
+ const handleKeyDown = (e: KeyboardEvent) => {
80
+ if (e.key === 'Escape') setIsActionsOpen(false);
81
+ };
82
+
83
+ document.addEventListener('mousedown', handlePointerDown);
84
+ document.addEventListener('touchstart', handlePointerDown);
85
+ document.addEventListener('keydown', handleKeyDown);
86
+ return () => {
87
+ document.removeEventListener('mousedown', handlePointerDown);
88
+ document.removeEventListener('touchstart', handlePointerDown);
89
+ document.removeEventListener('keydown', handleKeyDown);
90
+ };
91
+ }, [isActionsOpen]);
92
+
40
93
  function replaceNode(root: Node, target: Node, next: Node): Node {
41
94
  if (root === target) return next;
42
95
  if (root === null || root === undefined) return root;
@@ -50,16 +103,13 @@ export function EditorHeader({
50
103
  });
51
104
  return changed ? arr : root;
52
105
  }
53
- const data = root as any;
106
+ const data = root as Record<string, unknown>;
54
107
  if ('children' in data) {
55
- const prev = data.children;
108
+ const prev = data.children as Node;
56
109
  const replaced = Array.isArray(prev)
57
- ? prev.map((c: Node) => replaceNode(c, target, next))
58
- : replaceNode(prev as Node, target, next);
59
- if (replaced !== prev) {
60
- data.children = replaced;
61
- return { ...data, children: replaced } as Node;
62
- }
110
+ ? prev.map((c) => replaceNode(c, target, next))
111
+ : replaceNode(prev, target, next);
112
+ if (replaced !== prev) return { ...data, children: replaced } as Node;
63
113
  }
64
114
  return root;
65
115
  }
@@ -81,6 +131,104 @@ export function EditorHeader({
81
131
  // in sync with what’s rendered/edited.
82
132
  setCurrent(cloned);
83
133
  };
134
+
135
+ const cloneNode = (node: Node): Node =>
136
+ JSON.parse(JSON.stringify(node)) as Node;
137
+
138
+ const handleReplaceFromSample = (sample: Project) => {
139
+ if (!setEditorData) return;
140
+ const next = cloneNode(sample.data);
141
+ setEditorData(next);
142
+ setCurrent(next);
143
+ if (sample.appConfig) setAppConfig(sample.appConfig);
144
+ setProjectColors(sample.projectColors);
145
+ toast.success(`Replaced with "${sample.name}"`);
146
+ };
147
+
148
+ const handlePasteFromSample = (sample: Project) => {
149
+ if (!current || !editorData || !setEditorData) return;
150
+ const incoming = cloneNode(sample.data);
151
+
152
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
153
+ typeof v === 'object' && v !== null && !Array.isArray(v);
154
+
155
+ const isNodeData = (v: unknown): v is Record<string, unknown> =>
156
+ isRecord(v) && 'type' in v && 'children' in v;
157
+
158
+ const getPasteNodes = (node: Node): Node[] => {
159
+ if (isNodeData(node)) {
160
+ const t = node.type;
161
+ const isMainLike = t === 'main' || node.isMain === true;
162
+ if (isMainLike) {
163
+ const ch = node.children as Node;
164
+ if (!ch) return [];
165
+ return Array.isArray(ch) ? ch : [ch];
166
+ }
167
+ }
168
+ return [node];
169
+ };
170
+
171
+ // "Paste from sample" should ADD the sample into the selected node (not replace it).
172
+ // We do this by appending the sample's root node into current.children.
173
+ if (!isNodeData(current)) {
174
+ toast.error('Select a component node to paste into');
175
+ return;
176
+ }
177
+
178
+ const prevChildren = current.children as Node;
179
+ const pasteNodes = getPasteNodes(incoming);
180
+ if (pasteNodes.length === 0) {
181
+ toast.error('Sample has no children to paste');
182
+ return;
183
+ }
184
+ let nextChildren: Node;
185
+ if (!prevChildren) {
186
+ nextChildren = pasteNodes.length === 1 ? pasteNodes[0] : pasteNodes;
187
+ } else if (Array.isArray(prevChildren)) {
188
+ nextChildren = [...prevChildren, ...pasteNodes];
189
+ } else {
190
+ nextChildren = [prevChildren, ...pasteNodes];
191
+ }
192
+
193
+ const nextNode: Node = { ...current, children: nextChildren } as Node;
194
+
195
+ const updated = replaceNode(editorData, current, nextNode);
196
+ setEditorData(updated);
197
+ setCurrent(nextNode);
198
+ toast.success(`Pasted from "${sample.name}"`);
199
+ };
200
+
201
+ const closeActions = () => setIsActionsOpen(false);
202
+ const handleSaveProject = () => {
203
+ onSaveProject?.();
204
+ closeActions();
205
+ };
206
+ const handleRestoreProject = () => {
207
+ try {
208
+ onRestoreProject?.();
209
+ toast.info('Restored');
210
+ } catch {
211
+ toast.error('Restore failed');
212
+ } finally {
213
+ closeActions();
214
+ }
215
+ };
216
+ const handleCopyAndClose = () => {
217
+ if (!canCopy) return;
218
+ handleCopy();
219
+ toast.info('Copied');
220
+ closeActions();
221
+ };
222
+ const handlePasteAndClose = () => {
223
+ if (!canPaste) return;
224
+ handlePaste();
225
+ toast.success('Pasted');
226
+ closeActions();
227
+ };
228
+
229
+ const canCopy = !!current;
230
+ const canPaste = !!current && !!editorData && !!setEditorData && !!copiedNode;
231
+
84
232
  return (
85
233
  <div
86
234
  className="editor-header"
@@ -105,38 +253,148 @@ export function EditorHeader({
105
253
  </button>
106
254
  </div>
107
255
  <div className="editor-header__actions">
108
- <button
109
- className="editor-button editor-save-button"
110
- aria-label="Save project data"
111
- onClick={() => onSaveProject && onSaveProject()}
112
- >
113
- Save
114
- </button>
115
- {onRestoreProject && (
116
- <button
117
- className="editor-button editor-save-previewconfig-button"
118
- aria-label="Restore project data"
119
- onClick={() => onRestoreProject()}
120
- >
121
- Restore
122
- </button>
123
- )}
124
- <button
125
- className="editor-button"
126
- aria-label="Copy node"
127
- onClick={handleCopy}
128
- >
129
- Copy
130
- </button>
131
- {copiedNode && (
256
+ <div className="editor-actions-dropdown" ref={actionsRef}>
132
257
  <button
133
- className="editor-button"
134
- aria-label="Paste node"
135
- onClick={handlePaste}
258
+ className="editor-button editor-actions-dropdown__trigger"
259
+ aria-label="Open actions menu"
260
+ aria-haspopup="menu"
261
+ aria-expanded={isActionsOpen}
262
+ aria-controls={actionsMenuId}
263
+ onClick={() => setIsActionsOpen((v) => !v)}
136
264
  >
137
- Paste
265
+ Actions
266
+ <span className="editor-actions-dropdown__caret" aria-hidden="true">
267
+
268
+ </span>
138
269
  </button>
139
- )}
270
+ {isActionsOpen && (
271
+ <ul
272
+ id={actionsMenuId}
273
+ className="editor-actions-dropdown__menu"
274
+ role="menu"
275
+ aria-label="Editor actions"
276
+ >
277
+ <li role="none" className="editor-actions-dropdown__submenu-root">
278
+ <button
279
+ className="editor-actions-dropdown__item"
280
+ role="menuitem"
281
+ aria-haspopup="menu"
282
+ aria-label="Replace from samples"
283
+ onClick={(e) => e.preventDefault()}
284
+ >
285
+ Replace from samples
286
+ <span
287
+ className="editor-actions-dropdown__submenu-caret"
288
+ aria-hidden="true"
289
+ >
290
+
291
+ </span>
292
+ </button>
293
+ <ul
294
+ className="editor-actions-dropdown__submenu"
295
+ role="menu"
296
+ aria-label="Replace from sample"
297
+ >
298
+ {sortedSamples.map((s) => (
299
+ <li role="none" key={`replace-${s.name}`}>
300
+ <button
301
+ className="editor-actions-dropdown__item editor-actions-dropdown__item--compact"
302
+ role="menuitem"
303
+ onClick={() => {
304
+ handleReplaceFromSample(s);
305
+ closeActions();
306
+ }}
307
+ >
308
+ {s.name}
309
+ </button>
310
+ </li>
311
+ ))}
312
+ </ul>
313
+ </li>
314
+ <li role="none" className="editor-actions-dropdown__submenu-root">
315
+ <button
316
+ className="editor-actions-dropdown__item"
317
+ role="menuitem"
318
+ aria-haspopup="menu"
319
+ aria-label="Paste from samples"
320
+ onClick={(e) => e.preventDefault()}
321
+ disabled={!current}
322
+ title={!current ? 'Select a node first' : undefined}
323
+ >
324
+ Paste from samples
325
+ <span
326
+ className="editor-actions-dropdown__submenu-caret"
327
+ aria-hidden="true"
328
+ >
329
+
330
+ </span>
331
+ </button>
332
+ <ul
333
+ className="editor-actions-dropdown__submenu"
334
+ role="menu"
335
+ aria-label="Paste from sample"
336
+ >
337
+ {sortedSamples.map((s) => (
338
+ <li role="none" key={`paste-${s.name}`}>
339
+ <button
340
+ className="editor-actions-dropdown__item editor-actions-dropdown__item--compact"
341
+ role="menuitem"
342
+ onClick={() => {
343
+ handlePasteFromSample(s);
344
+ closeActions();
345
+ }}
346
+ disabled={!current}
347
+ >
348
+ {s.name}
349
+ </button>
350
+ </li>
351
+ ))}
352
+ </ul>
353
+ </li>
354
+ <li role="none">
355
+ <button
356
+ className="editor-actions-dropdown__item editor-save-button"
357
+ role="menuitem"
358
+ onClick={handleSaveProject}
359
+ disabled={!onSaveProject}
360
+ >
361
+ Save
362
+ </button>
363
+ </li>
364
+ {onRestoreProject && (
365
+ <li role="none">
366
+ <button
367
+ className="editor-actions-dropdown__item editor-save-previewconfig-button"
368
+ role="menuitem"
369
+ onClick={handleRestoreProject}
370
+ >
371
+ Restore
372
+ </button>
373
+ </li>
374
+ )}
375
+ <li role="none">
376
+ <button
377
+ className="editor-actions-dropdown__item"
378
+ role="menuitem"
379
+ onClick={handleCopyAndClose}
380
+ disabled={!canCopy}
381
+ >
382
+ Copy
383
+ </button>
384
+ </li>
385
+ <li role="none">
386
+ <button
387
+ className="editor-actions-dropdown__item"
388
+ role="menuitem"
389
+ onClick={handlePasteAndClose}
390
+ disabled={!canPaste}
391
+ >
392
+ Paste
393
+ </button>
394
+ </li>
395
+ </ul>
396
+ )}
397
+ </div>
140
398
  </div>
141
399
  {isDevicesModalOpen && (
142
400
  <DeviceSelectorModal