w-funnel/src/components/admin/builder/templates/TemplateConfig.tsx
2025-12-01 04:09:26 +03:00

702 lines
22 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 { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
import { TrialChoiceScreenConfig } from "./TrialChoiceScreenConfig";
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,
TrialPaymentScreenDefinition,
SpecialOfferScreenDefinition,
TrialChoiceScreenDefinition,
} from "@/lib/funnel/types";
import { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";
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) => {
// Всегда обновляем текст, даже если пустой
// Это позволяет controlled input работать корректно
onChange({
...value,
text,
show: value?.show ?? true, // Если show не задан, по умолчанию true
});
};
const handleTextBlur = () => {
// При потере фокуса удаляем объект если текст пустой
if (allowRemove && (!value?.text || value.text.trim() === "")) {
onChange(undefined);
}
};
const handleAdvancedChange = (
field: keyof TypographyVariant,
fieldValue: string
) => {
onChange({
...value,
text: value?.text || "",
[field]: fieldValue || undefined,
});
};
const handleShowToggle = (show: boolean) => {
if (!show) {
// Скрываем элемент
if (allowRemove) {
// Для опциональных полей - удаляем объект полностью
onChange(undefined);
} else {
// Для обязательных полей - сохраняем с show: false
onChange({
...value,
text: value?.text || "",
show: false,
});
}
} else {
// Показываем элемент
onChange({
...value,
text: value?.text || "",
show: true,
});
}
};
return (
<div className="space-y-3">
{showToggle && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={value ? value.show ?? true : false}
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)}
onBlur={handleTextBlur}
rows={2}
className="resize-y"
aria-invalid={
!allowRemove && (!value?.text || value.text.trim() === "")
}
/>
{!allowRemove && (!value?.text || value.text.trim() === "") && (
<p className="text-xs text-destructive">
Это поле обязательно для заполнения
</p>
)}
</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,
showProgress: true
};
const handleToggle = (
field: "show" | "showBackButton" | "showProgress",
checked: boolean
) => {
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">
<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"
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 showGradientBlur = value?.showGradientBlur ?? true;
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);
}
};
const handleGradientBlurToggle = (checked: boolean) => {
// Работает даже когда кнопка отключена (для LIST экранов)
const newValue = {
...value,
// Если checked = true (дефолт), не сохраняем поле
// Если checked = false, сохраняем явно
showGradientBlur: checked ? undefined : false,
};
// Убираем undefined поля для чистоты
if (
!newValue.text &&
!newValue.cornerRadius &&
newValue.show !== false &&
!newValue.showPrivacyTermsConsent &&
newValue.showGradientBlur !== false
) {
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>
)}
{/* Gradient Blur - доступен даже когда кнопка отключена */}
<div className="rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showGradientBlur}
onChange={(event) =>
handleGradientBlurToggle(event.target.checked)
}
/>
<span>
Показывать gradient blur{" "}
{!isEnabled && (
<span className="text-muted-foreground">(работает даже без кнопки)</span>
)}
</span>
</label>
</div>
</div>
);
}
export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
const { template } = screen;
const handleTitleChange = (value: TypographyVariant | undefined) => {
// Заголовок обязательный, но разрешаем временно пустой текст
// (для корректной работы controlled input)
// Валидация при сохранении покажет ошибку если текст пустой
if (!value) {
// Создаем минимальный объект вместо undefined
onUpdate({ title: { text: "" } });
return;
}
onUpdate({ title: value });
};
const handleSubtitleChange = (newValue: TypographyVariant | undefined) => {
onUpdate({ subtitle: newValue });
};
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}
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
}
/>
)}
{template === "trialPayment" && (
<TrialPaymentScreenConfig
screen={screen as BuilderScreen & { template: "trialPayment" }}
onUpdate={
onUpdate as (updates: Partial<TrialPaymentScreenDefinition>) => void
}
/>
)}
{template === "specialOffer" && (
<SpecialOfferScreenConfig
screen={screen as BuilderScreen & { template: "specialOffer" }}
onUpdate={
onUpdate as (updates: Partial<SpecialOfferScreenDefinition>) => void
}
/>
)}
{template === "trialChoice" && (
<TrialChoiceScreenConfig
screen={screen as BuilderScreen & { template: "trialChoice" }}
onUpdate={
onUpdate as (updates: Partial<TrialChoiceScreenDefinition>) => void
}
/>
)}
</div>
);
}