@developer_tribe/react-builder 1.2.18 → 1.2.20

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 (62) hide show
  1. package/dist/build-components/OnboardDot/OnboardDotProps.generated.d.ts +2 -1
  2. package/dist/build-components/patterns.generated.d.ts +23 -8
  3. package/dist/index.cjs.js +3 -3
  4. package/dist/index.cjs.js.map +1 -1
  5. package/dist/index.esm.js +1 -1
  6. package/dist/index.esm.js.map +1 -1
  7. package/dist/index.web.cjs.js +4 -4
  8. package/dist/index.web.cjs.js.map +1 -1
  9. package/dist/index.web.esm.js +3 -3
  10. package/dist/index.web.esm.js.map +1 -1
  11. package/dist/pages/ProjectDebug.d.ts +9 -1
  12. package/dist/pages/ProjectMigrationPage.d.ts +3 -1
  13. package/dist/pages/ProjectValidationPage.d.ts +3 -2
  14. package/dist/styles.css +1 -1
  15. package/dist/utils/applyJsonTransform.d.ts +13 -0
  16. package/dist/utils/repairNodeKeys.d.ts +11 -0
  17. package/dist/utils/safeJsonStringify.d.ts +1 -0
  18. package/dist/utils/wrapNodeInMain.d.ts +2 -0
  19. package/package.json +1 -1
  20. package/src/RenderPage.tsx +17 -46
  21. package/src/assets/meta.json +1 -1
  22. package/src/assets/samples/carousel-sample.json +51 -51
  23. package/src/assets/samples/paywall-1.json +77 -77
  24. package/src/assets/samples/paywall-2.json +76 -76
  25. package/src/assets/samples/simple-1.json +13 -13
  26. package/src/assets/samples/simple-2.json +97 -97
  27. package/src/assets/samples/unmigrated-builder-1.1.1.json +25 -25
  28. package/src/assets/samples/unmigrated-builder1.json +1 -1
  29. package/src/assets/samples/unvalidated-builder1.json +15 -15
  30. package/src/assets/samples/unvalidated-crash1.json +4 -4
  31. package/src/assets/samples/vpn-onboard-1.json +100 -78
  32. package/src/assets/samples/vpn-onboard-2.json +97 -75
  33. package/src/assets/samples/vpn-onboard-3.json +103 -79
  34. package/src/assets/samples/vpn-onboard-4.json +103 -79
  35. package/src/assets/samples/vpn-onboard-5.json +139 -108
  36. package/src/assets/samples/vpn-onboard-6.json +100 -81
  37. package/src/build-components/CarouselDots/CarouselDots.tsx +112 -12
  38. package/src/build-components/OnboardDot/OnboardDot.tsx +74 -40
  39. package/src/build-components/OnboardDot/OnboardDotProps.generated.ts +2 -1
  40. package/src/build-components/OnboardDot/pattern.json +28 -10
  41. package/src/build-components/PaywallProvider/PaywallProvider.tsx +2 -3
  42. package/src/build-components/Text/Text.tsx +4 -9
  43. package/src/build-components/patterns.generated.ts +23 -8
  44. package/src/build-components/useNode.ts +20 -4
  45. package/src/components/AttributesEditorPanel.tsx +13 -1
  46. package/src/components/Builder.tsx +19 -5
  47. package/src/components/EditorHeader.tsx +16 -6
  48. package/src/components/JsonTextEditor.tsx +41 -0
  49. package/src/pages/DebugJsonPage.tsx +104 -4
  50. package/src/pages/ProjectDebug.tsx +66 -28
  51. package/src/pages/ProjectMigrationPage.tsx +15 -0
  52. package/src/pages/ProjectPage.tsx +160 -23
  53. package/src/pages/ProjectValidationPage.tsx +64 -1
  54. package/src/styles/layout/_project-validation.scss +29 -0
  55. package/src/styles/utilities/_carousel.scss +0 -32
  56. package/src/utils/__special_exceptions.ts +9 -3
  57. package/src/utils/analyseNodeByPatterns.ts +16 -6
  58. package/src/utils/applyJsonTransform.ts +19 -0
  59. package/src/utils/novaToJson.ts +7 -3
  60. package/src/utils/repairNodeKeys.ts +90 -0
  61. package/src/utils/safeJsonStringify.ts +18 -0
  62. package/src/utils/wrapNodeInMain.ts +67 -0
