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:
pennyteenycat 2025-09-26 20:42:36 +02:00 committed by GitHub
commit da92fe28c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 285 additions and 88 deletions

View File

@ -5,6 +5,7 @@ import { useCallback, useState } from "react";
import { BuilderLayout } from "@/components/admin/builder/BuilderLayout"; import { BuilderLayout } from "@/components/admin/builder/BuilderLayout";
import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar"; import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar";
import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas"; import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas";
import { BuilderPreview } from "@/components/admin/builder/BuilderPreview";
import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar"; import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar";
import { import {
BuilderProvider, BuilderProvider,
@ -41,6 +42,7 @@ function BuilderView() {
} }
sidebar={<BuilderSidebar />} sidebar={<BuilderSidebar />}
canvas={<BuilderCanvas />} canvas={<BuilderCanvas />}
preview={<BuilderPreview />}
showPreview={showPreview} showPreview={showPreview}
onTogglePreview={handleTogglePreview} onTogglePreview={handleTogglePreview}
/> />

View File

@ -1,10 +1,14 @@
"use client"; "use client";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context"; 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"; import { cn } from "@/lib/utils";
function DropIndicator({ isActive }: { isActive: boolean }) { 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 }) { function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
switch (screen.template) { switch (screen.template) {
case "list": { case "list": {
@ -253,16 +357,27 @@ export function BuilderCanvas() {
const isDropAfter = dropIndex === screens.length && index === screens.length - 1; const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
const rules = screen.navigation?.rules ?? []; const rules = screen.navigation?.rules ?? [];
const defaultNext = screen.navigation?.defaultNextScreenId; const defaultNext = screen.navigation?.defaultNextScreenId;
const isLast = index === screens.length - 1;
const defaultTargetIndex = defaultNext
? screens.findIndex((candidate) => candidate.id === defaultNext)
: null;
return ( return (
<div key={screen.id} className="relative"> <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} />} {isDropBefore && <DropIndicator isActive={isDropBefore} />}
<div className="flex items-start gap-4 md:gap-6"> <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 <div
className={cn( 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", isSelected && "border-primary/50 ring-2 ring-primary",
dragStateRef.current?.screenId === screen.id && "cursor-grabbing opacity-90" dragStateRef.current?.screenId === screen.id && "cursor-grabbing opacity-90"
)} )}
@ -272,45 +387,47 @@ export function BuilderCanvas() {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onClick={() => handleSelectScreen(screen.id)} onClick={() => handleSelectScreen(screen.id)}
> >
<div className="flex flex-wrap items-start justify-between gap-4"> <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">
<div className="flex items-center gap-3"> {TEMPLATE_TITLES[screen.template] ?? screen.template}
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"> </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} {index + 1}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">#{screen.id}</span> <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-lg font-semibold text-foreground"> #{screen.id}
</span>
<span className="max-h-14 overflow-hidden break-words text-lg font-semibold leading-tight text-foreground">
{screen.title.text || "Без названия"} {screen.title.text || "Без названия"}
</span> </span>
</div> </div>
</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> </div>
{"subtitle" in screen && screen.subtitle?.text && ( {("subtitle" in screen && screen.subtitle?.text) && (
<p className="mt-3 text-sm text-muted-foreground">{screen.subtitle.text}</p> <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} /> <TemplateSummary screen={screen} />
<div className="space-y-2"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span> <span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
<div className="h-px flex-1 bg-border/60" /> <div className="h-px flex-1 bg-border/60" />
</div> </div>
<div className="space-y-2"> <div className="space-y-3">
<div className="flex flex-col gap-1 rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"> <TransitionRow
<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"> type={defaultNext ? "default" : "end"}
По умолчанию label={defaultNext ? "По умолчанию" : "Завершение"}
</span> targetLabel={defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : undefined}
<span className="text-sm text-foreground"> targetIndex={defaultNext && defaultTargetIndex !== -1 ? defaultTargetIndex : null}
{defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : "Воронка завершится"} />
</span>
</div>
{rules.map((rule, ruleIndex) => { {rules.map((rule, ruleIndex) => {
const condition = rule.conditions[0]; 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 ( return (
<div <TransitionRow
key={`${ruleIndex}-${rule.nextScreenId}`} 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" type="branch"
> label="Вариативность"
<div className="flex flex-wrap items-center gap-2"> targetLabel={ruleTargetLabel}
<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"> targetIndex={ruleTargetIndex !== -1 ? ruleTargetIndex : null}
Вариативность optionSummaries={optionSummaries}
</span> operator={operatorLabel}
{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>
); );
})} })}
</div> </div>

View File

@ -478,30 +478,28 @@ export function BuilderSidebar() {
</select> </select>
</label> </label>
{selectedScreen.template === "list" && ( <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> <span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
<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">
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3"> {selectedScreen.list.options.map((option) => {
{selectedScreen.list.options.map((option) => { const condition = rule.conditions[0];
const condition = rule.conditions[0]; const isChecked = condition.optionIds?.includes(option.id) ?? false;
const isChecked = condition.optionIds?.includes(option.id) ?? false; return (
return ( <label key={option.id} className="flex items-center gap-2 text-sm">
<label key={option.id} className="flex items-center gap-2 text-sm"> <input
<input type="checkbox"
type="checkbox" checked={isChecked}
checked={isChecked} onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)} />
/> <span>
<span> {option.label}
{option.label} <span className="text-muted-foreground"> ({option.id})</span>
<span className="text-muted-foreground"> ({option.id})</span> </span>
</span> </label>
</label> );
); })}
})}
</div>
</div> </div>
)} </div>
<label className="flex flex-col gap-2"> <label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span> <span className="text-sm font-medium text-muted-foreground">Следующий экран</span>

View File

@ -65,7 +65,8 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
className="h-8 px-3 text-xs text-destructive" size="sm"
className="text-destructive"
onClick={() => removeField(index)} onClick={() => removeField(index)}
> >
Удалить Удалить

View File

@ -76,9 +76,7 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
<select <select
className="rounded-lg border border-border bg-background px-2 py-1" className="rounded-lg border border-border bg-background px-2 py-1"
value={infoScreen.icon?.size ?? "lg"} value={infoScreen.icon?.size ?? "lg"}
onChange={(event) => onChange={(event) => handleIconChange("size", event.target.value)}
handleIconChange("size", event.target.value as "sm" | "md" | "lg" | "xl")
}
> >
<option value="sm">Маленький</option> <option value="sm">Маленький</option>
<option value="md">Средний</option> <option value="md">Средний</option>

View File

@ -221,13 +221,103 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat
} }
case "reorder-screens": { case "reorder-screens": {
const { fromIndex, toIndex } = action.payload; const { fromIndex, toIndex } = action.payload;
const newScreens = [...state.screens]; const previousScreens = state.screens;
const [removed] = newScreens.splice(fromIndex, 1); const newScreens = [...previousScreens];
newScreens.splice(toIndex, 0, removed); 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, { return withDirty(state, {
...state, ...state,
screens: newScreens, screens: rewiredScreens,
meta: nextMeta,
selectedScreenId: nextSelectedScreenId,
}); });
} }
case "set-selected-screen": { case "set-selected-screen": {