@morphika/andami 0.1.8 → 0.1.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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Document → State normalization functions.
3
+ * Converts Sanity page documents into the builder's Zustand state shape.
4
+ *
5
+ * Extracted from serializer.ts in Session 162.
6
+ */
7
+
8
+ import type { Page, ContentBlock, ContentItem, PageSection, PageSectionV2, SectionBlock, SectionColumn, SectionV2Settings, CustomSectionInstance, ParallaxGroup } from "../../../lib/sanity/types";
9
+ import type { BuilderState, PageSettings } from "../types";
10
+ import { generateKey } from "../utils";
11
+ import { DEFAULT_BG_COLOR, DEFAULT_TEXT_COLOR, DEFAULT_GRID_WIDTH } from "../constants";
12
+ import type { EnterAnimationConfig } from "../../../lib/animation/enter-types";
13
+
14
+ import { ensureKeys, normalizeBlockResponsive, SECTION_TYPE_MAP } from "./shared";
15
+ import { migrateProjectGridV1ToV2, normalizeBlockAnimationFields } from "./migrations";
16
+
17
+ // ============================================
18
+ // Section Normalizers
19
+ // ============================================
20
+
21
+ export function normalizePageSection(section: Partial<PageSection> & { _key: string }): PageSection {
22
+ const ensuredBlock = ensureKeys((section.block || []) as SectionBlock[]) as (SectionBlock & { _key: string })[];
23
+ const ensuredBlockType = ensuredBlock[0]?._type || "projectGridBlock";
24
+ // BUG-022 fix: Preserve the original section_type even if unknown.
25
+ // Only default when the field is missing entirely (new sections).
26
+ const sectionType = section.section_type || (SECTION_TYPE_MAP[ensuredBlockType] || "projectGrid");
27
+
28
+ // Warn on unknown types but don't override — future types should survive round-trips
29
+ if (!Object.values(SECTION_TYPE_MAP).includes(sectionType)) {
30
+ console.warn(`[Serializer] Unknown section_type: ${sectionType} — preserving as-is`);
31
+ }
32
+
33
+ // Auto-migrate projectGridBlock v1 → v2
34
+ if (ensuredBlock[0]) {
35
+ migrateProjectGridV1ToV2(ensuredBlock[0] as unknown as Record<string, unknown>);
36
+ }
37
+
38
+ // Session 117: Migrate animation fields on section blocks
39
+ if (ensuredBlock[0]) {
40
+ normalizeBlockAnimationFields(ensuredBlock[0] as unknown as Record<string, unknown>);
41
+ }
42
+
43
+ return {
44
+ _type: "pageSection",
45
+ _key: section._key,
46
+ section_type: sectionType as import("../../../lib/sanity/types").PageSectionType,
47
+ block: [ensuredBlock[0] || { _type: ensuredBlockType, _key: generateKey() }] as [SectionBlock],
48
+ settings: section.settings || {},
49
+ // BUG-013 fix: preserve responsive overrides for sections
50
+ ...(section.responsive ? { responsive: section.responsive } : {}),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Normalize a PageSectionV2 from Sanity data into the builder's shape.
56
+ * Ensures all columns have _keys, validates grid positions, and fills defaults.
57
+ */
58
+ export function normalizePageSectionV2(section: Partial<PageSectionV2> & { _key: string }): PageSectionV2 {
59
+ const rawColumns = ensureKeys((section.columns || []) as Array<Partial<SectionColumn> & { _key?: string }>);
60
+
61
+ const columns: SectionColumn[] = rawColumns.map((col) => {
62
+ const colRecord = col as unknown as Record<string, unknown>;
63
+ // Migrate column-level enter_animation (new field, no old equivalent for columns)
64
+ const colEnterAnimation = colRecord.enter_animation as EnterAnimationConfig | undefined;
65
+
66
+ return {
67
+ _key: col._key || generateKey(),
68
+ grid_column: col.grid_column || 1,
69
+ grid_row: col.grid_row || 1,
70
+ span: col.span || 12,
71
+ blocks: ensureKeys(col.blocks || []).map((b) => {
72
+ const blockRecord = b as unknown as Record<string, unknown>;
73
+
74
+ // Migrate animation fields on blocks (Session 117)
75
+ normalizeBlockAnimationFields(blockRecord);
76
+
77
+ const blockResponsive = normalizeBlockResponsive(
78
+ blockRecord.responsive as Record<string, Record<string, unknown>> | undefined
79
+ );
80
+ if (blockResponsive) {
81
+ return { ...b, responsive: blockResponsive } as ContentBlock;
82
+ }
83
+ if (blockRecord.responsive && !blockResponsive) {
84
+ const { responsive: _, ...rest } = blockRecord;
85
+ return rest as unknown as ContentBlock;
86
+ }
87
+ return b as ContentBlock;
88
+ }),
89
+ ...(colEnterAnimation ? { enter_animation: colEnterAnimation } : {}),
90
+ };
91
+ });
92
+
93
+ const rawSettings = section.settings || {} as Partial<SectionV2Settings>;
94
+
95
+ return {
96
+ _type: "pageSectionV2",
97
+ _key: section._key,
98
+ section_type: "empty-v2",
99
+ columns,
100
+ settings: {
101
+ preset: rawSettings.preset || "full",
102
+ grid_columns: rawSettings.grid_columns || 12,
103
+ col_gap: rawSettings.col_gap ?? 20,
104
+ row_gap: rawSettings.row_gap ?? 20,
105
+ spacing_top: rawSettings.spacing_top,
106
+ spacing_right: rawSettings.spacing_right,
107
+ spacing_bottom: rawSettings.spacing_bottom,
108
+ spacing_left: rawSettings.spacing_left,
109
+ offset_top: rawSettings.offset_top,
110
+ offset_right: rawSettings.offset_right,
111
+ offset_bottom: rawSettings.offset_bottom,
112
+ offset_left: rawSettings.offset_left,
113
+ background_color: rawSettings.background_color,
114
+ background_opacity: rawSettings.background_opacity,
115
+ background_image: rawSettings.background_image,
116
+ background_size: rawSettings.background_size,
117
+ background_position: rawSettings.background_position,
118
+ background_repeat: rawSettings.background_repeat,
119
+ border_color: rawSettings.border_color,
120
+ border_width: rawSettings.border_width,
121
+ border_style: rawSettings.border_style,
122
+ border_sides: rawSettings.border_sides,
123
+ border_radius: rawSettings.border_radius,
124
+ enter_animation: rawSettings.enter_animation,
125
+ stagger: rawSettings.stagger,
126
+ },
127
+ ...(section.responsive ? { responsive: section.responsive } : {}),
128
+ };
129
+ }
130
+
131
+ // ============================================
132
+ // ParallaxGroup normalization (Session 123)
133
+ // ============================================
134
+
135
+ /**
136
+ * Normalize a ParallaxGroup from Sanity data into the builder's shape.
137
+ * Each slide contains V2 section data (columns + settings).
138
+ */
139
+ export function normalizeParallaxGroup(group: Partial<ParallaxGroup> & { _key: string }): ParallaxGroup {
140
+ const rawSlides = ensureKeys(
141
+ (group.slides || []) as Array<Partial<import("../../../lib/sanity/types").ParallaxSlideV2> & { _key?: string }>
142
+ );
143
+
144
+ const slides: import("../../../lib/sanity/types").ParallaxSlideV2[] = rawSlides.map((slide) => {
145
+ // Normalize the V2 columns within each slide (reuse existing logic)
146
+ const slideKey = slide._key || generateKey();
147
+ const tempV2: Partial<PageSectionV2> & { _key: string } = {
148
+ _key: slideKey,
149
+ columns: slide.columns as SectionColumn[] | undefined,
150
+ settings: slide.section_settings as SectionV2Settings | undefined,
151
+ };
152
+ const normalizedV2 = normalizePageSectionV2(tempV2);
153
+
154
+ return {
155
+ _key: slideKey,
156
+ _type: "parallaxSlide" as const,
157
+ background_type: slide.background_type || "image",
158
+ background_image: slide.background_image,
159
+ background_video: slide.background_video,
160
+ background_position: slide.background_position || "center center",
161
+ background_overlay_color: slide.background_overlay_color || "#000000",
162
+ background_overlay_opacity: slide.background_overlay_opacity ?? 0,
163
+ nav_color: slide.nav_color,
164
+ columns: normalizedV2.columns,
165
+ section_settings: normalizedV2.settings,
166
+ };
167
+ });
168
+
169
+ return {
170
+ _type: "parallaxGroup",
171
+ _key: group._key,
172
+ slides,
173
+ transition_effect: group.transition_effect || "parallax",
174
+ snap_enabled: group.snap_enabled ?? true,
175
+ parallax_intensity: group.parallax_intensity ?? 0.4,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Normalize a content item (PageSection, PageSectionV2, ParallaxGroup, etc.).
181
+ */
182
+ export function migrateContentItem(item: Record<string, unknown>): ContentItem {
183
+ // PageSection — normalize and return
184
+ if (item._type === "pageSection") {
185
+ return normalizePageSection(item as unknown as Partial<PageSection> & { _key: string });
186
+ }
187
+
188
+ // PageSectionV2 — normalize and return
189
+ if (item._type === "pageSectionV2") {
190
+ return normalizePageSectionV2(item as unknown as Partial<PageSectionV2> & { _key: string });
191
+ }
192
+
193
+ // CustomSectionInstance — pass through reference + per-instance overrides (Session 107, 130)
194
+ if (item._type === "customSectionInstance") {
195
+ const raw = item as unknown as Record<string, unknown>;
196
+ return {
197
+ _type: "customSectionInstance",
198
+ _key: (item._key as string) || generateKey(),
199
+ custom_section_id: (item.custom_section_id as string) || "",
200
+ custom_section_slug: (item.custom_section_slug as string) || "",
201
+ custom_section_title: (item.custom_section_title as string) || "Untitled",
202
+ ...(raw.settings_overrides && typeof raw.settings_overrides === "object"
203
+ ? { settings_overrides: raw.settings_overrides as Partial<SectionV2Settings> }
204
+ : {}),
205
+ ...(raw.responsive_overrides && typeof raw.responsive_overrides === "object"
206
+ ? { responsive_overrides: raw.responsive_overrides as PageSectionV2["responsive"] }
207
+ : {}),
208
+ } as CustomSectionInstance;
209
+ }
210
+
211
+ // ParallaxGroup — normalize slides (Session 123)
212
+ if (item._type === "parallaxGroup") {
213
+ return normalizeParallaxGroup(item as unknown as Partial<ParallaxGroup> & { _key: string });
214
+ }
215
+
216
+ // Unknown type — warn and skip
217
+ console.warn(`[Serializer] Unknown content item type: ${item._type}, skipping`);
218
+ // Return a dummy PageSectionV2 as fallback
219
+ return normalizePageSectionV2({ _key: generateKey() } as unknown as Partial<PageSectionV2> & { _key: string });
220
+ }
221
+
222
+ /**
223
+ * Convert a Sanity `page` document into the builder's state shape.
224
+ */
225
+ export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSaving" | "saveError" | "lastSavedAt" | "selectedRowKey" | "selectedColumnKey" | "selectedBlockKey" | "_history" | "_future" | "_isTimeTraveling" | "_originalSlug" | "previewMode" | "canvasZoom" | "canvasPanX" | "canvasPanY" | "canvasTool" | "activeViewport" | "editorMode" | "customSectionSlug" | "customSectionTitle" | "savedPageState"> {
226
+ const docRecord = doc as unknown as Record<string, unknown>;
227
+ const pageSettings = docRecord.page_settings as Record<string, unknown> | undefined;
228
+
229
+ // BUG-014 fix: Track whether the Sanity document actually had page_settings saved.
230
+ // This lets applyGlobalStyles() know not to overwrite user-chosen colors.
231
+ const hasDocumentPageSettings = !!pageSettings;
232
+
233
+ // nav_color: free hex string (or legacy NavColorVariant — both accepted)
234
+ const nav_color = (typeof pageSettings?.nav_color === "string")
235
+ ? (pageSettings.nav_color as string)
236
+ : "";
237
+
238
+ // Process content_rows with auto-migration
239
+ const rawRows = ensureKeys((doc.content_rows || []) as Array<{ _key?: string }>);
240
+ const rows: ContentItem[] = rawRows.map((item) =>
241
+ migrateContentItem(item as unknown as Record<string, unknown>)
242
+ );
243
+
244
+ return {
245
+ pageId: doc._id,
246
+ pageTitle: doc.title || "",
247
+ pageSlug: doc.slug?.current || "",
248
+ pageType: doc.page_type || "page",
249
+ metadata: doc.metadata || {},
250
+ publishedAt: doc.published_at || null,
251
+ draftMode: doc.draft_mode ?? true,
252
+ rows,
253
+ _customSectionCache: {},
254
+ // BUG-014 fix: flag whether colors came from the document
255
+ _hasDocumentPageSettings: hasDocumentPageSettings,
256
+ pageSettings: {
257
+ background_color: (pageSettings?.background_color as string) || DEFAULT_BG_COLOR,
258
+ text_color: (pageSettings?.text_color as string) || DEFAULT_TEXT_COLOR,
259
+ nav_color,
260
+ enter_animation: pageSettings?.enter_animation as PageSettings["enter_animation"],
261
+ nav_entrance_animation: (pageSettings?.nav_entrance_animation as PageSettings["nav_entrance_animation"]) || undefined,
262
+ nav_entrance_duration: (pageSettings?.nav_entrance_duration as number) || undefined,
263
+ nav_entrance_delay: (pageSettings?.nav_entrance_delay as number) || undefined,
264
+ nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean) || undefined,
265
+ },
266
+ // Grid settings are loaded separately via applyGlobalStyles(), not from the page document.
267
+ // Provide defaults here to satisfy the type; they'll be overwritten on init.
268
+ gridSettings: {
269
+ width: DEFAULT_GRID_WIDTH,
270
+ outer_padding: "30",
271
+ gutter_desktop: "30",
272
+ gutter_responsive: "30",
273
+ gutter_phone: "16",
274
+ },
275
+ selectedProjectCardKey: null,
276
+ colorPickerPreview: null,
277
+ };
278
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * State → Document serialization functions.
3
+ * Converts the builder's Zustand state into Sanity-compatible document payloads.
4
+ *
5
+ * Extracted from serializer.ts in Session 162.
6
+ */
7
+
8
+ import type { ContentBlock, ContentItem, PageSection, PageSectionV2, ParallaxGroup } from "../../../lib/sanity/types";
9
+ import { isPageSection, isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../../lib/sanity/types";
10
+ import type { BuilderState } from "../types";
11
+ import { DEFAULT_BG_COLOR, DEFAULT_TEXT_COLOR } from "../constants";
12
+
13
+ import { stripUndefined, normalizeBlockResponsive } from "./shared";
14
+
15
+ // ============================================
16
+ // Block Serialization
17
+ // ============================================
18
+
19
+ function serializeBlock(block: ContentBlock): ContentBlock {
20
+ const serialized = stripUndefined({ ...block }) as ContentBlock;
21
+
22
+ // Normalize block responsive data (clean up empty layout overrides)
23
+ const blockRecord = serialized as unknown as Record<string, unknown>;
24
+ if (blockRecord.responsive) {
25
+ const normalized = normalizeBlockResponsive(
26
+ blockRecord.responsive as Record<string, Record<string, unknown>>
27
+ );
28
+ if (normalized) {
29
+ blockRecord.responsive = normalized;
30
+ } else {
31
+ delete blockRecord.responsive;
32
+ }
33
+ }
34
+
35
+ // Normalize block layout responsive if present (layout.responsive sub-object)
36
+ if (blockRecord.layout && typeof blockRecord.layout === "object") {
37
+ const layout = blockRecord.layout as Record<string, unknown>;
38
+ // Strip undefined/null/empty string values from layout
39
+ const cleanLayout: Record<string, unknown> = {};
40
+ for (const [k, v] of Object.entries(layout)) {
41
+ if (v !== undefined && v !== null && v !== "") {
42
+ cleanLayout[k] = v;
43
+ }
44
+ }
45
+ if (Object.keys(cleanLayout).length > 0) {
46
+ blockRecord.layout = cleanLayout;
47
+ } else {
48
+ delete blockRecord.layout;
49
+ }
50
+ }
51
+
52
+ // Session 117: Ensure new animation fields are written
53
+ // enter_animation / hover_effect are already on the block from the store.
54
+ // Clean empty animation configs (preset=none or no preset → remove to keep Sanity clean)
55
+ const enterAnim = blockRecord.enter_animation as Record<string, unknown> | undefined;
56
+ if (enterAnim && (!enterAnim.preset || enterAnim.preset === "none")) {
57
+ delete blockRecord.enter_animation;
58
+ }
59
+ const hoverEff = blockRecord.hover_effect;
60
+ if (hoverEff && typeof hoverEff === "object") {
61
+ const he = hoverEff as Record<string, unknown>;
62
+ if (!he.preset || he.preset === "none") {
63
+ delete blockRecord.hover_effect;
64
+ }
65
+ }
66
+
67
+ return serialized;
68
+ }
69
+
70
+ // ============================================
71
+ // Section Serialization
72
+ // ============================================
73
+
74
+ /**
75
+ * Normalize section responsive overrides for serialization (save path).
76
+ * Strips undefined/null/empty string values and removes empty viewport objects.
77
+ */
78
+ function normalizeSectionResponsiveForSerialize(
79
+ responsive: PageSection["responsive"] | undefined
80
+ ): PageSection["responsive"] | undefined {
81
+ if (!responsive) return undefined;
82
+
83
+ const result: NonNullable<PageSection["responsive"]> = {};
84
+
85
+ for (const vp of ["tablet", "phone"] as const) {
86
+ const overrides = responsive[vp];
87
+ if (!overrides || typeof overrides !== "object") continue;
88
+
89
+ const cleaned: Record<string, unknown> = {};
90
+ for (const [key, val] of Object.entries(overrides)) {
91
+ if (val === undefined || val === null) continue;
92
+ if (typeof val === "string" && val.trim() === "") continue;
93
+ cleaned[key] = val;
94
+ }
95
+
96
+ if (Object.keys(cleaned).length > 0) {
97
+ result[vp] = cleaned as typeof result[typeof vp];
98
+ }
99
+ }
100
+
101
+ return Object.keys(result).length > 0 ? result : undefined;
102
+ }
103
+
104
+ function serializePageSection(section: PageSection): Record<string, unknown> {
105
+ const s = section.settings;
106
+ return {
107
+ _key: section._key,
108
+ _type: "pageSection",
109
+ section_type: section.section_type,
110
+ block: (section.block || []).map((b) => serializeBlock(b as ContentBlock)),
111
+ // BUG-013 fix: persist responsive overrides for sections (normalized: strips empty values)
112
+ responsive: normalizeSectionResponsiveForSerialize(section.responsive),
113
+ settings: s ? stripUndefined({
114
+ background_color: s.background_color,
115
+ background_opacity: s.background_opacity,
116
+ background_image: s.background_image,
117
+ background_size: s.background_size,
118
+ background_position: s.background_position,
119
+ background_repeat: s.background_repeat,
120
+ spacing_top: s.spacing_top,
121
+ spacing_right: s.spacing_right,
122
+ spacing_bottom: s.spacing_bottom,
123
+ spacing_left: s.spacing_left,
124
+ offset_top: s.offset_top,
125
+ offset_right: s.offset_right,
126
+ offset_bottom: s.offset_bottom,
127
+ offset_left: s.offset_left,
128
+ border_color: s.border_color,
129
+ border_width: s.border_width,
130
+ border_style: s.border_style,
131
+ border_sides: s.border_sides,
132
+ border_radius: s.border_radius,
133
+ enter_animation: s.enter_animation,
134
+ }) : undefined,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Normalize V2 section responsive overrides for serialization.
140
+ */
141
+ function normalizeSectionV2ResponsiveForSerialize(
142
+ responsive: PageSectionV2["responsive"] | undefined
143
+ ): PageSectionV2["responsive"] | undefined {
144
+ if (!responsive) return undefined;
145
+
146
+ const result: NonNullable<PageSectionV2["responsive"]> = {};
147
+
148
+ for (const vp of ["tablet", "phone"] as const) {
149
+ const overrides = responsive[vp];
150
+ if (!overrides || typeof overrides !== "object") continue;
151
+
152
+ const cleaned: Record<string, unknown> = {};
153
+
154
+ // Column overrides
155
+ if (overrides.columns && Array.isArray(overrides.columns) && overrides.columns.length > 0) {
156
+ const cleanedCols = overrides.columns.filter((co) => {
157
+ // Only keep overrides that have at least one meaningful value
158
+ return co.grid_column != null || co.grid_row != null || co.span != null;
159
+ });
160
+ if (cleanedCols.length > 0) {
161
+ cleaned.columns = cleanedCols;
162
+ }
163
+ }
164
+
165
+ // Settings overrides
166
+ if (overrides.settings) {
167
+ const settingsCleaned: Record<string, unknown> = {};
168
+ for (const [key, val] of Object.entries(overrides.settings)) {
169
+ if (val === undefined || val === null) continue;
170
+ if (typeof val === "string" && val.trim() === "") continue;
171
+ settingsCleaned[key] = val;
172
+ }
173
+ if (Object.keys(settingsCleaned).length > 0) {
174
+ cleaned.settings = settingsCleaned;
175
+ }
176
+ }
177
+
178
+ if (Object.keys(cleaned).length > 0) {
179
+ result[vp] = cleaned as typeof result[typeof vp];
180
+ }
181
+ }
182
+
183
+ return Object.keys(result).length > 0 ? result : undefined;
184
+ }
185
+
186
+ function serializePageSectionV2(section: PageSectionV2): Record<string, unknown> {
187
+ const s = section.settings;
188
+ return {
189
+ _key: section._key,
190
+ _type: "pageSectionV2",
191
+ section_type: section.section_type,
192
+ columns: section.columns.map((col) => {
193
+ const colData: Record<string, unknown> = {
194
+ _key: col._key,
195
+ _type: "sectionColumn",
196
+ grid_column: col.grid_column,
197
+ grid_row: col.grid_row,
198
+ span: col.span,
199
+ blocks: col.blocks.map(serializeBlock),
200
+ };
201
+ // Session 117: Column-level enter animation
202
+ if (col.enter_animation && col.enter_animation.preset && col.enter_animation.preset !== "none") {
203
+ colData.enter_animation = col.enter_animation;
204
+ }
205
+ return colData;
206
+ }),
207
+ settings: s ? stripUndefined({
208
+ preset: s.preset,
209
+ grid_columns: s.grid_columns,
210
+ col_gap: s.col_gap,
211
+ row_gap: s.row_gap,
212
+ spacing_top: s.spacing_top,
213
+ spacing_right: s.spacing_right,
214
+ spacing_bottom: s.spacing_bottom,
215
+ spacing_left: s.spacing_left,
216
+ offset_top: s.offset_top,
217
+ offset_right: s.offset_right,
218
+ offset_bottom: s.offset_bottom,
219
+ offset_left: s.offset_left,
220
+ background_color: s.background_color,
221
+ background_opacity: s.background_opacity,
222
+ background_image: s.background_image,
223
+ background_size: s.background_size,
224
+ background_position: s.background_position,
225
+ background_repeat: s.background_repeat,
226
+ border_color: s.border_color,
227
+ border_width: s.border_width,
228
+ border_style: s.border_style,
229
+ border_sides: s.border_sides,
230
+ border_radius: s.border_radius,
231
+ enter_animation: (s.enter_animation?.preset && s.enter_animation.preset !== "none")
232
+ ? s.enter_animation : undefined,
233
+ stagger: s.stagger,
234
+ }) : undefined,
235
+ responsive: normalizeSectionV2ResponsiveForSerialize(section.responsive),
236
+ };
237
+ }
238
+
239
+ // ============================================
240
+ // ParallaxGroup Serialization (Session 123)
241
+ // ============================================
242
+
243
+ /**
244
+ * Serialize a ParallaxGroup for Sanity.
245
+ */
246
+ function serializeParallaxGroup(group: ParallaxGroup): Record<string, unknown> {
247
+ return {
248
+ _key: group._key,
249
+ _type: "parallaxGroup",
250
+ transition_effect: group.transition_effect,
251
+ snap_enabled: group.snap_enabled,
252
+ parallax_intensity: group.parallax_intensity,
253
+ slides: group.slides.map((slide) => {
254
+ // Serialize the V2 section data within each slide
255
+ const tempV2: PageSectionV2 = {
256
+ _type: "pageSectionV2",
257
+ _key: slide._key,
258
+ section_type: "empty-v2",
259
+ columns: slide.columns,
260
+ settings: slide.section_settings,
261
+ };
262
+ const serializedV2 = serializePageSectionV2(tempV2);
263
+
264
+ return stripUndefined({
265
+ _key: slide._key,
266
+ _type: "parallaxSlide",
267
+ background_type: slide.background_type,
268
+ background_image: slide.background_image,
269
+ background_video: slide.background_video,
270
+ background_position: slide.background_position,
271
+ background_overlay_color: slide.background_overlay_color,
272
+ background_overlay_opacity: slide.background_overlay_opacity,
273
+ nav_color: slide.nav_color,
274
+ columns: serializedV2.columns,
275
+ section_settings: serializedV2.settings,
276
+ });
277
+ }),
278
+ };
279
+ }
280
+
281
+ // ============================================
282
+ // Content Item Dispatcher
283
+ // ============================================
284
+
285
+ /**
286
+ * Serialize a content item (PageSection or PageSectionV2) for Sanity.
287
+ */
288
+ function serializeContentItem(item: ContentItem): Record<string, unknown> {
289
+ if (isPageSection(item)) {
290
+ return serializePageSection(item);
291
+ }
292
+ if (isPageSectionV2(item)) {
293
+ return serializePageSectionV2(item);
294
+ }
295
+ // CustomSectionInstance — serialize reference + per-instance overrides (Session 107, 130)
296
+ if (isCustomSectionInstance(item)) {
297
+ const out: Record<string, unknown> = {
298
+ _type: "customSectionInstance",
299
+ _key: item._key,
300
+ custom_section_id: item.custom_section_id,
301
+ custom_section_slug: item.custom_section_slug,
302
+ custom_section_title: item.custom_section_title,
303
+ };
304
+ if (item.settings_overrides && Object.keys(item.settings_overrides).length > 0) {
305
+ out.settings_overrides = item.settings_overrides;
306
+ }
307
+ if (item.responsive_overrides) {
308
+ // Only write if there's actual data (at least one viewport with content)
309
+ const r = item.responsive_overrides;
310
+ const hasTablet = r.tablet && (r.tablet.settings && Object.keys(r.tablet.settings).length > 0 || r.tablet.columns && r.tablet.columns.length > 0);
311
+ const hasPhone = r.phone && (r.phone.settings && Object.keys(r.phone.settings).length > 0 || r.phone.columns && r.phone.columns.length > 0);
312
+ if (hasTablet || hasPhone) {
313
+ out.responsive_overrides = item.responsive_overrides;
314
+ }
315
+ }
316
+ return out;
317
+ }
318
+ // ParallaxGroup — serialize slides with their V2 section data (Session 123)
319
+ if (isParallaxGroup(item)) {
320
+ return serializeParallaxGroup(item);
321
+ }
322
+ // Should never reach here
323
+ throw new Error(`[Serializer] Unknown content item type during serialization`);
324
+ }
325
+
326
+ // ============================================
327
+ // Main Export
328
+ // ============================================
329
+
330
+ /**
331
+ * Convert the builder's state into a Sanity-compatible document payload.
332
+ * Used by the save API route.
333
+ */
334
+ export function stateToDocument(
335
+ state: Pick<BuilderState, "pageId" | "pageTitle" | "pageSlug" | "pageType" | "metadata" | "draftMode" | "publishedAt" | "rows" | "pageSettings">
336
+ ): Record<string, unknown> {
337
+ // BUG-008 fix: Always persist page_settings when ANY field has a non-default value.
338
+ // Previously, animation configs with preset='none' but other fields set (intensity, easing)
339
+ // would be stripped. Now we check for the presence of any animation config object,
340
+ // not just whether its preset is 'none'.
341
+ const ps = state.pageSettings;
342
+ const bgColor = ps?.background_color || DEFAULT_BG_COLOR;
343
+ const txtColor = ps?.text_color || DEFAULT_TEXT_COLOR;
344
+ const navColor = ps?.nav_color || "";
345
+
346
+ const enterAnim = ps?.enter_animation;
347
+ const hasEnterAnimation = enterAnim && enterAnim.preset && enterAnim.preset !== 'none';
348
+
349
+ const navEntranceAnimation = ps?.nav_entrance_animation || "";
350
+ const navEntranceDuration = ps?.nav_entrance_duration;
351
+ const navEntranceDelay = ps?.nav_entrance_delay;
352
+ const navEntranceDisabled = ps?.nav_entrance_disabled;
353
+
354
+ // Persist whenever any value differs from deserialisation defaults
355
+ const hasPageSettings =
356
+ bgColor !== DEFAULT_BG_COLOR ||
357
+ txtColor !== DEFAULT_TEXT_COLOR ||
358
+ !!navColor ||
359
+ !!hasEnterAnimation ||
360
+ !!enterAnim ||
361
+ !!navEntranceAnimation ||
362
+ !!navEntranceDuration ||
363
+ !!navEntranceDelay ||
364
+ !!navEntranceDisabled;
365
+
366
+ return stripUndefined({
367
+ _id: state.pageId,
368
+ _type: "page",
369
+ title: state.pageTitle,
370
+ slug: { _type: "slug", current: state.pageSlug },
371
+ page_type: state.pageType,
372
+ content_rows: state.rows.map(serializeContentItem),
373
+ metadata: stripUndefined({
374
+ seo_title: state.metadata.seo_title,
375
+ seo_description: state.metadata.seo_description,
376
+ og_image_path: state.metadata.og_image_path,
377
+ }),
378
+ page_settings: hasPageSettings
379
+ ? {
380
+ background_color: bgColor,
381
+ text_color: txtColor,
382
+ nav_color: navColor || undefined,
383
+ enter_animation: hasEnterAnimation ? enterAnim : undefined,
384
+ nav_entrance_animation: navEntranceAnimation || undefined,
385
+ nav_entrance_duration: navEntranceDuration || undefined,
386
+ nav_entrance_delay: navEntranceDelay || undefined,
387
+ nav_entrance_disabled: navEntranceDisabled || undefined,
388
+ }
389
+ : undefined,
390
+ draft_mode: state.draftMode,
391
+ published_at: state.publishedAt,
392
+ });
393
+ }