Merge pull request #10 from WIT-LAB-LLC/codex/complete-admin-panel-for-funnels-jbwsju
Restore preview and improve funnel builder reordering
This commit is contained in:
commit
da92fe28c8
@ -5,6 +5,7 @@ import { useCallback, useState } from "react";
|
||||
import { BuilderLayout } from "@/components/admin/builder/BuilderLayout";
|
||||
import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar";
|
||||
import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas";
|
||||
import { BuilderPreview } from "@/components/admin/builder/BuilderPreview";
|
||||
import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar";
|
||||
import {
|
||||
BuilderProvider,
|
||||
@ -41,6 +42,7 @@ function BuilderView() {
|
||||
}
|
||||
sidebar={<BuilderSidebar />}
|
||||
canvas={<BuilderCanvas />}
|
||||
preview={<BuilderPreview />}
|
||||
showPreview={showPreview}
|
||||
onTogglePreview={handleTogglePreview}
|
||||
/>
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import type { ListOptionDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
||||
import type {
|
||||
ListOptionDefinition,
|
||||
NavigationConditionDefinition,
|
||||
ScreenDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropIndicator({ isActive }: { isActive: boolean }) {
|
||||
@ -18,6 +22,106 @@ function DropIndicator({ isActive }: { isActive: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
|
||||
list: "Список",
|
||||
form: "Форма",
|
||||
info: "Инфо",
|
||||
date: "Дата",
|
||||
coupon: "Купон",
|
||||
};
|
||||
|
||||
const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
|
||||
includesAny: "любой из",
|
||||
includesAll: "все из",
|
||||
includesExactly: "точное совпадение",
|
||||
};
|
||||
|
||||
interface TransitionRowProps {
|
||||
type: "default" | "branch" | "end";
|
||||
label: string;
|
||||
targetLabel?: string;
|
||||
targetIndex?: number | null;
|
||||
optionSummaries?: { id: string; label: string }[];
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
function TransitionRow({
|
||||
type,
|
||||
label,
|
||||
targetLabel,
|
||||
targetIndex,
|
||||
optionSummaries = [],
|
||||
operator,
|
||||
}: TransitionRowProps) {
|
||||
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
|
||||
type === "branch"
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border/60 bg-background/90"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
|
||||
type === "branch" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-wide",
|
||||
type === "branch" ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{operator && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||
{operator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{optionSummaries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{optionSummaries.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
{type === "end" ? (
|
||||
<span className="text-muted-foreground">Завершение воронки</span>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
{typeof targetIndex === "number" && (
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
#{targetIndex + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{targetLabel ?? "Не выбрано"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
|
||||
switch (screen.template) {
|
||||
case "list": {
|
||||
@ -253,16 +357,27 @@ export function BuilderCanvas() {
|
||||
const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const defaultNext = screen.navigation?.defaultNextScreenId;
|
||||
const isLast = index === screens.length - 1;
|
||||
const defaultTargetIndex = defaultNext
|
||||
? screens.findIndex((candidate) => candidate.id === defaultNext)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={screen.id} className="relative">
|
||||
<div className="absolute left-0 top-6 hidden h-[calc(100%-1.5rem)] w-px bg-border md:block" aria-hidden />
|
||||
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
|
||||
<div className="flex items-start gap-4 md:gap-6">
|
||||
<div className="relative mt-1 hidden h-3 w-3 flex-shrink-0 rounded-full border-2 border-background bg-primary shadow md:block" />
|
||||
<div className="relative hidden w-8 flex-shrink-0 md:flex md:flex-col md:items-center">
|
||||
<span className="mt-1 h-3 w-3 rounded-full border-2 border-background bg-primary shadow" />
|
||||
{!isLast && (
|
||||
<div className="mt-2 flex h-full flex-col items-center">
|
||||
<div className="flex-1 w-px bg-gradient-to-b from-primary/40 via-border/40 to-transparent" />
|
||||
<ArrowDown className="mt-1 h-4 w-4 text-border/70" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
|
||||
"relative flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
|
||||
isSelected && "border-primary/50 ring-2 ring-primary",
|
||||
dragStateRef.current?.screenId === screen.id && "cursor-grabbing opacity-90"
|
||||
)}
|
||||
@ -272,45 +387,47 @@ export function BuilderCanvas() {
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => handleSelectScreen(screen.id)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
<span className="absolute right-5 top-5 inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{TEMPLATE_TITLES[screen.template] ?? screen.template}
|
||||
</span>
|
||||
<div className="pr-28">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">#{screen.id}</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
#{screen.id}
|
||||
</span>
|
||||
<span className="max-h-14 overflow-hidden break-words text-lg font-semibold leading-tight text-foreground">
|
||||
{screen.title.text || "Без названия"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs font-medium uppercase text-muted-foreground">
|
||||
{screen.template}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{"subtitle" in screen && screen.subtitle?.text && (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{screen.subtitle.text}</p>
|
||||
{("subtitle" in screen && screen.subtitle?.text) && (
|
||||
<p className="mt-3 max-h-12 overflow-hidden text-sm leading-snug text-muted-foreground">
|
||||
{screen.subtitle.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="mt-4 space-y-5">
|
||||
<TemplateSummary screen={screen} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-1 rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex w-fit items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
По умолчанию
|
||||
</span>
|
||||
<span className="text-sm text-foreground">
|
||||
{defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : "Воронка завершится"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<TransitionRow
|
||||
type={defaultNext ? "default" : "end"}
|
||||
label={defaultNext ? "По умолчанию" : "Завершение"}
|
||||
targetLabel={defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : undefined}
|
||||
targetIndex={defaultNext && defaultTargetIndex !== -1 ? defaultTargetIndex : null}
|
||||
/>
|
||||
|
||||
{rules.map((rule, ruleIndex) => {
|
||||
const condition = rule.conditions[0];
|
||||
@ -322,37 +439,28 @@ export function BuilderCanvas() {
|
||||
}))
|
||||
: [];
|
||||
|
||||
const operatorKey = condition?.operator as
|
||||
| Exclude<NavigationConditionDefinition["operator"], undefined>
|
||||
| undefined;
|
||||
const operatorLabel = operatorKey
|
||||
? OPERATOR_LABELS[operatorKey] ?? operatorKey
|
||||
: undefined;
|
||||
|
||||
const ruleTargetIndex = screens.findIndex(
|
||||
(candidate) => candidate.id === rule.nextScreenId
|
||||
);
|
||||
const ruleTargetLabel = screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId;
|
||||
|
||||
return (
|
||||
<div
|
||||
<TransitionRow
|
||||
key={`${ruleIndex}-${rule.nextScreenId}`}
|
||||
className="flex flex-col gap-2 rounded-xl border border-primary/30 bg-primary/5 p-3 text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">
|
||||
Вариативность
|
||||
</span>
|
||||
{condition?.operator && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||
{condition.operator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{optionSummaries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{optionSummaries.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className="inline-flex items-center rounded-lg bg-background px-2 py-1 text-[11px] text-foreground"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-foreground">
|
||||
→ {screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId}
|
||||
</div>
|
||||
</div>
|
||||
type="branch"
|
||||
label="Вариативность"
|
||||
targetLabel={ruleTargetLabel}
|
||||
targetIndex={ruleTargetIndex !== -1 ? ruleTargetIndex : null}
|
||||
optionSummaries={optionSummaries}
|
||||
operator={operatorLabel}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -478,30 +478,28 @@ export function BuilderSidebar() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedScreen.template === "list" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
|
||||
{selectedScreen.list.options.map((option) => {
|
||||
const condition = rule.conditions[0];
|
||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||
return (
|
||||
<label key={option.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground"> ({option.id})</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
|
||||
{selectedScreen.list.options.map((option) => {
|
||||
const condition = rule.conditions[0];
|
||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||
return (
|
||||
<label key={option.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground"> ({option.id})</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
|
||||
|
||||
@ -65,7 +65,8 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-3 text-xs text-destructive"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => removeField(index)}
|
||||
>
|
||||
Удалить
|
||||
|
||||
@ -76,9 +76,7 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
value={infoScreen.icon?.size ?? "lg"}
|
||||
onChange={(event) =>
|
||||
handleIconChange("size", event.target.value as "sm" | "md" | "lg" | "xl")
|
||||
}
|
||||
onChange={(event) => handleIconChange("size", event.target.value)}
|
||||
>
|
||||
<option value="sm">Маленький</option>
|
||||
<option value="md">Средний</option>
|
||||
|
||||
@ -221,13 +221,103 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat
|
||||
}
|
||||
case "reorder-screens": {
|
||||
const { fromIndex, toIndex } = action.payload;
|
||||
const newScreens = [...state.screens];
|
||||
const [removed] = newScreens.splice(fromIndex, 1);
|
||||
newScreens.splice(toIndex, 0, removed);
|
||||
|
||||
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>();
|
||||
|
||||
previousScreens.forEach((screen, index) => {
|
||||
previousSequentialNext.set(screen.id, previousScreens[index + 1]?.id);
|
||||
previousIndexMap.set(screen.id, index);
|
||||
});
|
||||
|
||||
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) {
|
||||
return {
|
||||
...(navigation?.rules ? { rules: navigation.rules } : {}),
|
||||
...(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: newScreens,
|
||||
screens: rewiredScreens,
|
||||
meta: nextMeta,
|
||||
selectedScreenId: nextSelectedScreenId,
|
||||
});
|
||||
}
|
||||
case "set-selected-screen": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user