w-funnel/src/lib/admin/builder/state/reducer.ts
2025-10-22 22:42:01 +02:00

444 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ListScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { BuilderState, BuilderAction } from "./types";
import { INITIAL_STATE } from "./constants";
import { withDirty, generateScreenId, createScreenByTemplate } from "./utils";
/**
* Обновляет все ссылки на oldScreenId на newScreenId во всех экранах
*/
function updateScreenIdReferences(
screens: BuilderScreen[],
oldScreenId: string,
newScreenId: string
): BuilderScreen[] {
return screens.map((screen) => {
let updated = false;
const updatedScreen = { ...screen };
// Обновляем navigation.defaultNextScreenId
if (updatedScreen.navigation?.defaultNextScreenId === oldScreenId) {
updatedScreen.navigation = {
...updatedScreen.navigation,
defaultNextScreenId: newScreenId,
};
updated = true;
}
// Обновляем navigation.rules[].nextScreenId
if (updatedScreen.navigation?.rules && updatedScreen.navigation.rules.length > 0) {
const updatedRules = updatedScreen.navigation.rules.map((rule) => {
if (rule.nextScreenId === oldScreenId) {
return { ...rule, nextScreenId: newScreenId };
}
return rule;
});
if (updatedRules.some((rule, index) => rule !== updatedScreen.navigation!.rules![index])) {
updatedScreen.navigation = {
...updatedScreen.navigation,
rules: updatedRules,
};
updated = true;
}
}
// Обновляем variants[].conditions[].screenId
if (updatedScreen.variants && updatedScreen.variants.length > 0) {
const updatedVariants = updatedScreen.variants.map((variant) => {
if (!variant.conditions) return variant;
const updatedConditions = variant.conditions.map((condition) => {
if (condition.screenId === oldScreenId) {
return { ...condition, screenId: newScreenId };
}
return condition;
});
if (updatedConditions.some((cond, index) => cond !== variant.conditions![index])) {
return { ...variant, conditions: updatedConditions };
}
return variant;
});
if (updatedVariants.some((v, index) => v !== updatedScreen.variants![index])) {
updatedScreen.variants = updatedVariants;
updated = true;
}
}
return updated ? updatedScreen : screen;
});
}
export function builderReducer(state: BuilderState, action: BuilderAction): BuilderState {
switch (action.type) {
case "set-meta": {
return withDirty(state, {
...state,
meta: {
...state.meta,
...action.payload,
},
});
}
case "set-default-texts": {
return withDirty(state, {
...state,
defaultTexts: {
...state.defaultTexts,
...action.payload,
},
});
}
case "add-screen": {
const nextId = generateScreenId(state.screens.map((s) => s.id));
const template = action.payload?.template || "list";
const newScreen = createScreenByTemplate(template, nextId);
// 🎯 АВТОМАТИЧЕСКОЕ СВЯЗЫВАНИЕ С ПРЕДЫДУЩИМ ЭКРАНОМ
let updatedScreens = [...state.screens, newScreen];
// Если есть предыдущий экран и у него нет defaultNextScreenId, связываем с новым
if (state.screens.length > 0) {
const lastScreen = state.screens[state.screens.length - 1];
if (!lastScreen.navigation?.defaultNextScreenId) {
// Обновляем предыдущий экран, чтобы он указывал на новый
updatedScreens = updatedScreens.map(screen =>
screen.id === lastScreen.id
? {
...screen,
navigation: {
...screen.navigation,
defaultNextScreenId: nextId,
}
}
: screen
);
}
}
return withDirty(state, {
...state,
screens: updatedScreens,
selectedScreenId: newScreen.id,
meta: {
...state.meta,
firstScreenId: state.meta.firstScreenId ?? newScreen.id,
},
});
}
case "insert-screen": {
const { atIndex, template = "list" } = action.payload;
const nextId = generateScreenId(state.screens.map((s) => s.id));
const newScreen = createScreenByTemplate(template, nextId);
// Вставляем экран на указанную позицию
const updatedScreens = [...state.screens];
updatedScreens.splice(atIndex, 0, newScreen);
// 🎯 АВТОМАТИЧЕСКОЕ СВЯЗЫВАНИЕ
// Обновляем навигацию предыдущего экрана (если есть)
if (atIndex > 0) {
const prevScreen = updatedScreens[atIndex - 1];
const nextScreen = updatedScreens[atIndex + 1];
// Если предыдущий экран указывал на следующий, перенаправляем через новый
if (nextScreen && prevScreen.navigation?.defaultNextScreenId === nextScreen.id) {
updatedScreens[atIndex - 1] = {
...prevScreen,
navigation: {
...prevScreen.navigation,
defaultNextScreenId: nextId,
},
};
// Новый экран указывает на следующий
updatedScreens[atIndex] = {
...newScreen,
navigation: {
...newScreen.navigation,
defaultNextScreenId: nextScreen.id,
},
};
} else if (!prevScreen.navigation?.defaultNextScreenId) {
// Если у предыдущего нет перехода, связываем с новым
updatedScreens[atIndex - 1] = {
...prevScreen,
navigation: {
...prevScreen.navigation,
defaultNextScreenId: nextId,
},
};
}
}
return withDirty(state, {
...state,
screens: updatedScreens,
selectedScreenId: newScreen.id,
meta: {
...state.meta,
firstScreenId: atIndex === 0 ? newScreen.id : state.meta.firstScreenId ?? newScreen.id,
},
});
}
case "remove-screen": {
const filtered = state.screens.filter((screen) => screen.id !== action.payload.screenId);
const selectedScreenId =
state.selectedScreenId === action.payload.screenId ? filtered[0]?.id ?? null : state.selectedScreenId;
const nextMeta = {
...state.meta,
firstScreenId:
state.meta.firstScreenId === action.payload.screenId
? filtered[0]?.id ?? null
: state.meta.firstScreenId,
};
return withDirty(state, {
...state,
screens: filtered,
selectedScreenId,
meta: nextMeta,
});
}
case "update-screen": {
const { screenId, screen } = action.payload;
let nextSelectedScreenId = state.selectedScreenId;
// Проверяем, меняется ли ID экрана
const isIdChange = "id" in screen && screen.id !== screenId;
const newScreenId = isIdChange ? screen.id! : screenId;
let nextScreens = state.screens.map((current) =>
current.id === screenId
? (() => {
const nextScreen = {
...current,
...screen,
title: screen.title ? { ...current.title, ...screen.title } : current.title,
// Если subtitle явно передан в screen (даже если undefined), используем его
// Иначе сохраняем текущий subtitle
...("subtitle" in screen
? { subtitle: screen.subtitle }
: "subtitle" in current
? { subtitle: current.subtitle }
: {}),
...(current.template === "list" && "list" in screen && screen.list
? {
list: {
...(current as ListScreenDefinition).list,
...screen.list,
options:
screen.list.options ??
(current as ListScreenDefinition).list.options,
},
}
: {}),
} as BuilderScreen;
if ("variants" in screen) {
if (Array.isArray(screen.variants) && screen.variants.length > 0) {
nextScreen.variants = screen.variants;
} else if ("variants" in nextScreen) {
delete (nextScreen as Partial<BuilderScreen>).variants;
}
}
if (state.selectedScreenId === current.id && nextScreen.id !== current.id) {
nextSelectedScreenId = nextScreen.id;
}
return nextScreen;
})()
: current
);
// Если изменился ID экрана, обновляем все ссылки на него
if (isIdChange) {
nextScreens = updateScreenIdReferences(nextScreens, screenId, newScreenId);
}
return withDirty(state, {
...state,
screens: nextScreens,
selectedScreenId: nextSelectedScreenId,
});
}
case "reorder-screens": {
const { fromIndex, toIndex } = action.payload;
const previousScreens = state.screens;
const newScreens = [...previousScreens];
const [movedScreen] = newScreens.splice(fromIndex, 1);
newScreens.splice(toIndex, 0, movedScreen);
const previousSequentialNext = new Map<string, string | undefined>();
const previousIndexMap = new Map<string, number>();
const newSequentialNext = new Map<string, string | undefined>();
previousScreens.forEach((screen, index) => {
previousSequentialNext.set(screen.id, previousScreens[index + 1]?.id);
previousIndexMap.set(screen.id, index);
});
newScreens.forEach((screen, index) => {
newSequentialNext.set(screen.id, newScreens[index + 1]?.id);
});
const totalScreens = newScreens.length;
const rewiredScreens = newScreens.map((screen, index) => {
const prevIndex = previousIndexMap.get(screen.id);
const prevSequential = previousSequentialNext.get(screen.id);
const nextSequential = newScreens[index + 1]?.id;
const navigation = screen.navigation;
const hasRules = Boolean(navigation?.rules && navigation.rules.length > 0);
let defaultNext = navigation?.defaultNextScreenId;
if (!hasRules) {
if (!defaultNext || defaultNext === prevSequential) {
defaultNext = nextSequential;
}
} else if (defaultNext === prevSequential) {
defaultNext = nextSequential;
}
const updatedNavigation = (() => {
if ((navigation?.rules && navigation.rules.length > 0) || defaultNext) {
// Обновляем nextScreenId в правилах навигации при reorder
const updatedRules = navigation?.rules?.map(rule => {
let updatedNextScreenId = rule.nextScreenId;
// Обновляем ссылки если правило указывает на экран, который "следовал" за другим экраном
// и эта последовательность изменилась
for (const [screenId, oldNext] of previousSequentialNext.entries()) {
const newNext = newSequentialNext.get(screenId);
// Если правило указывало на экран, который раньше был "следующим"
// за каким-то экраном, но теперь следующим стал другой экран
if (rule.nextScreenId === oldNext && newNext && oldNext !== newNext) {
updatedNextScreenId = newNext;
break;
}
}
return {
...rule,
nextScreenId: updatedNextScreenId
};
});
return {
...(updatedRules ? { rules: updatedRules } : {}),
...(defaultNext ? { defaultNextScreenId: defaultNext } : {}),
};
}
return undefined;
})();
let updatedHeader = screen.header;
if (screen.header?.progress) {
const progress = { ...screen.header.progress };
const previousProgress = prevIndex !== undefined ? previousScreens[prevIndex]?.header?.progress : undefined;
if (
typeof progress.current === "number" &&
prevIndex !== undefined &&
(progress.current === prevIndex + 1 || previousProgress?.current === prevIndex + 1)
) {
progress.current = index + 1;
}
if (typeof progress.total === "number") {
const previousTotal = previousProgress?.total ?? progress.total;
if (previousTotal === previousScreens.length) {
progress.total = totalScreens;
}
}
updatedHeader = {
...screen.header,
progress,
};
}
const nextScreen: BuilderScreen = {
...screen,
...(updatedHeader ? { header: updatedHeader } : {}),
};
if (updatedNavigation) {
nextScreen.navigation = updatedNavigation;
} else if ("navigation" in nextScreen) {
delete nextScreen.navigation;
}
return nextScreen;
});
const nextMeta = {
...state.meta,
firstScreenId: rewiredScreens[0]?.id,
};
const nextSelectedScreenId =
movedScreen && state.selectedScreenId === movedScreen.id
? movedScreen.id
: state.selectedScreenId;
return withDirty(state, {
...state,
screens: rewiredScreens,
meta: nextMeta,
selectedScreenId: nextSelectedScreenId,
});
}
case "set-selected-screen": {
return {
...state,
selectedScreenId: action.payload.screenId,
};
}
case "set-screens": {
return withDirty(state, {
...state,
screens: action.payload,
selectedScreenId: action.payload[0]?.id ?? null,
meta: {
...state.meta,
firstScreenId: state.meta.firstScreenId ?? action.payload[0]?.id,
},
});
}
case "update-navigation": {
const { screenId, navigation } = action.payload;
return withDirty(state, {
...state,
screens: state.screens.map((screen) =>
screen.id === screenId
? {
...screen,
navigation: {
defaultNextScreenId: navigation.defaultNextScreenId ?? undefined,
rules: navigation.rules ?? [],
isEndScreen: navigation.isEndScreen,
...("onBackScreenId" in navigation
? { onBackScreenId: navigation.onBackScreenId ?? undefined }
: {}),
},
}
: screen
),
});
}
case "reset": {
return action.payload ? { ...action.payload, isDirty: false } : INITIAL_STATE;
}
default:
return state;
}
}