444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
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;
|
||
}
|
||
}
|