add solmate portrait page
This commit is contained in:
gofnnp 2025-10-06 22:43:55 +04:00
parent 21bedbcc53
commit 2f1d71d26d
12 changed files with 449 additions and 136 deletions

View File

@ -98,7 +98,7 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
{/* Иконка */}
<CollapsibleSection title="Иконка" defaultExpanded={true}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="space-y-2 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Тип иконки
<select

View File

@ -62,13 +62,15 @@ export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigPro
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Настройки анимации</h4>
<TextInput
label="Длительность анимации (мс)"
type="number"
placeholder="5000"
value={screen.progressbars?.transitionDuration?.toString() || "5000"}
onChange={(e) => updateProgressbars({ transitionDuration: parseInt(e.target.value) || 5000 })}
/>
<div className="space-y-3">
<TextInput
label="Длительность анимации (мс)"
type="number"
placeholder="5000"
value={screen.progressbars?.transitionDuration?.toString() || "5000"}
onChange={(e) => updateProgressbars({ transitionDuration: parseInt(e.target.value) || 5000 })}
/>
</div>
</div>
<div>
@ -100,7 +102,7 @@ export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigPro
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<TextInput
label="Заголовок"
placeholder="Step 1"
@ -115,7 +117,7 @@ export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigPro
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<TextInput
label="Текст во время обработки"
placeholder="Processing..."
@ -130,7 +132,7 @@ export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigPro
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<TextInput
label="Текст при завершении"
placeholder="Completed!"

View File

