610 lines
24 KiB
TypeScript
610 lines
24 KiB
TypeScript
"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 { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
|
||
import { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog";
|
||
import type {
|
||
ListOptionDefinition,
|
||
NavigationConditionDefinition,
|
||
ScreenDefinition,
|
||
ScreenVariantDefinition,
|
||
} from "@/lib/funnel/types";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
function DropIndicator({ isActive }: { isActive: boolean }) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"mx-4 h-9 rounded-xl border-2 border-dashed border-primary/50 bg-primary/10 transition-all",
|
||
isActive ? "opacity-100" : "pointer-events-none opacity-0"
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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": {
|
||
return (
|
||
<div className="space-y-2 text-xs text-muted-foreground">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
|
||
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-foreground">Варианты ({screen.list.options.length})</p>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{screen.list.options.map((option) => (
|
||
<span
|
||
key={option.id}
|
||
className="inline-flex items-center gap-1 rounded-lg bg-primary/5 px-2 py-1 text-[11px] text-primary"
|
||
>
|
||
{option.emoji && <span className="text-base leading-none">{option.emoji}</span>}
|
||
<span className="font-medium">{option.label}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
case "form": {
|
||
return (
|
||
<div className="space-y-2 text-xs text-muted-foreground">
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
|
||
Полей: {screen.fields.length}
|
||
</span>
|
||
{screen.bottomActionButton?.text && (
|
||
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
|
||
{screen.bottomActionButton.text}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{screen.validationMessages && (
|
||
<div className="rounded-lg border border-border/50 bg-background/80 p-2">
|
||
<p className="text-[11px] text-muted-foreground">
|
||
Настроены пользовательские сообщения валидации
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
case "coupon": {
|
||
return (
|
||
<div className="space-y-2 text-xs text-muted-foreground">
|
||
<p>
|
||
<span className="font-medium">Промо:</span> {screen.coupon.promoCode.text}
|
||
</p>
|
||
<p className="text-muted-foreground/80">{screen.coupon.offer.title.text}</p>
|
||
</div>
|
||
);
|
||
}
|
||
case "date": {
|
||
return (
|
||
<div className="space-y-2 text-xs text-muted-foreground">
|
||
<p className="font-medium">Формат даты:</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{screen.dateInput.monthLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.monthLabel}</span>}
|
||
{screen.dateInput.dayLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.dayLabel}</span>}
|
||
{screen.dateInput.yearLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.yearLabel}</span>}
|
||
</div>
|
||
{screen.dateInput.validationMessage && (
|
||
<p className="text-[11px] text-destructive">{screen.dateInput.validationMessage}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
case "info": {
|
||
return (
|
||
<div className="space-y-2 text-xs text-muted-foreground">
|
||
{screen.description?.text && <p>{screen.description.text}</p>}
|
||
{screen.icon?.value && (
|
||
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
|
||
<span className="text-base">{screen.icon.value}</span>
|
||
<span className="text-[11px] uppercase text-muted-foreground">Иконка</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function VariantSummary({
|
||
screen,
|
||
screenTitleMap,
|
||
listOptionsMap,
|
||
}: {
|
||
screen: ScreenDefinition;
|
||
screenTitleMap: Record<string, string>;
|
||
listOptionsMap: Record<string, ListOptionDefinition[]>;
|
||
}) {
|
||
const variants = (
|
||
screen as ScreenDefinition & {
|
||
variants?: ScreenVariantDefinition<ScreenDefinition>[];
|
||
}
|
||
).variants;
|
||
|
||
if (!variants || variants.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<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" />
|
||
<span className="text-[11px] uppercase text-muted-foreground/70">{variants.length}</span>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{variants.map((variant, index) => {
|
||
const [condition] = variant.conditions ?? [];
|
||
const controllingScreenId = condition?.screenId;
|
||
const controllingScreenTitle = controllingScreenId
|
||
? screenTitleMap[controllingScreenId] ?? controllingScreenId
|
||
: "Не выбрано";
|
||
|
||
const options = controllingScreenId ? listOptionsMap[controllingScreenId] ?? [] : [];
|
||
const optionSummaries = (condition?.optionIds ?? []).map((optionId) => ({
|
||
id: optionId,
|
||
label: getOptionLabel(options, optionId),
|
||
}));
|
||
|
||
const operatorKey = condition?.operator as
|
||
| Exclude<NavigationConditionDefinition["operator"], undefined>
|
||
| undefined;
|
||
const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
|
||
|
||
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
|
||
|
||
return (
|
||
<div
|
||
key={`${index}-${controllingScreenId ?? "none"}`}
|
||
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-3"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Вариант {index + 1}</span>
|
||
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||
{operatorLabel}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-1 text-xs text-primary/90">
|
||
<div>
|
||
<span className="font-semibold">Экран:</span> {controllingScreenTitle}
|
||
</div>
|
||
{optionSummaries.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{optionSummaries.map((option) => (
|
||
<span key={option.id} className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium">
|
||
{option.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-primary/70">Нет выбранных ответов</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-1 text-xs text-primary/90">
|
||
<span className="font-semibold">Изменяет:</span>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
|
||
<span
|
||
key={highlight}
|
||
className="rounded-lg bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||
>
|
||
{highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
|
||
const option = options.find((item) => item.id === optionId);
|
||
return option ? option.label : optionId;
|
||
}
|
||
|
||
export function BuilderCanvas() {
|
||
const { screens, selectedScreenId } = useBuilderState();
|
||
const dispatch = useBuilderDispatch();
|
||
|
||
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
|
||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||
const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false);
|
||
|
||
const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
|
||
event.dataTransfer.effectAllowed = "move";
|
||
event.dataTransfer.setData("text/plain", screenId);
|
||
dragStateRef.current = { screenId, dragStartIndex: index };
|
||
setDropIndex(index);
|
||
}, []);
|
||
|
||
const handleDragOverCard = useCallback((event: React.DragEvent<HTMLDivElement>, index: number) => {
|
||
event.preventDefault();
|
||
if (!dragStateRef.current) {
|
||
return;
|
||
}
|
||
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
const offsetY = event.clientY - rect.top;
|
||
const nextIndex = offsetY > rect.height / 2 ? index + 1 : index;
|
||
setDropIndex(nextIndex);
|
||
}, []);
|
||
|
||
const handleDragOverList = useCallback(
|
||
(event: React.DragEvent<HTMLDivElement>) => {
|
||
if (!dragStateRef.current) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
if (event.target === event.currentTarget) {
|
||
setDropIndex(screens.length);
|
||
}
|
||
},
|
||
[screens.length]
|
||
);
|
||
|
||
const finalizeDrop = useCallback(
|
||
(insertionIndex: number | null) => {
|
||
if (!dragStateRef.current) {
|
||
return;
|
||
}
|
||
|
||
const { dragStartIndex } = dragStateRef.current;
|
||
const boundedIndex = Math.max(0, Math.min(insertionIndex ?? dragStartIndex, screens.length));
|
||
let targetIndex = boundedIndex;
|
||
|
||
if (targetIndex > dragStartIndex) {
|
||
targetIndex -= 1;
|
||
}
|
||
|
||
if (dragStartIndex !== targetIndex) {
|
||
dispatch({
|
||
type: "reorder-screens",
|
||
payload: {
|
||
fromIndex: dragStartIndex,
|
||
toIndex: targetIndex,
|
||
},
|
||
});
|
||
}
|
||
|
||
dragStateRef.current = null;
|
||
setDropIndex(null);
|
||
},
|
||
[dispatch, screens.length]
|
||
);
|
||
|
||
const handleDrop = useCallback(
|
||
(event: React.DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
finalizeDrop(dropIndex);
|
||
},
|
||
[dropIndex, finalizeDrop]
|
||
);
|
||
|
||
const handleDragEnd = useCallback(() => {
|
||
dragStateRef.current = null;
|
||
setDropIndex(null);
|
||
}, []);
|
||
|
||
const handleSelectScreen = useCallback(
|
||
(screenId: string) => {
|
||
dispatch({ type: "set-selected-screen", payload: { screenId } });
|
||
},
|
||
[dispatch]
|
||
);
|
||
|
||
const handleAddScreen = useCallback(() => {
|
||
setAddScreenDialogOpen(true);
|
||
}, []);
|
||
|
||
const handleAddScreenWithTemplate = useCallback((template: ScreenDefinition["template"]) => {
|
||
dispatch({ type: "add-screen", payload: { template } });
|
||
}, [dispatch]);
|
||
|
||
const screenTitleMap = useMemo(() => {
|
||
return screens.reduce<Record<string, string>>((accumulator, screen) => {
|
||
accumulator[screen.id] = screen.title.text || screen.id;
|
||
return accumulator;
|
||
}, {});
|
||
}, [screens]);
|
||
|
||
const listOptionsMap = useMemo(() => {
|
||
return screens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, screen) => {
|
||
if (screen.template === "list") {
|
||
accumulator[screen.id] = screen.list.options;
|
||
}
|
||
return accumulator;
|
||
}, {});
|
||
}, [screens]);
|
||
|
||
return (
|
||
<>
|
||
<div className="flex h-full flex-col">
|
||
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-border/60 bg-background/95 px-6 py-4 backdrop-blur-sm">
|
||
<div>
|
||
<h2 className="text-lg font-semibold">Экраны воронки</h2>
|
||
</div>
|
||
<Button variant="outline" className="w-8 h-8 p-0 flex items-center justify-center" onClick={handleAddScreen}>
|
||
<span className="text-lg leading-none">+</span>
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="relative flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-900">
|
||
<div className="relative mx-auto max-w-4xl">
|
||
<div className="absolute left-6 top-0 bottom-0 hidden w-px bg-border md:block" aria-hidden />
|
||
<div
|
||
className="space-y-6 pl-0 md:pl-12"
|
||
onDragOver={handleDragOverList}
|
||
onDrop={handleDrop}
|
||
>
|
||
{screens.map((screen, index) => {
|
||
const isSelected = screen.id === selectedScreenId;
|
||
const isDropBefore = dropIndex === index;
|
||
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">
|
||
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
|
||
<div className="flex items-start gap-4 md:gap-6">
|
||
<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(
|
||
"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"
|
||
)}
|
||
draggable
|
||
onDragStart={(event) => handleDragStart(event, screen.id, index)}
|
||
onDragOver={(event) => handleDragOverCard(event, index)}
|
||
onDragEnd={handleDragEnd}
|
||
onClick={() => handleSelectScreen(screen.id)}
|
||
>
|
||
<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 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>
|
||
</div>
|
||
|
||
{("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-5">
|
||
<TemplateSummary screen={screen} />
|
||
|
||
<VariantSummary
|
||
screen={screen}
|
||
screenTitleMap={screenTitleMap}
|
||
listOptionsMap={listOptionsMap}
|
||
/>
|
||
|
||
<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-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];
|
||
const optionSummaries =
|
||
screen.template === "list" && condition?.optionIds
|
||
? condition.optionIds.map((optionId) => ({
|
||
id: optionId,
|
||
label: getOptionLabel(screen.list.options, optionId),
|
||
}))
|
||
: [];
|
||
|
||
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 (
|
||
<TransitionRow
|
||
key={`${ruleIndex}-${rule.nextScreenId}`}
|
||
type="branch"
|
||
label="Вариативность"
|
||
targetLabel={ruleTargetLabel}
|
||
targetIndex={ruleTargetIndex !== -1 ? ruleTargetIndex : null}
|
||
optionSummaries={optionSummaries}
|
||
operator={operatorLabel}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{screens.length === 0 && (
|
||
<div className="rounded-2xl border border-dashed border-border/60 bg-background/80 p-8 text-center text-sm text-muted-foreground">
|
||
Добавьте первый экран, чтобы начать строить воронку.
|
||
</div>
|
||
)}
|
||
|
||
<div className="pt-4">
|
||
<Button variant="ghost" onClick={handleAddScreen} className="w-8 h-8 p-0 mx-auto flex items-center justify-center">
|
||
+
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<AddScreenDialog
|
||
open={addScreenDialogOpen}
|
||
onOpenChange={setAddScreenDialogOpen}
|
||
onAddScreen={handleAddScreenWithTemplate}
|
||
/>
|
||
</>
|
||
);
|
||
}
|