add trial choice template

This commit is contained in:
dev.daminik00 2025-10-22 22:42:01 +02:00
parent 360a15d5a7
commit adfe8830d4
17 changed files with 181 additions and 19 deletions

View File

@ -11,6 +11,7 @@ import type {
} from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
import { DropIndicator } from "./DropIndicator";
import { InsertScreenButton } from "./InsertScreenButton";
import { TransitionRow } from "./TransitionRow";
import { TemplateSummary } from "./TemplateSummary";
import { VariantSummary } from "./VariantSummary";
@ -24,6 +25,8 @@ export function BuilderCanvas() {
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false);
const [insertScreenDialogOpen, setInsertScreenDialogOpen] = useState(false);
const [insertAtIndex, setInsertAtIndex] = useState<number | null>(null);
const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
event.dataTransfer.effectAllowed = "move";
@ -115,6 +118,17 @@ export function BuilderCanvas() {
dispatch({ type: "add-screen", payload: { template } });
}, [dispatch]);
const handleInsertScreen = useCallback((atIndex: number) => {
setInsertAtIndex(atIndex);
setInsertScreenDialogOpen(true);
}, []);
const handleInsertScreenWithTemplate = useCallback((template: ScreenDefinition["template"]) => {
if (insertAtIndex !== null) {
dispatch({ type: "insert-screen", payload: { template, atIndex: insertAtIndex } });
}
}, [dispatch, insertAtIndex]);
const screenTitleMap = useMemo(() => {
return screens.reduce<Record<string, string>>((accumulator, screen) => {
accumulator[screen.id] = screen.title?.text || screen.id;
@ -159,10 +173,15 @@ export function BuilderCanvas() {
const defaultTargetIndex = defaultNext
? screens.findIndex((candidate) => candidate.id === defaultNext)
: null;
const onBackScreenId = screen.navigation?.onBackScreenId;
const backTargetIndex = onBackScreenId
? screens.findIndex((candidate) => candidate.id === onBackScreenId)
: null;
return (
<div key={screen.id} className="relative">
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
<div>
<div
className={cn(
@ -217,6 +236,15 @@ export function BuilderCanvas() {
</div>
<div className="space-y-3">
{onBackScreenId && (
<TransitionRow
type="back"
label="← Переход назад"
targetLabel={screenTitleMap[onBackScreenId] ?? onBackScreenId}
targetIndex={backTargetIndex !== -1 ? backTargetIndex : null}
/>
)}
<TransitionRow
type={
screen.navigation?.isEndScreen
@ -275,6 +303,10 @@ export function BuilderCanvas() {
</div>
</div>
</div>
{/* Insert button after each screen */}
<InsertScreenButton onInsert={() => handleInsertScreen(index + 1)} />
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
</div>
);
@ -301,6 +333,12 @@ export function BuilderCanvas() {
onOpenChange={setAddScreenDialogOpen}
onAddScreen={handleAddScreenWithTemplate}
/>
<AddScreenDialog
open={insertScreenDialogOpen}
onOpenChange={setInsertScreenDialogOpen}
onAddScreen={handleInsertScreenWithTemplate}
/>
</>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState } from "react";
interface InsertScreenButtonProps {
onInsert: () => void;
}
export function InsertScreenButton({ onInsert }: InsertScreenButtonProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="group relative flex h-0 items-center justify-center transition-all"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Hover area - wider for easier interaction */}
<div className="absolute inset-0 h-12 -translate-y-1/2 -mx-8" />
{/* Divider line */}
<div
className={cn(
"absolute left-0 right-0 h-px transition-all",
isHovered ? "bg-primary/40" : "bg-transparent"
)}
/>
{/* Insert button */}
<button
type="button"
onClick={onInsert}
className={cn(
"relative z-10 flex h-7 w-7 items-center justify-center rounded-full border-2 bg-background shadow-sm transition-all",
isHovered
? "scale-110 border-primary/50 text-primary hover:bg-primary hover:text-primary-foreground"
: "scale-90 border-border/40 text-muted-foreground opacity-0 group-hover:opacity-100"
)}
aria-label="Вставить экран"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
);
}

View File

@ -1,8 +1,8 @@
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
import { ArrowDown, ArrowRight, ArrowLeft, CircleSlash2, GitBranch } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TransitionRowProps {
type: "default" | "branch" | "end";
type: "default" | "branch" | "end" | "back";
label: string;
targetLabel?: string;
targetIndex?: number | null;
@ -18,7 +18,7 @@ export function TransitionRow({
optionSummaries = [],
operator,
}: TransitionRowProps) {
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : type === "back" ? ArrowLeft : ArrowDown;
return (
<div
@ -26,13 +26,19 @@ export function TransitionRow({
"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"
: type === "back"
? "border-orange-400/40 bg-orange-50/50 dark:bg-orange-950/20"
: "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"
type === "branch"
? "bg-primary text-primary-foreground"
: type === "back"
? "bg-orange-500 text-white dark:bg-orange-600"
: "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
@ -42,7 +48,11 @@ export function TransitionRow({
<span
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
type === "branch" ? "text-primary" : "text-muted-foreground"
type === "branch"
? "text-primary"
: type === "back"
? "text-orange-600 dark:text-orange-400"
: "text-muted-foreground"
)}
>
{label}

View File

@ -3,6 +3,7 @@ export { BuilderCanvas } from "./BuilderCanvas";
// Sub-components
export { DropIndicator } from "./DropIndicator";
export { InsertScreenButton } from "./InsertScreenButton";
export { TransitionRow } from "./TransitionRow";
export { TemplateSummary } from "./TemplateSummary";
export { VariantSummary } from "./VariantSummary";

View File

@ -294,18 +294,16 @@ interface HeaderControlsProps {
}
function HeaderControls({ header, onChange }: HeaderControlsProps) {
const activeHeader = header ?? { show: true, showBackButton: true };
const handleToggle = (field: "show" | "showBackButton", checked: boolean) => {
if (field === "show" && !checked) {
onChange({
...activeHeader,
show: false,
showBackButton: false,
});
return;
}
const activeHeader = header ?? {
show: true,
showBackButton: true,
showProgress: true
};
const handleToggle = (
field: "show" | "showBackButton" | "showProgress",
checked: boolean
) => {
onChange({
...activeHeader,
[field]: checked,
@ -320,11 +318,22 @@ function HeaderControls({ header, onChange }: HeaderControlsProps) {
checked={activeHeader.show !== false}
onChange={(event) => handleToggle("show", event.target.checked)}
/>
Показывать шапку с прогрессом
Показывать шапку экрана
</label>
{activeHeader.show !== false && (
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs">
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={activeHeader.showProgress !== false}
onChange={(event) =>
handleToggle("showProgress", event.target.checked)
}
/>
Показывать прогресс бар
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"

View File

@ -129,6 +129,61 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
},
});
}
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 =

View File

@ -13,6 +13,7 @@ export type BuilderAction =
| { type: "set-meta"; payload: Partial<BuilderFunnelState["meta"]> }
| { type: "set-default-texts"; payload: Partial<BuilderFunnelState["defaultTexts"]> }
| { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> }
| { type: "insert-screen"; payload: { template?: ScreenDefinition["template"]; atIndex: number } & Partial<BuilderScreen> }
| { type: "remove-screen"; payload: { screenId: string } }
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }

View File

@ -96,6 +96,7 @@ const HeaderDefinitionSchema = new Schema(
className: String,
},
showBackButton: { type: Boolean, default: true },
showProgress: { type: Boolean, default: true },
show: { type: Boolean, default: true },
},
{ _id: false }