@ -2,6 +2,9 @@
import React from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Button } from "@/components/ui/button";
import { ImageUpload } from "@/components/admin/builder/forms/ImageUpload";
import { Plus, Trash2 } from "lucide-react";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
@ -11,6 +14,10 @@ interface SoulmatePortraitScreenConfigProps {
}
export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortraitScreenConfigProps) {
type Delivered = NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>;
type Avatar = NonNullable<Delivered["avatars"]>[number];
type DeliveredText = NonNullable<Delivered["text"]>;
const updateDescription = (updates: Partial<SoulmatePortraitScreenDefinition["description"]>) => {
onUpdate({
description: screen.description ? {
@ -20,6 +27,64 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
});
};
const updateDelivered = (
updates: Partial<NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>>
) => {
const base = screen.soulmatePortraitsDelivered ?? {};
onUpdate({
soulmatePortraitsDelivered: {
...base,
...updates,
},
});
};
const updateDeliveredText = (
updates: Partial<DeliveredText>
) => {
const currentText = (screen.soulmatePortraitsDelivered?.text ?? { text: "" }) as DeliveredText;
const nextText = { ...currentText, ...(updates as object) } as DeliveredText;
updateDelivered({ text: nextText });
};
const addAvatar = () => {
const avatars = screen.soulmatePortraitsDelivered?.avatars ?? [];
updateDelivered({ avatars: [...avatars, { src: "", alt: "", fallbackText: "" }] });
};
const removeAvatar = (index: number) => {
const avatars = screen.soulmatePortraitsDelivered?.avatars ?? [];
updateDelivered({ avatars: avatars.filter((_, i) => i !== index) });
};
const updateAvatar = (
index: number,
updates: Partial<Avatar>
) => {
const avatars = screen.soulmatePortraitsDelivered?.avatars ?? [];
const next = avatars.map((a, i) => (i === index ? { ...a, ...updates } : a));
updateDelivered({ avatars: next });
};
const addTextListItem = () => {
const items = screen.textList?.items ?? [];
onUpdate({ textList: { items: [...items, { text: "" }] } });
};
const removeTextListItem = (index: number) => {
const items = screen.textList?.items ?? [];
onUpdate({ textList: { items: items.filter((_, i) => i !== index) } });
};
const updateTextListItem = (
index: number,
updates: Partial<NonNullable<SoulmatePortraitScreenDefinition["textList"]>["items"][number]>
) => {
const items = screen.textList?.items ?? [];
const next = items.map((it, i) => (i === index ? { ...it, ...updates } : it));
onUpdate({ textList: { items: next } });
};
return (
<div className="space-y-4">
<div>
@ -32,6 +97,119 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
/>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Блок доставленных портретов</h4>
<div className="space-y-3">
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение</span>
<ImageUpload
currentValue={screen.soulmatePortraitsDelivered?.image}
onImageSelect={(url) => updateDelivered({ image: url })}
onImageRemove={() => updateDelivered({ image: undefined })}
funnelId={screen.id}
/>
</div>
<TextInput
label="Текст под изображением"
placeholder="soulmate portraits delivered today"
value={screen.soulmatePortraitsDelivered?.text?.text || ""}
onChange={(e) => updateDeliveredText({ text: e.target.value })}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-slate-700">Аватары</h4>
<Button type="button" variant="outline" onClick={addAvatar} className="flex items-center gap-2 text-sm px-3 py-1">
<Plus className="w-4 h-4" /> Добавить
</Button>
</div>
<div className="space-y-4">
{(screen.soulmatePortraitsDelivered?.avatars ?? []).map((avatar, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-slate-600">Аватар {index + 1}</h5>
<Button type="button" variant="ghost" onClick={() => removeAvatar(index)} className="text-red-600 hover:text-red-700 text-sm px-2 py-1">
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение</span>
<ImageUpload
currentValue={avatar.src}
onImageSelect={(url) => updateAvatar(index, { src: url })}
onImageRemove={() => updateAvatar(index, { src: "" })}
funnelId={screen.id}
/>
</div>
<TextInput
label="Alt"
placeholder="Описание"
value={avatar.alt || ""}
onChange={(e) => updateAvatar(index, { alt: e.target.value })}
/>
<TextInput
label="Fallback текст"
placeholder="Напр. 900+"
value={avatar.fallbackText || ""}
onChange={(e) => updateAvatar(index, { fallbackText: e.target.value })}
/>
</div>
<div className="text-xs text-muted-foreground">Можно указать изображение или fallback текст (или оба).</div>
</div>
))}
</div>
{(screen.soulmatePortraitsDelivered?.avatars ?? []).length === 0 && (
<div className="text-center py-6 text-slate-500">
<p>Нет аватаров</p>
<Button type="button" variant="outline" onClick={addAvatar} className="mt-2 text-sm px-3 py-1">
Добавить первый
</Button>
</div>
)}
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-slate-700">Список текстов</h4>
<Button type="button" variant="outline" onClick={addTextListItem} className="flex items-center gap-2 text-sm px-3 py-1">
<Plus className="w-4 h-4" /> Добавить
</Button>
</div>
<div className="space-y-3">
{(screen.textList?.items ?? []).map((item, index) => (
<div key={index} className="space-y-1">
<TextInput
label={`Элемент ${index + 1}`}
placeholder="Текст элемента"
value={item.text || ""}
onChange={(e) => updateTextListItem(index, { text: e.target.value })}
/>
<div className="flex justify-end">
<Button type="button" variant="ghost" onClick={() => removeTextListItem(index)} className="text-red-600 hover:text-red-700 text-sm px-2 py-1">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
{(screen.textList?.items ?? []).length === 0 && (
<div className="text-center py-6 text-slate-500">
<p>Пока нет элементов</p>
<Button type="button" variant="outline" onClick={addTextListItem} className="mt-2 text-sm px-3 py-1">
Добавить первый
</Button>
</div>
)}
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Информация</h4>
<div className="text-xs text-muted-foreground">

View File

@ -1,110 +1,64 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate";
import { fn } from "storybook/test";
import { buildSoulmateDefaults } from "@/lib/admin/builder/state/defaults/soulmate";
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildSoulmateDefaults("soulmate-screen-story") as SoulmatePortraitScreenDefinition;
const defaultScreen: SoulmatePortraitScreenDefinition = {
id: "soulmate-story",
template: "soulmate",
header: { show: false, showBackButton: false },
title: { text: "Soulmate Portrait" },
subtitle: { text: "Готов увидеть, кто твоя настоящая Родственная душа?" },
description: {
text: "Готов увидеть, кто твоя настоящая Родственная душа?",
align: "center",
},
soulmatePortraitsDelivered: {
image: "/soulmate-portrait-delivered-male.jpg",
text: {
text: "soulmate portraits delivered today",
font: "inter",
weight: "medium",
size: "sm",
color: "primary",
},
avatars: [
{ src: "/avatars/male-1.jpg", alt: "Male 1" },
{ src: "/avatars/male-2.jpg", alt: "Male 2" },
{ src: "/avatars/male-3.jpg", alt: "Male 3" },
{ src: "", fallbackText: "900+" },
],
},
textList: {
items: [
{
text: "Всего 2 минуты — и Портрет откроет того, кто связан с тобой судьбой.",
},
{ text: "Поразительная точность 99%." },
{ text: "Тебя ждёт неожиданное открытие." },
{ text: "Осталось лишь осмелиться взглянуть." },
],
},
bottomActionButton: { text: "Continue", showPrivacyTermsConsent: true },
};
/** SoulmatePortraitTemplate - результирующие экраны с портретом партнера */
const meta: Meta<typeof SoulmatePortraitTemplate> = {
title: "Funnel Templates/SoulmatePortraitTemplate",
component: SoulmatePortraitTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
parameters: { layout: "fullscreen" },
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 10, total: 10 }, // Обычно финальный экран
defaultTexts: {
nextButton: "Next",
},
},
argTypes: {
screen: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onContinue: { action: "continue" },
onBack: { action: "back" },
screenProgress: undefined,
defaultTexts: { nextButton: "Next" },
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтный soulmate portrait экран */
export const Default: Story = {};
/** Экран без описания */
export const WithoutDescription: Story = {
args: {
screen: {
...defaultScreen,
description: undefined,
},
},
};
/** Экран с кастомным описанием */
export const CustomDescription: Story = {
args: {
screen: {
...defaultScreen,
description: {
text: "На основе ваших ответов мы создали уникальный **портрет вашей второй половинки**. Этот анализ поможет вам лучше понять, кто может стать идеальным партнером.",
font: "inter",
weight: "regular",
align: "center",
size: "md",
color: "default",
},
},
},
};
/** Экран без header */
export const WithoutHeader: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: false,
},
},
},
};
/** Экран без subtitle */
export const WithoutSubtitle: Story = {
args: {
screen: {
...defaultScreen,
subtitle: undefined, // Просто удаляем subtitle
},
},
};
/** Финальный экран (без прогресса) */
export const FinalScreen: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: true,
showBackButton: false, // На финальном экране обычно нет кнопки назад
showProgress: false, // И нет прогресса
},
},
screenProgress: undefined,
canGoBack: false,
},
};

