add trial choice template
This commit is contained in:
parent
360a15d5a7
commit
adfe8830d4
@ -11,6 +11,7 @@ import type {
|
|||||||
} from "@/lib/funnel/types";
|
} from "@/lib/funnel/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DropIndicator } from "./DropIndicator";
|
import { DropIndicator } from "./DropIndicator";
|
||||||
|
import { InsertScreenButton } from "./InsertScreenButton";
|
||||||
import { TransitionRow } from "./TransitionRow";
|
import { TransitionRow } from "./TransitionRow";
|
||||||
import { TemplateSummary } from "./TemplateSummary";
|
import { TemplateSummary } from "./TemplateSummary";
|
||||||
import { VariantSummary } from "./VariantSummary";
|
import { VariantSummary } from "./VariantSummary";
|
||||||
@ -24,6 +25,8 @@ export function BuilderCanvas() {
|
|||||||
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
|
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
|
||||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false);
|
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) => {
|
const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
@ -115,6 +118,17 @@ export function BuilderCanvas() {
|
|||||||
dispatch({ type: "add-screen", payload: { template } });
|
dispatch({ type: "add-screen", payload: { template } });
|
||||||
}, [dispatch]);
|
}, [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(() => {
|
const screenTitleMap = useMemo(() => {
|
||||||
return screens.reduce<Record<string, string>>((accumulator, screen) => {
|
return screens.reduce<Record<string, string>>((accumulator, screen) => {
|
||||||
accumulator[screen.id] = screen.title?.text || screen.id;
|
accumulator[screen.id] = screen.title?.text || screen.id;
|
||||||
@ -159,10 +173,15 @@ export function BuilderCanvas() {
|
|||||||
const defaultTargetIndex = defaultNext
|
const defaultTargetIndex = defaultNext
|
||||||
? screens.findIndex((candidate) => candidate.id === defaultNext)
|
? screens.findIndex((candidate) => candidate.id === defaultNext)
|
||||||
: null;
|
: null;
|
||||||
|
const onBackScreenId = screen.navigation?.onBackScreenId;
|
||||||
|
const backTargetIndex = onBackScreenId
|
||||||
|
? screens.findIndex((candidate) => candidate.id === onBackScreenId)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={screen.id} className="relative">
|
<div key={screen.id} className="relative">
|
||||||
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
|
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -217,6 +236,15 @@ export function BuilderCanvas() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{onBackScreenId && (
|
||||||
|
<TransitionRow
|
||||||
|
type="back"
|
||||||
|
label="← Переход назад"
|
||||||
|
targetLabel={screenTitleMap[onBackScreenId] ?? onBackScreenId}
|
||||||
|
targetIndex={backTargetIndex !== -1 ? backTargetIndex : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<TransitionRow
|
<TransitionRow
|
||||||
type={
|
type={
|
||||||
screen.navigation?.isEndScreen
|
screen.navigation?.isEndScreen
|
||||||
@ -275,6 +303,10 @@ export function BuilderCanvas() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Insert button after each screen */}
|
||||||
|
<InsertScreenButton onInsert={() => handleInsertScreen(index + 1)} />
|
||||||
|
|
||||||
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
|
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -301,6 +333,12 @@ export function BuilderCanvas() {
|
|||||||
onOpenChange={setAddScreenDialogOpen}
|
onOpenChange={setAddScreenDialogOpen}
|
||||||
onAddScreen={handleAddScreenWithTemplate}
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface TransitionRowProps {
|
export interface TransitionRowProps {
|
||||||
type: "default" | "branch" | "end";
|
type: "default" | "branch" | "end" | "back";
|
||||||
label: string;
|
label: string;
|
||||||
targetLabel?: string;
|
targetLabel?: string;
|
||||||
targetIndex?: number | null;
|
targetIndex?: number | null;
|
||||||
@ -18,7 +18,7 @@ export function TransitionRow({
|
|||||||
optionSummaries = [],
|
optionSummaries = [],
|
||||||
operator,
|
operator,
|
||||||
}: TransitionRowProps) {
|
}: TransitionRowProps) {
|
||||||
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
|
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : type === "back" ? ArrowLeft : ArrowDown;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -26,13 +26,19 @@ export function TransitionRow({
|
|||||||
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
|
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
|
||||||
type === "branch"
|
type === "branch"
|
||||||
? "border-primary/40 bg-primary/5"
|
? "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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
|
"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" />
|
<Icon className="h-4 w-4" />
|
||||||
@ -42,7 +48,11 @@ export function TransitionRow({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[11px] font-semibold uppercase tracking-wide",
|
"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}
|
{label}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export { BuilderCanvas } from "./BuilderCanvas";
|
|||||||
|
|
||||||
// Sub-components
|
// Sub-components
|
||||||
export { DropIndicator } from "./DropIndicator";
|
export { DropIndicator } from "./DropIndicator";
|
||||||
|
export { InsertScreenButton } from "./InsertScreenButton";
|
||||||
export { TransitionRow } from "./TransitionRow";
|
export { TransitionRow } from "./TransitionRow";
|
||||||
export { TemplateSummary } from "./TemplateSummary";
|
export { TemplateSummary } from "./TemplateSummary";
|
||||||
export { VariantSummary } from "./VariantSummary";
|
export { VariantSummary } from "./VariantSummary";
|
||||||
|
|||||||
@ -294,18 +294,16 @@ interface HeaderControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HeaderControls({ header, onChange }: HeaderControlsProps) {
|
function HeaderControls({ header, onChange }: HeaderControlsProps) {
|
||||||
const activeHeader = header ?? { show: true, showBackButton: true };
|
const activeHeader = header ?? {
|
||||||
|
show: true,
|
||||||
const handleToggle = (field: "show" | "showBackButton", checked: boolean) => {
|
showBackButton: true,
|
||||||
if (field === "show" && !checked) {
|
showProgress: true
|
||||||
onChange({
|
};
|
||||||
...activeHeader,
|
|
||||||
show: false,
|
|
||||||
showBackButton: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleToggle = (
|
||||||
|
field: "show" | "showBackButton" | "showProgress",
|
||||||
|
checked: boolean
|
||||||
|
) => {
|
||||||
onChange({
|
onChange({
|
||||||
...activeHeader,
|
...activeHeader,
|
||||||
[field]: checked,
|
[field]: checked,
|
||||||
@ -320,11 +318,22 @@ function HeaderControls({ header, onChange }: HeaderControlsProps) {
|
|||||||
checked={activeHeader.show !== false}
|
checked={activeHeader.show !== false}
|
||||||
onChange={(event) => handleToggle("show", event.target.checked)}
|
onChange={(event) => handleToggle("show", event.target.checked)}
|
||||||
/>
|
/>
|
||||||
Показывать шапку с прогрессом
|
Показывать шапку экрана
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{activeHeader.show !== false && (
|
{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">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
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": {
|
case "remove-screen": {
|
||||||
const filtered = state.screens.filter((screen) => screen.id !== action.payload.screenId);
|
const filtered = state.screens.filter((screen) => screen.id !== action.payload.screenId);
|
||||||
const selectedScreenId =
|
const selectedScreenId =
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export type BuilderAction =
|
|||||||
| { type: "set-meta"; payload: Partial<BuilderFunnelState["meta"]> }
|
| { type: "set-meta"; payload: Partial<BuilderFunnelState["meta"]> }
|
||||||
| { type: "set-default-texts"; payload: Partial<BuilderFunnelState["defaultTexts"]> }
|
| { type: "set-default-texts"; payload: Partial<BuilderFunnelState["defaultTexts"]> }
|
||||||
| { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> }
|
| { 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: "remove-screen"; payload: { screenId: string } }
|
||||||
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
|
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
|
||||||
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }
|
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }
|
||||||
|
|||||||
@ -96,6 +96,7 @@ const HeaderDefinitionSchema = new Schema(
|
|||||||
className: String,
|
className: String,
|
||||||
},
|
},
|
||||||
showBackButton: { type: Boolean, default: true },
|
showBackButton: { type: Boolean, default: true },
|
||||||
|
showProgress: { type: Boolean, default: true },
|
||||||
show: { type: Boolean, default: true },
|
show: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user