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 { 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}
/>

View File

@ -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>

View File

@ -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>

View File

@ -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)}
>
Удалить

View File

@ -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>

View File

@ -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": {