View File

@ -1,8 +1,15 @@
"use client";
import type { SoulmatePortraitScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import type {
SoulmatePortraitScreenDefinition,
DefaultTexts,
} from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers";
import SoulmatePortraitsDelivered from "@/components/widgets/SoulmatePortraitsDelivered/SoulmatePortraitsDelivered";
import { cn } from "@/lib/utils";
interface SoulmatePortraitTemplateProps {
screen: SoulmatePortraitScreenDefinition;
@ -21,14 +28,26 @@ export function SoulmatePortraitTemplate({
screenProgress,
defaultTexts,
}: SoulmatePortraitTemplateProps) {
// Скрываем subtitle как ненужный для этого экрана
const screenForLayout: SoulmatePortraitScreenDefinition = {
...screen,
subtitle: undefined,
};
const layoutProps = createTemplateLayoutProps(
screen,
screenForLayout,
{ canGoBack, onBack },
screenProgress,
{
preset: "center",
titleDefaults: { font: "manrope", weight: "bold", align: "center", size: "xl", color: "primary" },
subtitleDefaults: { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
titleDefaults: {
font: "manrope",
weight: "bold",
align: "center",
size: "xl",
className: "leading-[125%] text-primary text-xl",
},
subtitleDefaults: undefined,
actionButton: {
defaultText: defaultTexts?.nextButton || "Continue",
disabled: false,
@ -39,7 +58,91 @@ export function SoulmatePortraitTemplate({
return (
<TemplateLayout {...layoutProps}>
<div className="-mt-[20px]">
<div className="max-w-[560px] mx-auto flex flex-col items-center gap-[30px]">
{screen.soulmatePortraitsDelivered && (
<SoulmatePortraitsDelivered
image={screen.soulmatePortraitsDelivered.image}
textProps={
screen.soulmatePortraitsDelivered.text
? buildTypographyProps(screen.soulmatePortraitsDelivered.text, {
as: "p",
defaults: { font: "inter", size: "sm", color: "primary" },
})
: undefined
}
avatarsProps={
screen.soulmatePortraitsDelivered.avatars
? {
avatars: screen.soulmatePortraitsDelivered.avatars.map(
(a) => ({
imageProps: a.src
? { src: a.src, alt: a.alt ?? "" }
: undefined,
fallbackProps: a.fallbackText
? {
children: (
<Typography
size="xs"
weight="bold"
className="text-[#FF6B9D]"
>
{a.fallbackText}
</Typography>
),
className: "bg-background",
}
: undefined,
className: a.fallbackText ? "w-fit px-1" : undefined,
})
),
}
: undefined
}
/>
)}
<div className="w-full flex flex-col items-center gap-2.5">
{screen.description &&
(() => {
const descProps = buildTypographyProps(screen.description, {
as: "p",
defaults: {
align: "center",
font: "inter",
size: "md",
weight: "bold",
className: "text-[25px] font-bold",
},
});
if (!descProps) return null;
const { children, ...rest } = descProps;
return (
<Typography {...rest} enableMarkup>
{children}
</Typography>
);
})()}
{screen.textList && (
<ul className={cn("list-disc pl-6 w-full")}>
{screen.textList.items.map((item, index) => {
const itemProps = buildTypographyProps(item, {
as: "li",
defaults: { font: "inter", weight: "medium", size: "md" },
});
if (!itemProps) return null;
const { children, ...rest } = itemProps;
return (
<Typography
key={index}
{...rest}
className={cn("list-item text-[17px] leading-[26px]")}
>
{children}
</Typography>
);
})}
</ul>
)}
</div>
</div>
</TemplateLayout>
);

View File

@ -16,39 +16,57 @@ interface TemplateLayoutProps {
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
// Настройки template
titleDefaults?: {
font?: "manrope" | "inter" | "geistSans" | "geistMono";
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
align?: "left" | "center" | "right";
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
color?: "default" | "primary" | "secondary" | "destructive" | "success" | "card" | "accent" | "muted";
color?:
| "default"
| "primary"
| "secondary"
| "destructive"
| "success"
| "card"
| "accent"
| "muted";
className?: string;
};
subtitleDefaults?: {
font?: "manrope" | "inter" | "geistSans" | "geistMono";
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
color?: "default" | "primary" | "secondary" | "destructive" | "success" | "card" | "accent" | "muted";
color?:
| "default"
| "primary"
| "secondary"
| "destructive"
| "success"
| "card"
| "accent"
| "muted";
align?: "left" | "center" | "right";
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
className?: string;
};
actionButtonOptions?: {
defaultText: string;
disabled: boolean;
onClick: () => void;
};
// Дополнительные props для BottomActionButton
childrenAboveButton?: React.ReactNode;
childrenUnderButton?: React.ReactNode;
// Дополнительные props для Title
childrenAboveTitle?: React.ReactNode;
// Переопределения стилей LayoutQuestion (контент и обертка контента)
contentProps?: React.ComponentProps<"div">;
childrenWrapperProps?: React.ComponentProps<"div">;
// Контент template
children: React.ReactNode;
}
@ -62,8 +80,20 @@ export function TemplateLayout({
canGoBack,
onBack,
screenProgress,
titleDefaults = { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" },
subtitleDefaults = { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
titleDefaults = {
font: "manrope",
weight: "bold",
align: "left",
size: "2xl",
color: "default",
},
subtitleDefaults = {
font: "manrope",
weight: "medium",
color: "default",
align: "left",
size: "lg",
},
actionButtonOptions,
childrenAboveButton,
childrenUnderButton,
@ -73,9 +103,7 @@ export function TemplateLayout({
children,
}: TemplateLayoutProps) {
// 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON
const {
elementRef: bottomActionButtonRef,
} = useDynamicSize<HTMLDivElement>({
const { elementRef: bottomActionButtonRef } = useDynamicSize<HTMLDivElement>({
defaultHeight: 132,
});
@ -98,20 +126,20 @@ export function TemplateLayout({
: undefined;
// 🎯 Автоматически создаем PrivacyTermsConsent с фиксированными настройками
const shouldShowPrivacyTermsConsent =
'bottomActionButton' in screen &&
const shouldShowPrivacyTermsConsent =
"bottomActionButton" in screen &&
screen.bottomActionButton?.showPrivacyTermsConsent === true;
const autoPrivacyTermsConsent = shouldShowPrivacyTermsConsent ? (
<PrivacyTermsConsent
<PrivacyTermsConsent
className="mt-5"
privacyPolicy={{
href: "/privacy",
children: "Privacy Policy"
children: "Privacy Policy",
}}
termsOfUse={{
href: "/terms",
children: "Terms of use"
children: "Terms of use",
}}
/>
) : null;
@ -137,8 +165,8 @@ export function TemplateLayout({
</LayoutQuestion>
{bottomActionButtonProps && (
<BottomActionButton
{...bottomActionButtonProps}
<BottomActionButton
{...bottomActionButtonProps}
ref={bottomActionButtonRef}
childrenAboveButton={childrenAboveButton}
childrenUnderButton={finalChildrenUnderButton}

View File

@ -2,7 +2,9 @@
import { cn } from "@/lib/utils";
import { Header } from "@/components/layout/Header/Header";
import Typography, { TypographyProps } from "@/components/ui/Typography/Typography";
import Typography, {
TypographyProps,
} from "@/components/ui/Typography/Typography";
export interface LayoutQuestionProps
extends Omit<React.ComponentProps<"section">, "title" | "content"> {
@ -55,7 +57,7 @@ function LayoutQuestion({
weight="bold"
{...title}
align={title.align ?? "left"}
className={cn(title.className, "w-full text-[25px] leading-[38px]")}
className={cn("w-full text-[25px] leading-[38px]", title.className)}
/>
)}
@ -66,8 +68,8 @@ function LayoutQuestion({
{...subtitle}
align={subtitle.align ?? "left"}
className={cn(
subtitle.className,
"w-full mt-2.5 text-[17px] leading-[26px]"
"w-full mt-2.5 text-[17px] leading-[26px]",
subtitle.className
)}
/>
)}
@ -83,4 +85,4 @@ function LayoutQuestion({
);
}
export { LayoutQuestion };
export { LayoutQuestion };

View File

@ -60,6 +60,7 @@ export const useAuth = ({ funnelId }: IUseAuthProps) => {
sign: true,
signDate: new Date().toISOString(),
// feature: feature.includes("black") ? "ios" : feature,
feature: "stripe"
});
},
[funnelId]

View File

@ -54,6 +54,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => {
try {
const utm = parseQueryParams();
const sessionParams = {
feature: "stripe",
locale,
timezone,
// source: funnelId,
@ -102,7 +103,10 @@ export const useSession = ({ funnelId }: IUseSessionProps) => {
}
const result = await updateSessionApi({
sessionId: _sessionId,
data,
data: {
feature: "stripe",
...data,
},
});
return result;
} catch (error) {

View File

@ -1,11 +1,11 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultDescription
buildDefaultDescription,
} from "./blocks";
export function buildSoulmateDefaults(id: string): BuilderScreen {
@ -19,12 +19,39 @@ export function buildSoulmateDefaults(id: string): BuilderScreen {
title: buildDefaultTitle(),
subtitle: buildDefaultSubtitle(),
bottomActionButton: buildDefaultBottomActionButton({
text: "Получить полный анализ",
text: "Continue",
showPrivacyTermsConsent: true,
}),
description: buildDefaultDescription({
text: "Ваш персональный портрет почти готов.",
text: "Готов увидеть, кто твоя настоящая Родственная душа?",
align: "center",
}),
soulmatePortraitsDelivered: {
image: "/soulmate-portrait-delivered-male.jpg",
text: {
text: "soulmate portraits delivered today",
font: "inter",
weight: "medium",
size: "sm",
color: "primary",
},
avatars: [
{ src: "/avatars/male-1.jpg", alt: "Male 1" },
{ src: "/avatars/male-2.jpg", alt: "Male 2" },
{ src: "/avatars/male-3.jpg", alt: "Male 3" },
{ src: "", fallbackText: "900+" },
],
},
textList: {
items: [
{
text: "Всего 2 минуты — и Портрет откроет того, кто связан с тобой судьбой.",
},
{ text: "Поразительная точность 99%." },
{ text: "Тебя ждёт неожиданное открытие." },
{ text: "Осталось лишь осмелиться взглянуть." },
],
},
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -1,6 +1,7 @@
import type { TypographyProps } from "@/components/ui/Typography/Typography";
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
import { hasTextMarkup } from "@/lib/text-markup";
import { cn } from "@/lib/utils";
import type {
HeaderDefinition,
@ -34,6 +35,7 @@ interface TypographyDefaults {
size?: TypographyVariant["size"];
align?: TypographyVariant["align"];
color?: TypographyVariant["color"];
className?: string;
}
interface BuildTypographyOptions<T extends TypographyAs> {
@ -69,7 +71,7 @@ export function buildTypographyProps<T extends TypographyAs>(
size: variant.size ?? defaults?.size,
align: variant.align ?? defaults?.align,
color: variant.color ?? defaults?.color,
className: variant.className,
className: cn(defaults?.className, variant.className),
enableMarkup: hasTextMarkup(variant.text || ""), // 🎨 АВТОМАТИЧЕСКИ включаем разметку если обнаружена
} as TypographyProps<T>;
}

View File

@ -326,7 +326,19 @@ export interface SoulmatePortraitScreenDefinition {
header?: HeaderDefinition;
title: TitleDefinition;
subtitle?: SubtitleDefinition;
description?: TypographyVariant; // 🎯 Настраиваемый текст описания
description?: TypographyVariant; // Настраиваемый текст описания
soulmatePortraitsDelivered?: {
image?: string;
text?: TypographyVariant;
avatars?: Array<{
src: string;
alt?: string;
fallbackText?: string;
}>;
};
textList?: {
items: TypographyVariant[];
};
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<SoulmatePortraitScreenDefinition>[];