w-funnel/src/components/admin/builder/templates/TemplateConfig.tsx
2025-09-28 22:48:50 +02:00

516 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { InfoScreenConfig } from "./InfoScreenConfig";
import { DateScreenConfig } from "./DateScreenConfig";
import { CouponScreenConfig } from "./CouponScreenConfig";
import { FormScreenConfig } from "./FormScreenConfig";
import { ListScreenConfig } from "./ListScreenConfig";
import { EmailScreenConfig } from "./EmailScreenConfig";
import { LoadersScreenConfig } from "./LoadersScreenConfig";
import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type {
ScreenDefinition,
InfoScreenDefinition,
DateScreenDefinition,
CouponScreenDefinition,
FormScreenDefinition,
ListScreenDefinition,
EmailScreenDefinition,
LoadersScreenDefinition,
SoulmatePortraitScreenDefinition,
TypographyVariant,
BottomActionButtonDefinition,
HeaderDefinition,
} from "@/lib/funnel/types";
const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"];
interface TemplateConfigProps {
screen: BuilderScreen;
onUpdate: (updates: Partial<ScreenDefinition>) => void;
}
function CollapsibleSection({
title,
children,
defaultExpanded = false,
}: {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
}) {
const storageKey = `template-section-${title.toLowerCase().replace(/\s+/g, '-')}`;
const [isExpanded, setIsExpanded] = useState(() => {
if (typeof window === 'undefined') return defaultExpanded;
const stored = sessionStorage.getItem(storageKey);
return stored !== null ? JSON.parse(stored) : defaultExpanded;
});
const handleToggle = () => {
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
if (typeof window !== 'undefined') {
sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
}
};
return (
<div className="space-y-3">
<button
type="button"
onClick={handleToggle}
className="flex w-full items-center gap-2 text-left text-sm font-medium text-foreground hover:text-primary transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{title}
</button>
{isExpanded && <div className="ml-6 space-y-3">{children}</div>}
</div>
);
}
interface TypographyControlsProps {
label: string;
value: (TypographyVariant & { show?: boolean }) | undefined;
onChange: (value: (TypographyVariant & { show?: boolean }) | undefined) => void;
allowRemove?: boolean;
showToggle?: boolean; // Показывать ли чекбокс "Показывать"
}
function TypographyControls({ label, value, onChange, allowRemove = false, showToggle = false }: TypographyControlsProps) {
const storageKey = `typography-advanced-${label.toLowerCase().replace(/\s+/g, '-')}`;
const [showAdvanced, setShowAdvanced] = useState(false);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
const stored = sessionStorage.getItem(storageKey);
if (stored !== null) {
setShowAdvanced(JSON.parse(stored));
}
setIsHydrated(true);
}, [storageKey]);
const handleTextChange = (text: string) => {
if (text.trim() === "" && allowRemove) {
onChange(undefined);
return;
}
// Сохраняем существующие настройки или используем минимальные дефолты
onChange({
...value,
text,
});
};
const handleAdvancedChange = (field: keyof TypographyVariant, fieldValue: string) => {
onChange({
...value,
text: value?.text || "",
[field]: fieldValue || undefined,
});
};
const handleShowToggle = (show: boolean) => {
onChange({
...value,
text: value?.text || "",
show,
});
};
return (
<div className="space-y-3">
{showToggle && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={value?.show ?? true}
onChange={(event) => handleShowToggle(event.target.checked)}
/>
Показывать {label.toLowerCase()}
</label>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{label}</label>
<TextAreaInput
value={value?.text ?? ""}
onChange={(event) => handleTextChange(event.target.value)}
rows={2}
className="resize-y"
/>
</div>
{value?.text && (
<div className="space-y-2">
<button
type="button"
onClick={() => {
const newShowAdvanced = !showAdvanced;
setShowAdvanced(newShowAdvanced);
if (typeof window !== 'undefined') {
sessionStorage.setItem(storageKey, JSON.stringify(newShowAdvanced));
}
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition"
>
{showAdvanced ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Настройки оформления
</button>
{(isHydrated ? showAdvanced : false) && (
<div className="ml-4 grid grid-cols-2 gap-2 text-xs">
<label className="flex flex-col gap-1">
<span className="text-muted-foreground">Шрифт</span>
<select
className="rounded border border-border bg-background px-2 py-1"
value={value?.font ?? ""}
onChange={(e) => handleAdvancedChange("font", e.target.value)}
>
<option value="">По умолчанию</option>
<option value="manrope">Manrope</option>
<option value="inter">Inter</option>
<option value="geistSans">Geist Sans</option>
<option value="geistMono">Geist Mono</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-muted-foreground">Толщина</span>
<select
className="rounded border border-border bg-background px-2 py-1"
value={value?.weight ?? ""}
onChange={(e) => handleAdvancedChange("weight", e.target.value)}
>
<option value="">По умолчанию</option>
<option value="regular">Regular</option>
<option value="medium">Medium</option>
<option value="semiBold">Semi Bold</option>
<option value="bold">Bold</option>
<option value="extraBold">Extra Bold</option>
<option value="black">Black</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-muted-foreground">Выравнивание</span>
<select
className="rounded border border-border bg-background px-2 py-1"
value={value?.align ?? ""}
onChange={(e) => handleAdvancedChange("align", e.target.value)}
>
<option value="">По умолчанию</option>
<option value="left">Слева</option>
<option value="center">По центру</option>
<option value="right">Справа</option>
</select>
</label>
</div>
)}
</div>
)}
</div>
);
}
interface HeaderControlsProps {
header: HeaderDefinition | undefined;
onChange: (value: HeaderDefinition | undefined) => void;
}
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;
}
onChange({
...activeHeader,
[field]: checked,
});
};
return (
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
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">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={activeHeader.showBackButton !== false}
onChange={(event) => handleToggle("showBackButton", event.target.checked)}
/>
Показывать кнопку «Назад»
</label>
</div>
)}
</div>
);
}
interface ActionButtonControlsProps {
label: string;
value: BottomActionButtonDefinition | undefined;
onChange: (value: BottomActionButtonDefinition | undefined) => void;
}
function ActionButtonControls({ label, value, onChange }: ActionButtonControlsProps) {
// По умолчанию кнопка включена (show !== false)
const isEnabled = value?.show !== false;
const buttonText = value?.text || '';
const cornerRadius = value?.cornerRadius;
const showPrivacyTermsConsent = value?.showPrivacyTermsConsent ?? false;
const handleToggle = (enabled: boolean) => {
if (enabled) {
// Включаем кнопку - убираем show: false или создаем объект
const newValue = value ? { ...value, show: true } : { show: true };
// Если show: true по умолчанию, можем убрать это поле
if (newValue.show === true && !newValue.text && !newValue.cornerRadius) {
onChange(undefined); // Дефолтное состояние
} else {
const { show, ...rest } = newValue;
onChange(Object.keys(rest).length > 0 ? { show, ...rest } : undefined);
}
} else {
// Отключаем кнопку
onChange({ show: false });
}
};
const handleTextChange = (text: string) => {
if (!isEnabled) return;
const trimmedText = text.trim();
const newValue = {
...value,
text: trimmedText || undefined,
};
// Убираем undefined поля для чистоты
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false) {
onChange(undefined);
} else {
onChange(newValue);
}
};
const handleRadiusChange = (radius: string) => {
if (!isEnabled) return;
const newRadius = (radius as "3xl" | "full") || undefined;
const newValue = {
...value,
cornerRadius: newRadius,
};
// Убираем undefined поля для чистоты
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) {
onChange(undefined);
} else {
onChange(newValue);
}
};
const handlePrivacyTermsToggle = (checked: boolean) => {
if (!isEnabled) return;
const newValue = {
...value,
showPrivacyTermsConsent: checked || undefined,
};
// Убираем undefined поля для чистоты
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) {
onChange(undefined);
} else {
onChange(newValue);
}
};
return (
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isEnabled}
onChange={(event) => handleToggle(event.target.checked)}
/>
{label}
</label>
{isEnabled && (
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-muted-foreground">Текст кнопки</span>
<TextInput
value={buttonText}
onChange={(event) => handleTextChange(event.target.value)}
placeholder="Оставьте пустым для дефолтного текста"
/>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Скругление</span>
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={cornerRadius ?? ""}
onChange={(event) => handleRadiusChange(event.target.value)}
>
<option value="">По умолчанию</option>
{RADIUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showPrivacyTermsConsent}
onChange={(event) => handlePrivacyTermsToggle(event.target.checked)}
/>
Показывать PrivacyTermsConsent под кнопкой
</label>
</div>
)}
</div>
);
}
export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
const { template } = screen;
const handleTitleChange = (value: TypographyVariant | undefined) => {
if (!value) {
return;
}
onUpdate({ title: value });
};
const handleSubtitleChange = (value: TypographyVariant | undefined) => {
onUpdate({ subtitle: value });
};
const handleHeaderChange = (value: HeaderDefinition | undefined) => {
onUpdate({ header: value });
};
const handleButtonChange = (value: BottomActionButtonDefinition | undefined) => {
onUpdate({ bottomActionButton: value });
};
return (
<div className="space-y-4">
<CollapsibleSection title="Заголовок и подзаголовок">
<TypographyControls
label="Заголовок"
value={screen.title}
onChange={handleTitleChange}
allowRemove
showToggle
/>
<TypographyControls
label="Подзаголовок"
value={"subtitle" in screen ? screen.subtitle : undefined}
onChange={handleSubtitleChange}
allowRemove
showToggle
/>
</CollapsibleSection>
<CollapsibleSection title="Шапка экрана">
<HeaderControls header={screen.header} onChange={handleHeaderChange} />
</CollapsibleSection>
<CollapsibleSection title="Нижняя кнопка">
<ActionButtonControls label="Показывать основную кнопку" value={screen.bottomActionButton} onChange={handleButtonChange} />
</CollapsibleSection>
{template === "info" && (
<InfoScreenConfig
screen={screen as BuilderScreen & { template: "info" }}
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void}
/>
)}
{template === "date" && (
<DateScreenConfig
screen={screen as BuilderScreen & { template: "date" }}
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
/>
)}
{template === "coupon" && (
<CouponScreenConfig
screen={screen as BuilderScreen & { template: "coupon" }}
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
/>
)}
{template === "form" && (
<FormScreenConfig
screen={screen as BuilderScreen & { template: "form" }}
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
/>
)}
{template === "list" && (
<ListScreenConfig
screen={screen as BuilderScreen & { template: "list" }}
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
/>
)}
{template === "email" && (
<EmailScreenConfig
screen={screen as BuilderScreen & { template: "email" }}
onUpdate={onUpdate as (updates: Partial<EmailScreenDefinition>) => void}
/>
)}
{template === "loaders" && (
<LoadersScreenConfig
screen={screen as BuilderScreen & { template: "loaders" }}
onUpdate={onUpdate as (updates: Partial<LoadersScreenDefinition>) => void}
/>
)}
{template === "soulmate" && (
<SoulmatePortraitScreenConfig
screen={screen as BuilderScreen & { template: "soulmate" }}
onUpdate={onUpdate as (updates: Partial<SoulmatePortraitScreenDefinition>) => void}
/>
)}
</div>
);
}