diff --git a/src/components/admin/builder/Canvas/constants.ts b/src/components/admin/builder/Canvas/constants.ts index 205d8d6..3b68fae 100644 --- a/src/components/admin/builder/Canvas/constants.ts +++ b/src/components/admin/builder/Canvas/constants.ts @@ -13,6 +13,7 @@ export const TEMPLATE_TITLES: Record = { loaders: "Загрузка", soulmate: "Портрет партнера", trialPayment: "Trial Payment", + specialOffer: "Special Offer", }; export const OPERATOR_LABELS: Record< diff --git a/src/components/admin/builder/dialogs/AddScreenDialog.tsx b/src/components/admin/builder/dialogs/AddScreenDialog.tsx index 20b5350..d63f5bd 100644 --- a/src/components/admin/builder/dialogs/AddScreenDialog.tsx +++ b/src/components/admin/builder/dialogs/AddScreenDialog.tsx @@ -1,16 +1,17 @@ "use client"; import { useState } from "react"; -import { - List, - FormInput, - Info, - Calendar, +import { + List, + FormInput, + Info, + Calendar, Ticket, Loader, Heart, Mail, - CreditCard + CreditCard, + Gift, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -38,11 +39,12 @@ const TEMPLATE_OPTIONS = [ color: "bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400", }, { - template: "form" as const, + template: "form" as const, title: "Форма", description: "Ввод текстовых данных в поля", icon: FormInput, - color: "bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400", + color: + "bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400", }, { template: "email" as const, @@ -53,7 +55,7 @@ const TEMPLATE_OPTIONS = [ }, { template: "info" as const, - title: "Информация", + title: "Информация", description: "Отображение информации с кнопкой продолжить", icon: Info, color: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400", @@ -61,9 +63,11 @@ const TEMPLATE_OPTIONS = [ { template: "date" as const, title: "Дата рождения", - description: "Выбор даты (месяц, день, год) + автоматический расчет возраста", + description: + "Выбор даты (месяц, день, год) + автоматический расчет возраста", icon: Calendar, - color: "bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400", + color: + "bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400", }, { template: "loaders" as const, @@ -82,21 +86,36 @@ const TEMPLATE_OPTIONS = [ { template: "coupon" as const, title: "Купон", - description: "Отображение промокода и предложения", + description: "Отображение промокода и предложения", icon: Ticket, - color: "bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400", + color: + "bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400", }, { template: "trialPayment" as const, title: "Trial Payment", description: "Страница оплаты с пробным периодом", icon: CreditCard, - color: "bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400", + color: + "bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400", + }, + { + template: "specialOffer" as const, + title: "Special Offer", + description: "Страница с предложением", + icon: Gift, + color: "bg-pink-50 text-pink-600 dark:bg-pink-900/20 dark:text-pink-400", }, ] as const; -export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDialogProps) { - const [selectedTemplate, setSelectedTemplate] = useState(null); +export function AddScreenDialog({ + open, + onOpenChange, + onAddScreen, +}: AddScreenDialogProps) { + const [selectedTemplate, setSelectedTemplate] = useState< + ScreenDefinition["template"] | null + >(null); const handleAdd = () => { if (selectedTemplate) { @@ -113,7 +132,7 @@ export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDi return ( - + Выберите тип экрана @@ -130,18 +149,22 @@ export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDi ); diff --git a/src/components/admin/builder/templates/SpecialOfferScreenConfig.tsx b/src/components/admin/builder/templates/SpecialOfferScreenConfig.tsx new file mode 100644 index 0000000..bd459f1 --- /dev/null +++ b/src/components/admin/builder/templates/SpecialOfferScreenConfig.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput"; +import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; +import type { SpecialOfferScreenDefinition, TypographyVariant } from "@/lib/funnel/types"; + +interface SpecialOfferScreenConfigProps { + screen: BuilderScreen & { template: "specialOffer" }; + onUpdate: (updates: Partial) => void; +} + +function CollapsibleSection({ + title, + children, + storagePrefix = "special-offer", + defaultExpanded = false, +}: { + title: string; + children: React.ReactNode; + storagePrefix?: string; + defaultExpanded?: boolean; +}) { + const storageKey = `${storagePrefix}-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 next = !isExpanded; + setIsExpanded(next); + if (typeof window !== "undefined") { + sessionStorage.setItem(storageKey, JSON.stringify(next)); + } + }; + + return ( +
+ + {isExpanded &&
{children}
} +
+ ); +} + +export function SpecialOfferScreenConfig({ screen, onUpdate }: SpecialOfferScreenConfigProps) { + const specialScreen = screen as SpecialOfferScreenDefinition; + + const updateText = (field: keyof NonNullable, value: TypographyVariant | undefined) => { + const base = specialScreen.text ?? {}; + const next = { ...base, [field]: value } as NonNullable; + onUpdate({ text: next }); + }; + + const handleTextChange = ( + field: keyof NonNullable, + text: string + ) => { + const current = (specialScreen.text?.[field] ?? {}) as TypographyVariant; + const trimmed = text; // allow empty, higher-level validation can handle required rules + const nextValue: TypographyVariant | undefined = + trimmed.length > 0 ? { ...current, text: trimmed } : { ...current, text: "" }; + updateText(field, nextValue); + }; + + // description is a composite object: { trialPrice, text, oldTrialPrice } + const updateDescriptionPart = ( + part: "trialPrice" | "text" | "oldTrialPrice", + value: TypographyVariant | undefined + ) => { + const baseText = specialScreen.text ?? {}; + const currentDescription = baseText.description ?? {}; + const nextDescription = { + ...currentDescription, + [part]: value, + } as NonNullable["description"]; + + onUpdate({ text: { ...baseText, description: nextDescription } }); + }; + + const handleDescriptionTextChange = ( + part: "trialPrice" | "text" | "oldTrialPrice", + text: string + ) => { + const current = (specialScreen.text?.description?.[part] ?? {}) as TypographyVariant; + const nextValue: TypographyVariant | undefined = { ...current, text }; + updateDescriptionPart(part, nextValue); + }; + + const addAdvantageItem = () => { + const items = specialScreen.advantages?.items ?? []; + const next = [...items, { icon: { text: "" }, text: { text: "" } }]; + onUpdate({ advantages: { items: next } }); + }; + + const removeAdvantageItem = (index: number) => { + const items = specialScreen.advantages?.items ?? []; + const next = items.filter((_, i) => i !== index); + onUpdate({ advantages: { items: next } }); + }; + + const moveAdvantageItem = (index: number, direction: -1 | 1) => { + const items = [...(specialScreen.advantages?.items ?? [])]; + const target = index + direction; + if (target < 0 || target >= items.length) return; + const [cur] = items.splice(index, 1); + items.splice(target, 0, cur); + onUpdate({ advantages: { items } }); + }; + + const updateAdvantageItem = ( + index: number, + field: "icon" | "text", + text: string + ) => { + const items = specialScreen.advantages?.items ?? []; + const current = items[index] ?? { icon: { text: "" }, text: { text: "" } }; + const nextItem = { + ...current, + [field]: { ...(current[field] ?? {}), text }, + } as NonNullable["items"][number]; + const next = items.map((it, i) => (i === index ? nextItem : it)); + onUpdate({ advantages: { items: next } }); + }; + + return ( +
+ +
+ handleTextChange("title", e.target.value)} + /> + handleTextChange("subtitle", e.target.value)} + /> + handleDescriptionTextChange("trialPrice", e.target.value)} + /> + handleDescriptionTextChange("text", e.target.value)} + /> + handleDescriptionTextChange("oldTrialPrice", e.target.value)} + /> +
+
+ + +
+
+

Список преимуществ

+ +
+ +
+ {(specialScreen.advantages?.items ?? []).map((item, index) => ( +
+
+
Элемент {index + 1}
+
+ + + +
+
+ +
+ updateAdvantageItem(index, "icon", e.target.value)} + /> + updateAdvantageItem(index, "text", e.target.value)} + /> +
+
+ ))} +
+ + {(specialScreen.advantages?.items ?? []).length === 0 && ( +
+ Пока нет элементов. Добавьте первый, нажав "+". +
+ )} +
+
+ +
+

+ Тексты и преимущества отображаются в шаблоне `SpecialOfferTemplate`. Основная нижняя кнопка + настраивается выше в секции "Нижняя кнопка". +

+
+
+ ); +} + + diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index 8c43356..50b32b6 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -30,7 +30,9 @@ import type { BottomActionButtonDefinition, HeaderDefinition, TrialPaymentScreenDefinition, + SpecialOfferScreenDefinition, } from "@/lib/funnel/types"; +import { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig"; const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"]; @@ -48,11 +50,13 @@ function CollapsibleSection({ children: React.ReactNode; defaultExpanded?: boolean; }) { - const storageKey = `template-section-${title.toLowerCase().replace(/\s+/g, '-')}`; - + const storageKey = `template-section-${title + .toLowerCase() + .replace(/\s+/g, "-")}`; + const [isExpanded, setIsExpanded] = useState(() => { - if (typeof window === 'undefined') return defaultExpanded; - + if (typeof window === "undefined") return defaultExpanded; + const stored = sessionStorage.getItem(storageKey); return stored !== null ? JSON.parse(stored) : defaultExpanded; }); @@ -60,8 +64,8 @@ function CollapsibleSection({ const handleToggle = () => { const newExpanded = !isExpanded; setIsExpanded(newExpanded); - - if (typeof window !== 'undefined') { + + if (typeof window !== "undefined") { sessionStorage.setItem(storageKey, JSON.stringify(newExpanded)); } }; @@ -88,14 +92,24 @@ function CollapsibleSection({ interface TypographyControlsProps { label: string; value: (TypographyVariant & { show?: boolean }) | undefined; - onChange: (value: (TypographyVariant & { show?: boolean }) | undefined) => void; + 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, '-')}`; - +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); @@ -124,7 +138,10 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT } }; - const handleAdvancedChange = (field: keyof TypographyVariant, fieldValue: string) => { + const handleAdvancedChange = ( + field: keyof TypographyVariant, + fieldValue: string + ) => { onChange({ ...value, text: value?.text || "", @@ -162,25 +179,29 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT )} - +
- handleTextChange(event.target.value)} onBlur={handleTextBlur} rows={2} className="resize-y" - aria-invalid={!allowRemove && (!value?.text || value.text.trim() === "")} + aria-invalid={ + !allowRemove && (!value?.text || value.text.trim() === "") + } /> {!allowRemove && (!value?.text || value.text.trim() === "") && ( -

Это поле обязательно для заполнения

+

+ Это поле обязательно для заполнения +

)}
@@ -191,16 +212,23 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT onClick={() => { const newShowAdvanced = !showAdvanced; setShowAdvanced(newShowAdvanced); - if (typeof window !== 'undefined') { - sessionStorage.setItem(storageKey, JSON.stringify(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 ? : } + {showAdvanced ? ( + + ) : ( + + )} Настройки оформления - + {(isHydrated ? showAdvanced : false) && (
- + - - + @@ -314,10 +347,14 @@ interface ActionButtonControlsProps { onChange: (value: BottomActionButtonDefinition | undefined) => void; } -function ActionButtonControls({ label, value, onChange }: ActionButtonControlsProps) { +function ActionButtonControls({ + label, + value, + onChange, +}: ActionButtonControlsProps) { // По умолчанию кнопка включена (show !== false) const isEnabled = value?.show !== false; - const buttonText = value?.text || ''; + const buttonText = value?.text || ""; const cornerRadius = value?.cornerRadius; const showPrivacyTermsConsent = value?.showPrivacyTermsConsent ?? false; @@ -340,13 +377,13 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr 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); @@ -357,15 +394,20 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr 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) { + if ( + !newValue.text && + !newValue.cornerRadius && + newValue.show !== false && + !newValue.showPrivacyTermsConsent + ) { onChange(undefined); } else { onChange(newValue); @@ -374,14 +416,19 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr const handlePrivacyTermsToggle = (checked: boolean) => { if (!isEnabled) return; - + const newValue = { ...value, showPrivacyTermsConsent: checked || undefined, }; - + // Убираем undefined поля для чистоты - if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) { + if ( + !newValue.text && + !newValue.cornerRadius && + newValue.show !== false && + !newValue.showPrivacyTermsConsent + ) { onChange(undefined); } else { onChange(newValue); @@ -402,16 +449,20 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr {isEnabled && (