@@ -1,10 +1,17 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import type { Node } from '../types/Node';
3
+ import type { NodeData, NodeDefaultAttribute } from '../types/Node';
3
4
  import type { AppConfig } from '../types/PreviewConfig';
4
5
  import { Checkbox } from '../components/Checkbox';
5
6
  import { JsonTextEditor } from '../components/JsonTextEditor';
6
7
  import { analyseAndProccess } from '../utils/analyseNode';
7
8
  import { logRenderStore } from '../utils/logRenderStore';
9
+ import { useRenderStore } from '../store';
10
+ import {
11
+ isNodeArray,
12
+ isNodeNullOrUndefined,
13
+ isNodeString,
14
+ } from '../utils/nodeGuards';
8
15
 
9
16
  export type DebugJsonPageProps = {
10
17
  data: Node | null | undefined;
@@ -36,6 +43,7 @@ export function DebugJsonPage({
36
43
  setAppConfig,
37
44
  logLabel,
38
45
  }: DebugJsonPageProps) {
46
+ const setCurrent = useRenderStore((s) => s.setCurrent);
39
47
  const canTogglePreviewMode = typeof setPreviewMode === 'function';
40
48
  const canToggleTheme =
41
49
  typeof setAppConfig === 'function' && typeof appConfig?.theme === 'string';
@@ -43,6 +51,81 @@ export function DebugJsonPage({
43
51
  typeof setAppConfig === 'function' &&
44
52
  typeof (appConfig as any)?.isRtl !== 'undefined';
45
53
 
54
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
55
+ typeof v === 'object' && v !== null && !Array.isArray(v);
56
+
57
+ const extractNode = (value: unknown): Node => {
58
+ // Allow both:
59
+ // - raw Node JSON
60
+ // - Project wrapper JSON { name, version, data: Node }
61
+ if (isRecord(value) && 'data' in value) {
62
+ return (value as any).data as Node;
63
+ }
64
+ return value as Node;
65
+ };
66
+
67
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ function migrateStyleToStyles(node: Node): Node {
72
+ if (isNodeNullOrUndefined(node) || isNodeString(node)) {
73
+ return node;
74
+ }
75
+ if (isNodeArray(node)) {
76
+ const arr = node as Node[];
77
+ return arr.map((n) => migrateStyleToStyles(n));
78
+ }
79
+ if (!isPlainObject(node)) {
80
+ return node;
81
+ }
82
+
83
+ const record = node as unknown as NodeData<NodeDefaultAttribute>;
84
+ const nextChildren = migrateStyleToStyles(record.children);
85
+
86
+ if (!isPlainObject(record.attributes)) {
87
+ return { ...record, children: nextChildren };
88
+ }
89
+
90
+ const attrs = { ...record.attributes };
91
+ if ('style' in attrs && isPlainObject(attrs.style)) {
92
+ attrs.styles = attrs.style;
93
+ delete attrs.style;
94
+ }
95
+
96
+ return {
97
+ ...record,
98
+ children: nextChildren,
99
+ attributes: attrs,
100
+ };
101
+ }
102
+
103
+ const hasStyleAttribute = useMemo(() => {
104
+ function checkNode(n: Node): boolean {
105
+ if (isNodeNullOrUndefined(n) || isNodeString(n)) return false;
106
+ if (isNodeArray(n)) {
107
+ const arr = n as Node[];
108
+ return arr.some(checkNode);
109
+ }
110
+ if (!isPlainObject(n)) return false;
111
+ const record = n as unknown as NodeData<NodeDefaultAttribute>;
112
+ if (isPlainObject(record.attributes) && 'style' in record.attributes) {
113
+ return true;
114
+ }
115
+ return checkNode(record.children);
116
+ }
117
+ return checkNode(data);
118
+ }, [data]);
119
+
120
+ const handleFixStyleToStyles = () => {
121
+ if (!data) return;
122
+ const migrated = migrateStyleToStyles(data);
123
+ // Don't call analyseAndProccess here - let the user apply changes manually
124
+ // This avoids validation errors for unknown attributes that may exist
125
+ setData(migrated as Node);
126
+ setCurrent(migrated as Node);
127
+ };
128
+
46
129
  return (
47
130
  <>
48
131
  <div className="modal__header localication-modal__header">
@@ -72,6 +155,17 @@ export function DebugJsonPage({
72
155
  Log store
73
156
  </button>
74
157
 
158
+ {hasStyleAttribute ? (
159
+ <button
160
+ type="button"
161
+ className="editor-button"
162
+ title="Migrate attributes.style to attributes.styles (schemaVersion=2)"
163
+ onClick={handleFixStyleToStyles}
164
+ >
165
+ Fix style → styles
166
+ </button>
167
+ ) : null}
168
+
75
169
  {onClose ? (
76
170
  <button
77
171
  type="button"
@@ -123,9 +217,15 @@ export function DebugJsonPage({
123
217
  <JsonTextEditor
124
218
  rootName="node"
125
219
  value={data ?? ({} as any)}
126
- onChange={(next) =>
127
- setData(analyseAndProccess(next as Node) as Node)
128
- }
220
+ onChange={(next) => {
221
+ const nodeCandidate = extractNode(next);
222
+ const processed = analyseAndProccess(
223
+ nodeCandidate as Node,
224
+ ) as Node;
225
+ setData(processed);
226
+ // Keep selection in sync with the new root.
227
+ setCurrent(processed);
228
+ }}
129
229
  className="localication-modal__json-editor"
130
230
  />
131
231
  </div>
@@ -5,25 +5,7 @@ import type { Node } from '../types/Node';
5
5
  import type { Product } from '../paywall/types/paywall-types';
6
6
  import type { PaywallBenefits } from '../paywall/types/benefits';
7
7
  import { useSyncHtmlThemeClass } from '../hooks/useSyncHtmlThemeClass';
8
-
9
- function safeStringify(value: unknown): string {
10
- try {
11
- const seen = new WeakSet<object>();
12
- return JSON.stringify(
13
- value,
14
- (_key, v) => {
15
- if (typeof v === 'object' && v !== null) {
16
- if (seen.has(v as object)) return '[Circular]';
17
- seen.add(v as object);
18
- }
19
- return v;
20
- },
21
- 2,
22
- );
23
- } catch (e) {
24
- return `<< Unable to stringify value: ${String(e)} >>`;
25
- }
26
- }
8
+ import { safeJsonStringify } from '../utils/safeJsonStringify';
27
9
 
28
10
  async function copyTextToClipboard(text: string): Promise<boolean> {
29
11
  try {
@@ -68,6 +50,24 @@ function isObject(value: unknown): value is Record<string, unknown> {
68
50
  return typeof value === 'object' && value !== null && !Array.isArray(value);
69
51
  }
70
52
 
53
+ function extractNodeFromRawData(rawData: unknown): unknown {
54
+ // Support both:
55
+ // - raw node JSON (root has `type`)
56
+ // - full project JSON (root has `data`)
57
+ if (isObject(rawData)) {
58
+ if (typeof rawData.type === 'string' && rawData.type.trim()) return rawData;
59
+ const maybeData = rawData.data;
60
+ if (
61
+ isObject(maybeData) &&
62
+ typeof maybeData.type === 'string' &&
63
+ maybeData.type.trim()
64
+ ) {
65
+ return maybeData;
66
+ }
67
+ }
68
+ return rawData;
69
+ }
70
+
71
71
  function getNodeType(value: unknown): string | null {
72
72
  if (!isObject(value)) return null;
73
73
  const t = value.type;
@@ -132,6 +132,14 @@ export type ProjectDebugProps = {
132
132
  benefits: PaywallBenefits;
133
133
  canvasBg?: string;
134
134
  belowName?: ReactNode;
135
+ jsonEditor?: {
136
+ value: string;
137
+ onChange: (next: string) => void;
138
+ error?: string | null;
139
+ onSave?: () => void;
140
+ saveDisabled?: boolean;
141
+ saveLabel?: string;
142
+ };
135
143
  };
136
144
 
137
145
  export function ProjectDebug({
@@ -143,6 +151,7 @@ export function ProjectDebug({
143
151
  benefits,
144
152
  canvasBg,
145
153
  belowName,
154
+ jsonEditor,
146
155
  }: ProjectDebugProps) {
147
156
  useSyncHtmlThemeClass();
148
157
  const [previewError, setPreviewError] = useState<{
@@ -155,16 +164,18 @@ export function ProjectDebug({
155
164
  'idle',
156
165
  );
157
166
 
158
- const json = useMemo(() => safeStringify(rawData), [rawData]);
159
- const previewData = rawData as Node;
160
- const rootType = useMemo(() => getNodeType(rawData), [rawData]);
167
+ const json = useMemo(() => safeJsonStringify(rawData), [rawData]);
168
+ const jsonToCopy = jsonEditor ? jsonEditor.value : json;
169
+ const nodeRoot = useMemo(() => extractNodeFromRawData(rawData), [rawData]);
170
+ const previewData = nodeRoot as Node;
171
+ const rootType = useMemo(() => getNodeType(nodeRoot), [nodeRoot]);
161
172
  const parsedValidation = useMemo(
162
173
  () => parseValidationPrefix(validationError),
163
174
  [validationError],
164
175
  );
165
176
  const validationContext = useMemo(
166
- () => resolveNodeTypeAtPath(rawData, parsedValidation.path),
167
- [rawData, parsedValidation.path],
177
+ () => resolveNodeTypeAtPath(nodeRoot, parsedValidation.path),
178
+ [nodeRoot, parsedValidation.path],
168
179
  );
169
180
 
170
181
  const validationSummary = useMemo(() => {
@@ -241,7 +252,7 @@ export function ProjectDebug({
241
252
  type="button"
242
253
  className="editor-button"
243
254
  onClick={async () => {
244
- const ok = await copyTextToClipboard(json);
255
+ const ok = await copyTextToClipboard(jsonToCopy);
245
256
  if (!ok) return;
246
257
  setJsonCopyState('copied');
247
258
  window.setTimeout(() => setJsonCopyState('idle'), 900);
@@ -249,13 +260,40 @@ export function ProjectDebug({
249
260
  >
250
261
  {jsonCopyState === 'copied' ? 'Copied JSON' : 'Copy JSON'}
251
262
  </button>
263
+ {jsonEditor?.onSave && (
264
+ <button
265
+ type="button"
266
+ className="editor-button"
267
+ disabled={jsonEditor.saveDisabled}
268
+ onClick={() => jsonEditor.onSave?.()}
269
+ >
270
+ {jsonEditor.saveLabel ?? 'Save'}
271
+ </button>
272
+ )}
252
273
  </div>
253
274
  {belowName && (
254
275
  <div className="rb-project-debug__below-name">{belowName}</div>
255
276
  )}
256
- <pre className="rb-project-debug__code" tabIndex={0}>
257
- {json}
258
- </pre>
277
+ {jsonEditor ? (
278
+ <>
279
+ <textarea
280
+ className="rb-project-debug__code-editor"
281
+ value={jsonEditor.value}
282
+ onChange={(e) => jsonEditor.onChange(e.target.value)}
283
+ spellCheck={false}
284
+ aria-label="Edit JSON"
285
+ />
286
+ {jsonEditor.error ? (
287
+ <div className="rb-project-debug__json-error" role="alert">
288
+ {jsonEditor.error}
289
+ </div>
290
+ ) : null}
291
+ </>
292
+ ) : (
293
+ <pre className="rb-project-debug__code" tabIndex={0}>
294
+ {json}
295
+ </pre>
296
+ )}
259
297
  </section>
260
298
 
261
299
  <section
@@ -9,6 +9,7 @@ export type ProjectMigrationPageProps = {
9
9
 
10
10
  projectVersion: string;
11
11
  requiredVersion: string;
12
+ showFixVersionMeta?: boolean;
12
13
 
13
14
  pendingMigrations: Array<{
14
15
  id: string;
@@ -25,6 +26,7 @@ export type ProjectMigrationPageProps = {
25
26
  migrating?: boolean;
26
27
  onContinueWithoutValidation?: () => void;
27
28
  onMigrateNow?: () => void;
29
+ onFixVersionMeta?: () => void;
28
30
  };
29
31
 
30
32
  export function ProjectMigrationPage({
@@ -32,6 +34,7 @@ export function ProjectMigrationPage({
32
34
  rawData,
33
35
  projectVersion,
34
36
  requiredVersion,
37
+ showFixVersionMeta,
35
38
  pendingMigrations,
36
39
  products,
37
40
  benefits,
@@ -40,6 +43,7 @@ export function ProjectMigrationPage({
40
43
  migrating,
41
44
  onContinueWithoutValidation,
42
45
  onMigrateNow,
46
+ onFixVersionMeta,
43
47
  }: ProjectMigrationPageProps) {
44
48
  const message = `Migration required: project version ${projectVersion} is lower than ${requiredVersion}.`;
45
49
 
@@ -78,6 +82,17 @@ export function ProjectMigrationPage({
78
82
  {migrating ? 'Migrating…' : 'Migrate now'}
79
83
  </button>
80
84
 
85
+ {showFixVersionMeta && (
86
+ <button
87
+ type="button"
88
+ className="editor-button"
89
+ disabled={!!migrating}
90
+ onClick={() => onFixVersionMeta?.()}
91
+ >
92
+ Fix version meta
93
+ </button>
94
+ )}
95
+
81
96
  <button
82
97
  type="button"
83
98
  className="editor-button"
@@ -36,6 +36,8 @@ import {
36
36
  import type { Fonts } from '../types/Fonts';
37
37
  import { useProjectFonts } from '../hooks/useProjectFonts';
38
38
  import { resolveProjectForSave } from './projectPageUtils';
39
+ import { getDefaultProject } from '../utils/getDefaultProject';
40
+ import { CURRENT_PROJECT_VERSION } from '../migrations/migratePipe';
39
41
  export type ProjectPageProps = {
40
42
  project: Project;
41
43
  onSaveProject: (project: Project) => void;
@@ -75,10 +77,6 @@ export function ProjectPage({
75
77
  typography.fonts.find((f) => f?.isMain)?.name ??
76
78
  typography.fonts[0]?.name;
77
79
  useProjectFonts({ fonts: typography.fonts, appFont: resolvedAppFont });
78
- const resolvedName = name ?? project.name;
79
- const resolvedProjectColors = projectColors ?? project.projectColors;
80
- const isEmptyProjectData =
81
- isNodeNullOrUndefined(project.data) || isEmptyObject(project.data);
82
80
  // useRenderStore will be removed
83
81
  const {
84
82
  current,
@@ -103,10 +101,15 @@ export function ProjectPage({
103
101
  }));
104
102
  const resolvedAppConfig = appConfig ?? storeAppConfig ?? defaultAppConfig;
105
103
  const [overrideProject, setOverrideProject] = useState<Project | null>(null);
104
+ const activeProject = overrideProject ?? project;
105
+ const resolvedName = name ?? activeProject.name;
106
+ const resolvedProjectColors = projectColors ?? activeProject.projectColors;
107
+ const isEmptyProjectData =
108
+ isNodeNullOrUndefined(activeProject.data) ||
109
+ isEmptyObject(activeProject.data);
106
110
  const [editorData, setEditorData] = useState<Node>(() => {
107
- if (!isEmptyProjectData) return null;
108
- // Empty project should start in a usable state (no loader / no error).
109
- return analyseAndProccess({ type: 'Main', children: [] }) as Node;
111
+ // Empty project: keep data null-ish, show empty state.
112
+ return null;
110
113
  });
111
114
  const [validationError, setValidationError] = useState<string | null>(null);
112
115
  const [validationErrorStack, setValidationErrorStack] = useState<
@@ -221,7 +224,7 @@ export function ProjectPage({
221
224
  setMinLoadingDelayDone(false);
222
225
  const timer = setTimeout(() => setMinLoadingDelayDone(true), 1000);
223
226
  return () => clearTimeout(timer);
224
- }, [project.data]);
227
+ }, [activeProject.data]);
225
228
 
226
229
  useEffect(() => {
227
230
  try {
@@ -234,7 +237,7 @@ export function ProjectPage({
234
237
  setValidationError(null);
235
238
  setValidationErrorStack(null);
236
239
  // Version gate: if project is older than the current schema, show migration UI.
237
- const pipe = getMigrationPipe(project);
240
+ const pipe = getMigrationPipe(activeProject);
238
241
  if (!bypassValidation && pipe.required) {
239
242
  setMigrationGate(pipe);
240
243
  setEditorData(null);
@@ -245,13 +248,17 @@ export function ProjectPage({
245
248
  if (bypassValidation) {
246
249
  // Best-effort: let the user continue with the raw data even if invalid.
247
250
  // This may still crash the preview, but it unblocks users for debugging.
248
- setEditorData(project.data as unknown as Node);
249
- setCurrent(project.data as unknown as Node);
251
+ setEditorData(activeProject.data as unknown as Node);
252
+ setCurrent(activeProject.data as unknown as Node);
250
253
  return;
251
254
  }
252
- const inputNode: Node = isEmptyProjectData
253
- ? { type: 'Main', children: [] }
254
- : (project.data as Node);
255
+ if (isEmptyProjectData) {
256
+ setEditorData(null);
257
+ setCurrent(null);
258
+ return;
259
+ }
260
+
261
+ const inputNode: Node = activeProject.data as Node;
255
262
 
256
263
  const processed = analyseAndProccess(inputNode);
257
264
  if (!processed) return;
@@ -268,7 +275,7 @@ export function ProjectPage({
268
275
  setEditorData(null);
269
276
  setCurrent(null);
270
277
  }
271
- }, [project, project.data, bypassValidation, setCurrent]);
278
+ }, [activeProject, activeProject.data, bypassValidation, setCurrent]);
272
279
 
273
280
  const showLoading =
274
281
  !isEmptyProjectData && (editorData === null || !minLoadingDelayDone);
@@ -322,9 +329,10 @@ export function ProjectPage({
322
329
  {migrationGate ? (
323
330
  <ProjectMigrationPage
324
331
  name={resolvedName}
325
- rawData={project.data}
332
+ rawData={activeProject}
326
333
  projectVersion={migrationGate.projectVersion}
327
334
  requiredVersion={migrationGate.requiredVersion}
335
+ showFixVersionMeta={migrationGate.projectVersion === '0.0.0'}
328
336
  pendingMigrations={migrationGate.pending.map((m) => ({
329
337
  id: m.id,
330
338
  title: m.title,
@@ -338,15 +346,37 @@ export function ProjectPage({
338
346
  onContinueWithoutValidation={() => {
339
347
  setBypassValidation(true);
340
348
  setMigrationGate(null);
341
- setEditorData(project.data as unknown as Node);
342
- setCurrent(project.data as unknown as Node);
349
+ setEditorData(activeProject.data as unknown as Node);
350
+ setCurrent(activeProject.data as unknown as Node);
343
351
  setMinLoadingDelayDone(true);
344
352
  }}
345
353
  onMigrateNow={() => {
346
354
  try {
347
355
  setIsMigrating(true);
356
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
357
+ typeof v === 'object' && v !== null && !Array.isArray(v);
358
+ const isNodeLike = (v: unknown): v is { type: string } =>
359
+ isRecord(v) &&
360
+ typeof v.type === 'string' &&
361
+ v.type.trim().length > 0;
362
+
363
+ // If the incoming "project" is actually a raw Node (no version),
364
+ // wrap it into a valid Project shape before migrating/saving.
365
+ const projectForMigration: Project =
366
+ isNodeLike(activeProject) &&
367
+ !(
368
+ isRecord(activeProject) &&
369
+ typeof (activeProject as any).version === 'string'
370
+ )
371
+ ? getDefaultProject({
372
+ name: `imported-${Math.random().toString(36).slice(2, 8)}`,
373
+ version: CURRENT_PROJECT_VERSION,
374
+ data: activeProject as unknown as Node,
375
+ })
376
+ : activeProject;
377
+
348
378
  const { project: migratedProject } =
349
- runProjectMigrations(project);
379
+ runProjectMigrations(projectForMigration);
350
380
  onSaveProject(migratedProject);
351
381
  setOverrideProject(migratedProject);
352
382
  setBypassValidation(true);
@@ -358,11 +388,61 @@ export function ProjectPage({
358
388
  setIsMigrating(false);
359
389
  }
360
390
  }}
391
+ onFixVersionMeta={() => {
392
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
393
+ typeof v === 'object' && v !== null && !Array.isArray(v);
394
+ const isNodeLike = (v: unknown): v is { type: string } =>
395
+ isRecord(v) &&
396
+ typeof v.type === 'string' &&
397
+ v.type.trim().length > 0;
398
+ const isAllowedProjectType = (
399
+ v: unknown,
400
+ ): v is 'paywall' | 'onboard' | 'other' =>
401
+ v === 'paywall' || v === 'onboard' || v === 'other';
402
+
403
+ const fixedName =
404
+ typeof (activeProject as any)?.name === 'string' &&
405
+ String((activeProject as any).name).trim()
406
+ ? String((activeProject as any).name).trim()
407
+ : `imported-${Math.random().toString(36).slice(2, 8)}`;
408
+
409
+ const activeAny = (
410
+ isRecord(activeProject) ? activeProject : null
411
+ ) as Record<string, unknown> | null;
412
+ const nodeCandidate = (
413
+ activeAny && 'data' in activeAny
414
+ ? (activeAny as any).data
415
+ : isNodeLike(activeProject)
416
+ ? (activeProject as unknown as Node)
417
+ : null
418
+ ) as Node | null;
419
+
420
+ const fixedProject = getDefaultProject({
421
+ name: fixedName,
422
+ version: CURRENT_PROJECT_VERSION,
423
+ data: nodeCandidate,
424
+ appConfig: (activeAny as any)?.appConfig,
425
+ projectColors: (activeAny as any)?.projectColors,
426
+ type: isAllowedProjectType((activeAny as any)?.type)
427
+ ? ((activeAny as any).type as any)
428
+ : undefined,
429
+ });
430
+
431
+ // This action only fixes project metadata. It intentionally does NOT
432
+ // validate/normalize node data (it might still be invalid).
433
+ onSaveProject(fixedProject);
434
+ setOverrideProject(fixedProject);
435
+ setBypassValidation(false);
436
+ setMigrationGate(null);
437
+ setValidationError(null);
438
+ setValidationErrorStack(null);
439
+ toast.success('Fixed version meta');
440
+ }}
361
441
  />
362
442
  ) : validationError ? (
363
443
  <ProjectValidationPage
364
444
  name={resolvedName}
365
- rawData={project.data}
445
+ rawData={activeProject}
366
446
  validationError={validationError}
367
447
  validationErrorStack={validationErrorStack ?? undefined}
368
448
  products={products}
@@ -372,10 +452,67 @@ export function ProjectPage({
372
452
  setBypassValidation(true);
373
453
  setValidationError(null);
374
454
  setValidationErrorStack(null);
375
- setEditorData(project.data as unknown as Node);
376
- setCurrent(project.data as unknown as Node);
455
+ setEditorData(activeProject.data as unknown as Node);
456
+ setCurrent(activeProject.data as unknown as Node);
377
457
  setMinLoadingDelayDone(true);
378
458
  }}
459
+ onSaveEditedRawData={(nextRawData) => {
460
+ try {
461
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
462
+ typeof v === 'object' && v !== null && !Array.isArray(v);
463
+
464
+ // Accept both:
465
+ // - a raw Node (what the app uses internally)
466
+ // - a full Project-like wrapper { name, version, data: Node }
467
+ const parsed = nextRawData;
468
+ let nodeCandidate: unknown = parsed;
469
+ let nextName: string | undefined;
470
+ let nextVersion: string | undefined;
471
+
472
+ if (isRecord(parsed) && 'data' in parsed) {
473
+ nodeCandidate = (parsed as Record<string, unknown>).data;
474
+ const maybeName = (parsed as Record<string, unknown>).name;
475
+ const maybeVersion = (parsed as Record<string, unknown>)
476
+ .version;
477
+ if (typeof maybeName === 'string') nextName = maybeName;
478
+ if (typeof maybeVersion === 'string')
479
+ nextVersion = maybeVersion;
480
+ }
481
+
482
+ const processed = analyseAndProccess(nodeCandidate as Node);
483
+ if (!processed) throw new Error('Node is not valid');
484
+
485
+ const nextProject: Project = {
486
+ ...activeProject,
487
+ ...(nextName ? { name: nextName } : null),
488
+ ...(nextVersion ? { version: nextVersion } : null),
489
+ data: nodeCandidate as Project['data'],
490
+ };
491
+
492
+ onSaveProject(nextProject);
493
+ setOverrideProject(nextProject);
494
+ setBypassValidation(false);
495
+ setValidationError(null);
496
+ setValidationErrorStack(null);
497
+ setEditorData(processed);
498
+ setCurrent(processed);
499
+ setMinLoadingDelayDone(true);
500
+ toast.success('Saved');
501
+ } catch (e) {
502
+ logger.error(
503
+ 'ProjectPage',
504
+ 'save JSON from validation failed',
505
+ e,
506
+ );
507
+ setValidationError(
508
+ e instanceof Error ? e.message : 'Node is not valid',
509
+ );
510
+ setValidationErrorStack(
511
+ e instanceof Error ? (e.stack ?? null) : null,
512
+ );
513
+ toast.error('Save failed');
514
+ }
515
+ }}
379
516
  />
380
517
  ) : (
381
518
  <>
@@ -477,7 +614,7 @@ export function ProjectPage({
477
614
  </div>
478
615
  )}
479
616
  {/* NOTE: In React Native, `products` should come from an IAP wrapper (e.g. `react-native-iap`). */}
480
- {!showLoading && editorData && (
617
+ {!showLoading && (
481
618
  <RenderPage
482
619
  data={editorData}
483
620
  name={resolvedName}