add trial choice template
This commit is contained in:
parent
360a15d5a7
commit
adfe8830d4
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/admin/builder/Canvas/InsertScreenButton.tsx
Normal file
47
src/components/admin/builder/Canvas/InsertScreenButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 } }
|
||||
|
||||
@ -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 }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user