diff --git a/package-lock.json b/package-lock.json index ac2eb8c..d092a19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@lottiefiles/dotlottie-react": "^0.17.4", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", @@ -21,6 +22,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.2.2", + "idb": "^8.0.3", "lucide-react": "^0.544.0", "mongoose": "^8.18.2", "next": "15.5.3", @@ -1711,6 +1713,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lottiefiles/dotlottie-react": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.17.4.tgz", + "integrity": "sha512-PsWq0l+Q/sGwnjWMiRJC1GUmsXFYB8zc5TacWblfaU9EQzqJzBeblk5rqtac/EDQi9QiXqpojPgWsofJX97swg==", + "license": "MIT", + "dependencies": { + "@lottiefiles/dotlottie-web": "0.54.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19" + } + }, + "node_modules/@lottiefiles/dotlottie-web": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.54.0.tgz", + "integrity": "sha512-Jc/n4i9siOXo9/1CVhKkrWC8pxxsKqKwxYfrL4DFQP/cLUAeAO0TqFPQFx9Klh1m7T+/1RPFriycOcF8gW3ZtQ==", + "license": "MIT" + }, "node_modules/@mdx-js/react": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", @@ -7510,6 +7530,12 @@ "dev": true, "license": "MIT" }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 36d7316..d1f9023 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@lottiefiles/dotlottie-react": "^0.17.4", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", @@ -33,6 +34,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.2.2", + "idb": "^8.0.3", "lucide-react": "^0.544.0", "mongoose": "^8.18.2", "next": "15.5.3", diff --git a/src/app/(payment)/layout.tsx b/src/app/(payment)/layout.tsx new file mode 100644 index 0000000..765d7fa --- /dev/null +++ b/src/app/(payment)/layout.tsx @@ -0,0 +1,11 @@ +export default function PaymentLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/(payment)/payment/failed/page.tsx b/src/app/(payment)/payment/failed/page.tsx new file mode 100644 index 0000000..9f0f25b --- /dev/null +++ b/src/app/(payment)/payment/failed/page.tsx @@ -0,0 +1,24 @@ +// import { getTranslations } from "next-intl/server"; + +import AnimatedInfoScreen from "@/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen"; +import LottieAnimation from "@/components/widgets/LottieAnimation/LottieAnimation"; +import { ROUTES } from "@/shared/constants/client-routes"; +import { ELottieKeys } from "@/shared/constants/lottie"; + +export default async function PaymentFailed() { + // const t = await getTranslations("Payment.Error"); + + return ( + + } + // title={t("title")} + title="Payment failed" + animationTime={0} + animationTexts={[]} + buttonText="Try again" + nextRoute={ROUTES.home()} + /> + ); +} diff --git a/src/app/(payment)/payment/route.ts b/src/app/(payment)/payment/route.ts new file mode 100644 index 0000000..a1dde6d --- /dev/null +++ b/src/app/(payment)/payment/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { createPaymentCheckout } from "@/entities/payment/api"; +import { ROUTES } from "@/shared/constants/client-routes"; + +export async function GET(req: NextRequest) { + const productId = req.nextUrl.searchParams.get("productId"); + const placementId = req.nextUrl.searchParams.get("placementId"); + const paywallId = req.nextUrl.searchParams.get("paywallId"); + const fbPixels = req.nextUrl.searchParams.get("fb_pixels"); + const productPrice = req.nextUrl.searchParams.get("price"); + const currency = req.nextUrl.searchParams.get("currency"); + + const data = await createPaymentCheckout({ + productId: productId || "", + placementId: placementId || "", + paywallId: paywallId || "", + }); + + let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin); + if (!redirectUrl) { + redirectUrl = new URL(`${ROUTES.paymentFailed()}`, origin); + } + if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels); + if (productPrice) redirectUrl.searchParams.set("price", productPrice); + if (currency) redirectUrl.searchParams.set("currency", currency); + + return NextResponse.redirect(redirectUrl, { status: 307 }); +} diff --git a/src/app/(payment)/payment/success/Button.tsx b/src/app/(payment)/payment/success/Button.tsx new file mode 100644 index 0000000..0f775c5 --- /dev/null +++ b/src/app/(payment)/payment/success/Button.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { ActionButton } from "@/components/ui/ActionButton/ActionButton"; +// import { useRouter } from "next/navigation"; +// import { useTranslations } from "next-intl"; + +import Typography from "@/components/ui/Typography/Typography"; +// import { ROUTES } from "@/shared/constants/client-routes"; + +// import styles from "./Button.module.scss"; + +export default function PaymentSuccessButton() { + // const t = useTranslations("Payment.Success"); + // const router = useRouter(); + + const handleNext = () => { + // router.push(ROUTES.additionalPurchases()); + }; + + return ( + + + {/* {t("button")} */} + Done + + + ); +} diff --git a/src/app/(payment)/payment/success/page.tsx b/src/app/(payment)/payment/success/page.tsx new file mode 100644 index 0000000..089c7a8 --- /dev/null +++ b/src/app/(payment)/payment/success/page.tsx @@ -0,0 +1,24 @@ +// import { getTranslations } from "next-intl/server"; + +import AnimatedInfoScreen from "@/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen"; +import LottieAnimation from "@/components/widgets/LottieAnimation/LottieAnimation"; + +import PaymentSuccessButton from "./Button"; +import { ELottieKeys } from "@/shared/constants/lottie"; + +export default async function PaymentSuccess() { + // const t = await getTranslations("Payment.Success"); + + return ( + <> + + } + // title={t("title")} + title="Payment successful" + /> + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 11bbf29..d8a2730 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -230,3 +230,13 @@ transform: scale(1.05); } } + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/src/components/admin/builder/Canvas/BuilderCanvas.tsx b/src/components/admin/builder/Canvas/BuilderCanvas.tsx index 8155a4e..4a43670 100644 --- a/src/components/admin/builder/Canvas/BuilderCanvas.tsx +++ b/src/components/admin/builder/Canvas/BuilderCanvas.tsx @@ -117,7 +117,7 @@ export function BuilderCanvas() { const screenTitleMap = useMemo(() => { return screens.reduce>((accumulator, screen) => { - accumulator[screen.id] = screen.title.text || screen.id; + accumulator[screen.id] = screen.title?.text || screen.id; return accumulator; }, {}); }, [screens]); @@ -189,7 +189,7 @@ export function BuilderCanvas() { #{screen.id} - {screen.title.text || "Без названия"} + {screen.title?.text || "Без названия"} diff --git a/src/components/admin/builder/Canvas/constants.ts b/src/components/admin/builder/Canvas/constants.ts index c72d4e0..205d8d6 100644 --- a/src/components/admin/builder/Canvas/constants.ts +++ b/src/components/admin/builder/Canvas/constants.ts @@ -1,17 +1,24 @@ -import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types"; +import type { + ScreenDefinition, + NavigationConditionDefinition, +} from "@/lib/funnel/types"; export const TEMPLATE_TITLES: Record = { list: "Список", - form: "Форма", + form: "Форма", info: "Инфо", date: "Дата", coupon: "Купон", email: "Email", loaders: "Загрузка", soulmate: "Портрет партнера", + trialPayment: "Trial Payment", }; -export const OPERATOR_LABELS: Record, string> = { +export const OPERATOR_LABELS: Record< + Exclude, + string +> = { includesAny: "любой из", includesAll: "все из", includesExactly: "точное совпадение", diff --git a/src/components/admin/builder/Sidebar/BuilderSidebar.tsx b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx index 6cf760d..f3d5e2a 100644 --- a/src/components/admin/builder/Sidebar/BuilderSidebar.tsx +++ b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx @@ -6,7 +6,11 @@ import { Button } from "@/components/ui/button"; import { Trash2 } from "lucide-react"; import { TemplateConfig } from "@/components/admin/builder/templates"; import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig"; -import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context"; +import { + useBuilderDispatch, + useBuilderSelectedScreen, + useBuilderState, +} from "@/lib/admin/builder/context"; import type { BuilderScreen } from "@/lib/admin/builder/types"; import type { NavigationRuleDefinition, @@ -24,7 +28,9 @@ export function BuilderSidebar() { const dispatch = useBuilderDispatch(); const selectedScreen = useBuilderSelectedScreen(); - const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel"); + const [activeTab, setActiveTab] = useState<"funnel" | "screen">( + selectedScreen ? "screen" : "funnel" + ); const selectedScreenId = selectedScreen?.id ?? null; useEffect(() => { @@ -37,27 +43,31 @@ export function BuilderSidebar() { }, [selectedScreenId]); // ✅ Оптимизированная validation - только критичные поля - const screenIds = useMemo(() => state.screens.map(s => s.id).join(','), [state.screens]); + const screenIds = useMemo( + () => state.screens.map((s) => s.id).join(","), + [state.screens] + ); const validation = useMemo( () => validateBuilderState(state), // eslint-disable-next-line react-hooks/exhaustive-deps -- Оптимизация: пересчитываем только при изменении критичных полей - [ - state.meta.id, - state.meta.firstScreenId, - screenIds, - state.screens.length, - ] + [state.meta.id, state.meta.firstScreenId, screenIds, state.screens.length] ); const screenValidationIssues = useMemo(() => { if (!selectedScreenId) { return [] as ValidationIssues; } - return validation.issues.filter((issue) => issue.screenId === selectedScreenId); + return validation.issues.filter( + (issue) => issue.screenId === selectedScreenId + ); }, [selectedScreenId, validation]); const screenOptions = useMemo( - () => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), + () => + state.screens.map((screen) => ({ + id: screen.id, + title: screen.title?.text, + })), [state.screens] ); @@ -86,29 +96,29 @@ export function BuilderSidebar() { if (newId === currentId) { return; } - + // Разрешаем пустые ID для полного переименования if (newId.trim() === "") { // Просто обновляем на пустое значение, пользователь сможет ввести новое - dispatch({ - type: "update-screen", - payload: { - screenId: currentId, - screen: { id: newId } - } + dispatch({ + type: "update-screen", + payload: { + screenId: currentId, + screen: { id: newId }, + }, }); return; } - + // Обновляем ID экрана - dispatch({ - type: "update-screen", - payload: { - screenId: currentId, - screen: { id: newId } - } + dispatch({ + type: "update-screen", + payload: { + screenId: currentId, + screen: { id: newId }, + }, }); - + // Если это был первый экран в мета данных, обновляем и там if (state.meta.firstScreenId === currentId) { dispatch({ type: "set-meta", payload: { firstScreenId: newId } }); @@ -128,15 +138,20 @@ export function BuilderSidebar() { screenId: screen.id, navigation: { defaultNextScreenId: - navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId, + navigationUpdates.defaultNextScreenId ?? + screen.navigation?.defaultNextScreenId, rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [], - isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen, + isEndScreen: + navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen, }, }, }); }; - const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => { + const handleDefaultNextChange = ( + screenId: string, + nextScreenId: string | "" + ) => { const screen = getScreenById(screenId); if (!screen) { return; @@ -186,7 +201,11 @@ export function BuilderSidebar() { updateRules(screenId, nextRules); }; - const handleRuleOptionToggle = (screenId: string, ruleIndex: number, optionId: string) => { + const handleRuleOptionToggle = ( + screenId: string, + ruleIndex: number, + optionId: string + ) => { const screen = getScreenById(screenId); if (!screen) { return; @@ -220,7 +239,11 @@ export function BuilderSidebar() { updateRules(screenId, nextRules); }; - const handleRuleNextScreenChange = (screenId: string, ruleIndex: number, nextScreenId: string) => { + const handleRuleNextScreenChange = ( + screenId: string, + ruleIndex: number, + nextScreenId: string + ) => { const screen = getScreenById(screenId); if (!screen) { return; @@ -247,7 +270,10 @@ export function BuilderSidebar() { const nextRules = [ ...(screen.navigation?.rules ?? []), - { nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] }, + { + nextScreenId: state.screens[0]?.id ?? screen.id, + conditions: [defaultCondition], + }, ]; updateNavigation(screen, { rules: nextRules }); }; @@ -270,7 +296,10 @@ export function BuilderSidebar() { dispatch({ type: "remove-screen", payload: { screenId } }); }; - const handleTemplateUpdate = (screenId: string, updates: Partial) => { + const handleTemplateUpdate = ( + screenId: string, + updates: Partial + ) => { dispatch({ type: "update-screen", payload: { @@ -295,7 +324,9 @@ export function BuilderSidebar() { }); }; - const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false; + const selectedScreenIsListType = selectedScreen + ? isListScreen(selectedScreen) + : false; return (
@@ -347,19 +378,27 @@ export function BuilderSidebar() { handleMetaChange("title", event.target.value)} + onChange={(event) => + handleMetaChange("title", event.target.value) + } /> handleMetaChange("description", event.target.value)} + onChange={(event) => + handleMetaChange("description", event.target.value) + } />
diff --git a/src/components/admin/builder/Sidebar/NavigationPanel.tsx b/src/components/admin/builder/Sidebar/NavigationPanel.tsx index a32be24..b13e6b0 100644 --- a/src/components/admin/builder/Sidebar/NavigationPanel.tsx +++ b/src/components/admin/builder/Sidebar/NavigationPanel.tsx @@ -1,7 +1,10 @@ import { useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Trash2 } from "lucide-react"; -import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context"; +import { + useBuilderDispatch, + useBuilderState, +} from "@/lib/admin/builder/context"; import { Section } from "./Section"; import type { BuilderScreen } from "@/lib/admin/builder/types"; import type { NavigationRuleDefinition } from "@/lib/funnel/types"; @@ -10,7 +13,9 @@ interface NavigationPanelProps { screen: BuilderScreen; } -function isListScreen(screen: BuilderScreen): screen is BuilderScreen & { template: "list" } { +function isListScreen( + screen: BuilderScreen +): screen is BuilderScreen & { template: "list" } { return screen.template === "list"; } @@ -19,7 +24,7 @@ export function NavigationPanel({ screen }: NavigationPanelProps) { const dispatch = useBuilderDispatch(); const screenOptions = useMemo( - () => state.screens.map((s) => ({ id: s.id, title: s.title.text })), + () => state.screens.map((s) => ({ id: s.id, title: s.title?.text })), [state.screens] ); @@ -38,15 +43,22 @@ export function NavigationPanel({ screen }: NavigationPanelProps) { screenId: targetScreen.id, navigation: { defaultNextScreenId: - navigationUpdates.defaultNextScreenId ?? targetScreen.navigation?.defaultNextScreenId, - rules: navigationUpdates.rules ?? targetScreen.navigation?.rules ?? [], - isEndScreen: navigationUpdates.isEndScreen ?? targetScreen.navigation?.isEndScreen, + navigationUpdates.defaultNextScreenId ?? + targetScreen.navigation?.defaultNextScreenId, + rules: + navigationUpdates.rules ?? targetScreen.navigation?.rules ?? [], + isEndScreen: + navigationUpdates.isEndScreen ?? + targetScreen.navigation?.isEndScreen, }, }, }); }; - const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => { + const handleDefaultNextChange = ( + screenId: string, + nextScreenId: string | "" + ) => { const targetScreen = getScreenById(screenId); if (!targetScreen) return; @@ -64,7 +76,9 @@ export function NavigationPanel({ screen }: NavigationPanelProps) { const handleAddRule = (targetScreen: BuilderScreen) => { const rules = targetScreen.navigation?.rules ?? []; - const firstScreenOption = screenOptions.find(s => s.id !== targetScreen.id); + const firstScreenOption = screenOptions.find( + (s) => s.id !== targetScreen.id + ); updateRules(targetScreen.id, [ ...rules, { @@ -105,7 +119,9 @@ export function NavigationPanel({ screen }: NavigationPanelProps) { className="rounded border-border" />
- Финальный экран + + Финальный экран + Этот экран завершает воронку (переход не требуется) @@ -115,11 +131,15 @@ export function NavigationPanel({ screen }: NavigationPanelProps) { {/* Обычная навигация - показываем только если НЕ финальный экран */} {!screen.navigation?.isEndScreen && (
{previewVariantIndex !== null && (
- ⚠️ Превью принудительно показывает вариант. В реальной воронке он показывается только при выполнении условий. + ⚠️ Превью принудительно показывает вариант. В реальной воронке + он показывается только при выполнении условий.
)} @@ -155,10 +182,10 @@ export function BuilderPreview() { style={{ height: PREVIEW_HEIGHT, width: PREVIEW_WIDTH, - overflow: 'hidden', // Hide anything that goes outside - contain: 'layout style paint', // CSS containment - isolation: 'isolate', // Create new stacking context - transform: 'translateZ(0)' // Force new layer + overflow: "hidden", // Hide anything that goes outside + contain: "layout style paint", // CSS containment + isolation: "isolate", // Create new stacking context + transform: "translateZ(0)", // Force new layer }} > {/* Screen Content with scroll - wrapped in Error Boundary */} diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index 61e241a..8c43356 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -11,6 +11,7 @@ import { ListScreenConfig } from "./ListScreenConfig"; import { EmailScreenConfig } from "./EmailScreenConfig"; import { LoadersScreenConfig } from "./LoadersScreenConfig"; import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig"; +import { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig"; import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput"; @@ -28,6 +29,7 @@ import type { TypographyVariant, BottomActionButtonDefinition, HeaderDefinition, + TrialPaymentScreenDefinition, } from "@/lib/funnel/types"; const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"]; @@ -539,6 +541,12 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) { onUpdate={onUpdate as (updates: Partial) => void} /> )} + {template === "trialPayment" && ( + ) => void} + /> + )} ); } diff --git a/src/components/admin/builder/templates/TrialPaymentScreenConfig.tsx b/src/components/admin/builder/templates/TrialPaymentScreenConfig.tsx new file mode 100644 index 0000000..0d3309f --- /dev/null +++ b/src/components/admin/builder/templates/TrialPaymentScreenConfig.tsx @@ -0,0 +1,717 @@ +"use client"; + +import React from "react"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; +import type { TrialPaymentScreenDefinition } from "@/lib/funnel/types"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput"; +import { Button } from "@/components/ui/button"; +import { Trash } from "lucide-react"; + +interface TrialPaymentScreenConfigProps { + screen: BuilderScreen & { template: "trialPayment" }; + onUpdate: (updates: Partial) => void; +} + +export function TrialPaymentScreenConfig({ screen, onUpdate }: TrialPaymentScreenConfigProps) { + const updateHeaderBlock = (updates: Partial>) => { + onUpdate({ headerBlock: { ...screen.headerBlock, ...updates } }); + }; + + const updateUnlock = ( + updates: Partial> + ) => { + onUpdate({ unlockYourSketch: { ...screen.unlockYourSketch, ...updates } }); + }; + + const updatePaymentButtons = ( + index: number, + field: "text" | "icon" | "primary", + value: string | boolean + ) => { + const current = screen.paymentButtons?.buttons ?? []; + const buttons = current.map((b, i) => + i === index + ? { + ...b, + ...(field === "text" ? { text: String(value) } : {}), + ...(field === "icon" ? { icon: String(value) as "pay" | "google" | "card" } : {}), + ...(field === "primary" ? { primary: Boolean(value) } : {}), + } + : b + ); + onUpdate({ paymentButtons: { buttons } }); + }; + + const updateFooterContacts = ( + field: "email" | "address" | "title", + value: { href: string; text: string } | { text: string } + ) => { + const next = { ...(screen.footer?.contacts ?? {}) } as NonNullable["contacts"]; + if (field === "email") next!.email = value as { href: string; text: string }; + if (field === "address") next!.address = value as { text: string }; + if (field === "title") next!.title = { text: (value as { text: string }).text }; + onUpdate({ footer: { ...screen.footer, contacts: next } }); + }; + + return ( +
+
+

Header Block

+
+ updateHeaderBlock({ text: { ...(screen.headerBlock?.text ?? {}), text: e.target.value } })} + /> + updateHeaderBlock({ timerSeconds: Number(e.target.value) })} + /> +
+
+ +
+

Reviews

+
+ onUpdate({ reviews: { title: { text: e.target.value }, items: screen.reviews?.items ?? [] } })} + /> +
+
+ Items + +
+ {(screen.reviews?.items ?? []).map((r, idx) => ( +
+ { + const current = screen.reviews?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, name: { text: e.target.value } } : v)); + onUpdate({ reviews: { ...screen.reviews, items } }); + }} + /> + { + const current = screen.reviews?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, date: { text: e.target.value } } : v)); + onUpdate({ reviews: { ...screen.reviews, items } }); + }} + /> + { + const current = screen.reviews?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, text: { text: e.target.value } } : v)); + onUpdate({ reviews: { ...screen.reviews, items } }); + }} + /> + { + const current = screen.reviews?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, avatar: { src: e.target.value } } : v)); + onUpdate({ reviews: { ...screen.reviews, items } }); + }} + /> + { + const current = screen.reviews?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, portrait: { src: e.target.value } } : v)); + onUpdate({ reviews: { ...screen.reviews, items } }); + }} + /> + { + const current = screen.reviews?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, photo: { src: e.target.value } } : v)); + onUpdate({ reviews: { ...screen.reviews, items } }); + }} + /> +
+ { + const current = screen.reviews?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, rating: Number(e.target.value) } : v)); + onUpdate({ reviews: { ...screen.reviews, items } }); + }} + /> +
+ +
+
+
+ ))} +
+
+
+ +
+

Common Questions

+
+ onUpdate({ commonQuestions: { title: { text: e.target.value }, items: screen.commonQuestions?.items ?? [] } })} + /> +
+
+ Items + +
+ {(screen.commonQuestions?.items ?? []).map((q, idx) => ( +
+ { + const current = screen.commonQuestions?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, question: e.target.value } : v)); + onUpdate({ commonQuestions: { ...screen.commonQuestions, items } }); + }} + /> + { + const current = screen.commonQuestions?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, answer: e.target.value } : v)); + onUpdate({ commonQuestions: { ...screen.commonQuestions, items } }); + }} + /> +
+ +
+
+ ))} +
+
+
+ +
+

Progress To See Soulmate

+
+ onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, title: { text: e.target.value } } })} + /> + onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, progress: { value: Number(e.target.value) } } })} + /> + onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, leftText: { text: e.target.value } } })} + /> + onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, rightText: { text: e.target.value } } })} + /> +
+
+ +
+

Steps To See Soulmate

+
+
+ Steps + +
+ + {(screen.stepsToSeeSoulmate?.steps ?? []).map((step, idx) => ( +
+
+ { + const current = screen.stepsToSeeSoulmate?.steps ?? []; + const steps = current.map((s, i) => (i === idx ? { ...s, title: { text: e.target.value } } : s)); + onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } }); + }} + /> + { + const icon = e.target.value as "questions" | "profile" | "sketch" | "astro" | "chat"; + const current = screen.stepsToSeeSoulmate?.steps ?? []; + const steps = current.map((s, i) => (i === idx ? { ...s, icon } : s)); + onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } }); + }} + /> +
+ { + const current = screen.stepsToSeeSoulmate?.steps ?? []; + const steps = current.map((s, i) => (i === idx ? { ...s, description: { text: e.target.value } } : s)); + onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } }); + }} + /> +
+ + +
+
+ ))} +
+
+ +
+

Money Back Guarantee

+
+ onUpdate({ moneyBackGuarantee: { ...screen.moneyBackGuarantee, title: { text: e.target.value } } })} + /> + onUpdate({ moneyBackGuarantee: { ...screen.moneyBackGuarantee, text: { text: e.target.value } } })} + /> +
+
+ +
+

Policy

+
+ onUpdate({ policy: { text: { text: e.target.value } } })} + /> +
+
+ +
+

Users' Portraits

+
+ onUpdate({ usersPortraits: { ...screen.usersPortraits, title: { text: e.target.value } } })} + /> + onUpdate({ usersPortraits: { ...screen.usersPortraits, buttonText: e.target.value } })} + /> +
+
+
+ Images + +
+ {(screen.usersPortraits?.images ?? []).map((img, idx) => ( +
+ { + const current = screen.usersPortraits?.images ?? []; + const images = current.map((v, i) => (i === idx ? { ...v, src: e.target.value } : v)); + onUpdate({ usersPortraits: { ...screen.usersPortraits, images } }); + }} + /> +
+ +
+
+ ))} +
+
+ +
+

Joined Today With Avatars

+
+ onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, count: { text: e.target.value } } })} + /> + onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, text: { text: e.target.value } } })} + /> +
+
+
+ Avatars + +
+ {(screen.joinedTodayWithAvatars?.avatars?.images ?? []).map((img, idx) => ( +
+ { + const current = screen.joinedTodayWithAvatars?.avatars?.images ?? []; + const images = current.map((v, i) => (i === idx ? { ...v, src: e.target.value } : v)); + onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, avatars: { images } } }); + }} + /> +
+ +
+
+ ))} +
+
+ +
+

Try For Days

+
+ onUpdate({ tryForDays: { ...screen.tryForDays, title: { text: e.target.value } } })} + /> +
+
+ Items + +
+ {(screen.tryForDays?.textList?.items ?? []).map((it, idx) => ( +
+ { + const current = screen.tryForDays?.textList?.items ?? []; + const items = current.map((v, i) => (i === idx ? { ...v, text: e.target.value } : v)); + onUpdate({ tryForDays: { ...screen.tryForDays, textList: { items } } }); + }} + /> +
+ +
+
+ ))} +
+
+
+ +
+

Total Price

+
+ onUpdate({ totalPrice: { couponContainer: { ...(screen.totalPrice?.couponContainer ?? {}), title: { text: e.target.value } }, priceContainer: screen.totalPrice?.priceContainer } })} + /> + onUpdate({ totalPrice: { couponContainer: { ...(screen.totalPrice?.couponContainer ?? {}), buttonText: e.target.value }, priceContainer: screen.totalPrice?.priceContainer } })} + /> + onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), title: { text: e.target.value } } } })} + /> + onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), price: { text: e.target.value } } } })} + /> + onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), oldPrice: { text: e.target.value } } } })} + /> + onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), discount: { text: e.target.value } } } })} + /> +
+
+ +
+

Joined Today

+
+ onUpdate({ joinedToday: { ...screen.joinedToday, count: { text: e.target.value } } })} + /> + onUpdate({ joinedToday: { ...screen.joinedToday, text: { text: e.target.value } } })} + /> +
+
+ +
+

Trusted By Over

+
+ onUpdate({ trustedByOver: { text: { text: e.target.value } } })} + /> +
+
+ +
+

Finding The One Guide

+
+ onUpdate({ findingOneGuide: { ...screen.findingOneGuide, header: { ...(screen.findingOneGuide?.header ?? {}), emoji: { text: e.target.value } } } })} + /> + onUpdate({ findingOneGuide: { ...screen.findingOneGuide, header: { ...(screen.findingOneGuide?.header ?? {}), title: { text: e.target.value } } } })} + /> + onUpdate({ findingOneGuide: { ...screen.findingOneGuide, text: { text: e.target.value } } })} + /> + onUpdate({ findingOneGuide: { ...screen.findingOneGuide, blur: { ...(screen.findingOneGuide?.blur ?? {}), text: { text: e.target.value }, icon: "lock" } } })} + /> +
+
+ +
+

Unlock Your Sketch

+
+ updateUnlock({ title: { text: e.target.value } })} /> + updateUnlock({ subtitle: { text: e.target.value } })} /> + updateUnlock({ image: { src: e.target.value } })} /> + updateUnlock({ blur: { ...(screen.unlockYourSketch?.blur ?? {}), text: { text: e.target.value }, icon: "lock" } as NonNullable["blur"] })} /> + updateUnlock({ buttonText: e.target.value })} /> +
+
+ +
+

Payment Buttons

+ {(screen.paymentButtons?.buttons ?? []).map((b, i) => ( +
+ updatePaymentButtons(i, "text", e.target.value)} /> + updatePaymentButtons(i, "icon", e.target.value)} /> + +
+ ))} +
+ +
+

Footer / Contacts

+
+ updateFooterContacts("email", { href: e.target.value, text: e.target.value })} /> + updateFooterContacts("address", { text: e.target.value })} /> +
+
+ +
+

Still Have Questions

+
+ onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, title: { text: e.target.value } } })} + /> + onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, actionButtonText: e.target.value } })} + /> + onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, contactButtonText: e.target.value } })} + /> +
+
+
+ ); +} + + diff --git a/src/components/admin/builder/templates/index.ts b/src/components/admin/builder/templates/index.ts index 3070441..f31d3b8 100644 --- a/src/components/admin/builder/templates/index.ts +++ b/src/components/admin/builder/templates/index.ts @@ -4,3 +4,4 @@ export { CouponScreenConfig } from "./CouponScreenConfig"; export { FormScreenConfig } from "./FormScreenConfig"; export { ListScreenConfig } from "./ListScreenConfig"; export { TemplateConfig } from "./TemplateConfig"; +export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig"; diff --git a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx index 9f92dfc..416c6e6 100644 --- a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx +++ b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx @@ -47,7 +47,7 @@ export function EmailTemplate({ defaultTexts, }: EmailTemplateProps) { const { authorization, isLoading, error } = useAuth({ - funnelId: funnel.meta.id, + funnelId: funnel?.meta?.id ?? "preview", }); const [isTouched, setIsTouched] = useState(false); diff --git a/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.stories.tsx b/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.stories.tsx new file mode 100644 index 0000000..c7a04d7 --- /dev/null +++ b/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.stories.tsx @@ -0,0 +1,266 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { TrialPaymentTemplate } from "./TrialPaymentTemplate"; +import { fn } from "storybook/test"; +import type { TrialPaymentScreenDefinition } from "@/lib/funnel/types"; + +const defaultScreen: TrialPaymentScreenDefinition = { + id: "trial-payment-screen-story", + template: "trialPayment", + title: { text: "" }, + subtitle: { text: "" }, + bottomActionButton: { show: false, showPrivacyTermsConsent: false }, + headerBlock: { + timerSeconds: 600, + text: { text: "⚠️ Your sketch expires soon!" }, + timer: { text: "" }, + }, + unlockYourSketch: { + title: { text: "Unlock Your Sketch" }, + subtitle: { text: "Just One Click to Reveal Your Match!" }, + image: { src: "/trial-payment/portrait-female.jpg" }, + blur: { text: { text: "Unlock to reveal your personalized portrait" }, icon: "lock" }, + buttonText: "Get Me Soulmate Sketch", + }, + joinedToday: { + count: { text: "954" }, + text: { text: "Joined today" }, + }, + trustedByOver: { + text: { text: "Trusted by over 355,000 people." }, + }, + findingOneGuide: { + header: { + emoji: { text: "❤️" }, + title: { text: "Finding the One Guide" }, + }, + text: { + text: + "You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're. You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're", + }, + blur: { text: { text: "Чтобы открыть весь отчёт, нужен полный доступ." }, icon: "lock" }, + }, + tryForDays: { + title: { text: "Попробуйте в течение 7 дней!" }, + textList: { + items: [ + { text: "Receive a hand-drawn sketch of your soulmate, crafted by a trained AI-model." }, + { text: "Reveal the path to your soulmate with the Finding the One guide." }, + { text: "Talk to live experts and get guidance on finding your soulmate." }, + { text: "Start your 7-day trial for just $1.00 — then only $14.50/week for full access." }, + { text: "Cancel anytime—just 24 hours before renewal." }, + ], + }, + }, + totalPrice: { + couponContainer: { + title: { text: "Coupon\nCode" }, + buttonText: "SOULMATE94", + }, + priceContainer: { + title: { text: "Total" }, + price: { text: "$1.00" }, + oldPrice: { text: "$14.99" }, + discount: { text: "94% discount applied" }, + }, + }, + paymentButtons: { + buttons: [ + { text: "Pay", icon: "pay" }, + { text: "Pay", icon: "google" }, + { text: "Credit or debit card", icon: "card", primary: true }, + ], + }, + moneyBackGuarantee: { + title: { text: "30-DAY MONEY-BACK GUARANTEE" }, + text: { text: "If you don't receive your soulmate sketch, we'll refund your money!" }, + }, + policy: { + text: { text: "By clicking Continue, you agree to our Terms of Use & Service and Privacy Policy. You also acknowledge that your 1 week introductory plan to Respontika, billed at $1.00, will automatically renew at $14.50 every 1 week unless canceled before the end of the trial period." }, + }, + usersPortraits: { + title: { text: "Our Users' Soulmate Portraits" }, + images: [ + { src: "/trial-payment/users-portraits/1.jpg" }, + { src: "/trial-payment/users-portraits/2.jpg" }, + { src: "/trial-payment/users-portraits/3.jpg" }, + ], + buttonText: "Get me soulmate sketch", + }, + joinedTodayWithAvatars: { + count: { text: "954" }, + text: { text: "people joined today" }, + avatars: { + images: [ + { src: "/trial-payment/avatars/1.jpg" }, + { src: "/trial-payment/avatars/2.jpg" }, + { src: "/trial-payment/avatars/3.jpg" }, + { src: "/trial-payment/avatars/4.jpg" }, + { src: "/trial-payment/avatars/5.jpg" }, + ], + }, + }, + progressToSeeSoulmate: { + title: { text: "See Your Soulmate – Just One Step Away" }, + progress: { value: 92 }, + leftText: { text: "Step 2 of 5" }, + rightText: { text: "99% Complete" }, + }, + stepsToSeeSoulmate: { + steps: [ + { + title: { text: "Questions Answered" }, + description: { text: "You've provided all the necessary information about your preferences and personality." }, + icon: "questions", + isActive: true, + }, + { + title: { text: "Profile Analysis" }, + description: { text: "Our advanced system is creating your perfect soulmate profile." }, + icon: "profile", + isActive: true, + }, + { + title: { text: "Sketch Creation" }, + description: { text: "Your personalized soulmate sketch will be created." }, + icon: "sketch", + isActive: false, + }, + { + title: { text: "Астрологические Идеи" }, + description: { text: "Уникальные астрологические рекомендации, усиливающие совместимость." }, + icon: "astro", + isActive: false, + }, + { + title: { text: "Персонализированный чат с экспертом" }, + description: { text: "Персональные советы от экспертов по отношениям." }, + icon: "chat", + isActive: false, + }, + ], + buttonText: "Show Me My Soulmate", + }, + reviews: { + title: { text: "Loved and Trusted Worldwide" }, + items: [ + { + name: { text: "Jennifer Wilson 🇺🇸" }, + text: { text: "**“Я увидела свои ошибки… и нашла мужа”**\nПортрет сразу зацепил — было чувство, что я уже где-то его видела. Но настоящий перелом произошёл после гайда: я поняла, почему снова и снова выбирала «не тех». И самое удивительное — вскоре я познакомилась с мужчиной, который оказался точной копией того самого портрета. Сейчас он мой муж, и когда мы сравнили рисунок с его фото, сходство было просто вау." }, + avatar: { src: "/trial-payment/reviews/avatars/1.jpg" }, + portrait: { src: "/trial-payment/reviews/portraits/1.jpg" }, + photo: { src: "/trial-payment/reviews/photos/1.jpg" }, + rating: 5, + date: { text: "1 day ago" }, + }, + { + name: { text: "Amanda Davis 🇨🇦" }, + text: { text: "**“Я поняла своего партнёра лучше за один вечер, чем за несколько лет”**\nПрошла тест ради интереса — портрет нас удивил. Но настоящий прорыв случился, когда я прочитала гайд о второй половинке. Там были точные подсказки о том, как мы можем поддерживать друг друга. Цена смешная, а ценность огромная: теперь у нас меньше недопониманий и больше тепла." }, + avatar: { src: "/trial-payment/reviews/avatars/2.jpg" }, + portrait: { src: "/trial-payment/reviews/portraits/2.jpg" }, + photo: { src: "/trial-payment/reviews/photos/2.jpg" }, + rating: 5, + date: { text: "4 days ago" }, + }, + { + name: { text: "Michael Johnson 🇬🇧" }, + text: { text: "**“Увидел её лицо — и мурашки по коже”**\nКогда пришёл результат теста и показали портрет, я реально замер. Это была та самая девушка, с которой я начал встречаться пару недель назад. И гайд прямо описал, почему мы тянемся друг к другу. Честно, я не ожидал такого совпадения." }, + avatar: { src: "/trial-payment/reviews/avatars/3.jpg" }, + portrait: { src: "/trial-payment/reviews/portraits/3.jpg" }, + photo: { src: "/trial-payment/reviews/photos/3.jpg" }, + rating: 5, + date: { text: "1 week ago" }, + }, + ], + }, + commonQuestions: { + title: { text: "Common Questions" }, + items: [ + { + question: "When will I receive my sketch?", + answer: + "Your personalized soulmate sketch will be delivered within 24-48 hours after completing your order. You'll receive an email notification when it's ready for viewing in your account.", + }, + { + question: "How do I cancel my subscription?", + answer: + "You can cancel anytime from your account settings. Make sure to cancel at least 24 hours before the renewal date to avoid being charged.", + }, + { + question: "How accurate are the readings?", + answer: + "Our readings are based on a combination of your answers and advanced pattern analysis. While they provide valuable insights, they are intended for guidance and entertainment purposes.", + }, + { + question: "Is my data secure and private?", + answer: + "Yes. We follow strict data protection standards. Your data is encrypted and never shared with third parties without your consent.", + }, + ], + }, + stillHaveQuestions: { + title: { text: "Still have questions? We're here to help!" }, + actionButtonText: "Get me Soulmate Sketch", + contactButtonText: "Contact Support", + }, + footer: { + title: { text: "WIT LAB ©" }, + contacts: { + title: { text: "CONTACTS" }, + email: { href: "support@witlab.com", text: "support@witlab.com" }, + address: { text: "Wit Lab 2108 N ST STE N SACRAMENTO, CA95816, US" }, + }, + legal: { + title: { text: "LEGAL" }, + links: [ + { href: "https://witlab.com/terms", text: "Terms of Service" }, + { href: "https://witlab.com/privacy", text: "Privacy Policy" }, + { href: "https://witlab.com/refund", text: "Refund Policy" }, + ], + copyright: { + text: + "Copyright © 2025 Wit Lab™. All rights reserved. All trademarks referenced herein are the properties of their respective owners.", + }, + }, + paymentMethods: { + title: { text: "PAYMENT METHODS" }, + methods: [ + { src: "/trial-payment/payment-methods/visa.svg", alt: "visa" }, + { src: "/trial-payment/payment-methods/mastercard.svg", alt: "mastercard" }, + { src: "/trial-payment/payment-methods/discover.svg", alt: "discover" }, + { src: "/trial-payment/payment-methods/apple.svg", alt: "apple" }, + { src: "/trial-payment/payment-methods/google.svg", alt: "google" }, + { src: "/trial-payment/payment-methods/paypal.svg", alt: "paypal" }, + ], + }, + }, +}; + +const meta: Meta = { + title: "Funnel Templates/TrialPaymentTemplate", + component: TrialPaymentTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: defaultScreen, + onContinue: fn(), + canGoBack: true, + onBack: fn(), + screenProgress: { current: 8, total: 10 }, + defaultTexts: { + nextButton: "Continue", + continueButton: "Continue", + }, + }, + argTypes: { + screen: { control: { type: "object" } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + + diff --git a/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.tsx b/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.tsx new file mode 100644 index 0000000..cab26ad --- /dev/null +++ b/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.tsx @@ -0,0 +1,870 @@ +"use client"; + +import type { + TrialPaymentScreenDefinition, + DefaultTexts, + FunnelDefinition, +} from "@/lib/funnel/types"; +import { TemplateLayout } from "../layouts/TemplateLayout"; +import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers"; +import { cn } from "@/lib/utils"; +import { useRef } from "react"; +import { + Header, + JoinedToday, + TrustedByOver, + JoinedTodayWithAvatars, +} from "@/components/domains/TrialPayment"; +import { UnlockYourSketch } from "@/components/domains/TrialPayment/Cards"; +import { + FindingOneGuide, + TryForDays, + TotalPrice, + PaymentButtons, + UsersPortraits, +} from "@/components/domains/TrialPayment/Cards"; +import { MoneyBackGuarantee, Policy } from "@/components/domains/TrialPayment"; +import { + StepsToSeeSoulmate, + Reviews, + CommonQuestions, + StillHaveQuestions, + Footer, +} from "@/components/domains/TrialPayment/Cards"; +import ProgressToSeeSoulmate from "@/components/domains/TrialPayment/ProgressToSeeSoulmate/ProgressToSeeSoulmate"; +import { buildTypographyProps } from "@/lib/funnel/mappers"; +// import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement"; +// import { useRouter } from "next/navigation"; +// import { ROUTES } from "@/shared/constants/client-routes"; + +interface TrialPaymentTemplateProps { + funnel: FunnelDefinition; + screen: TrialPaymentScreenDefinition; + // selectedEmail: string; + // onEmailChange: (email: string) => void; + onContinue: () => void; + canGoBack: boolean; + onBack: () => void; + screenProgress?: { current: number; total: number }; + defaultTexts?: DefaultTexts; +} + +export function TrialPaymentTemplate({ +// funnel, + screen, + // selectedEmail, + // onEmailChange, +// onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, +}: TrialPaymentTemplateProps) { +// const router = useRouter(); + // TODO: выбрать корректный paymentId для этого экрана (ключ из backend), временно "main_secret_discount" +// const paymentId = "main_secret_discount"; +// const { placement } = usePaymentPlacement({ funnel, paymentId }); + + const handlePayClick = () => { + // const productId = + // placement?.variants?.[0]?.id || placement?.variants?.[0]?.key || ""; + // const placementId = placement?.placementId || ""; + // const paywallId = placement?.paywallId || ""; + // if (productId && placementId && paywallId) { + // router.push( + // ROUTES.payment({ + // productId, + // placementId, + // paywallId, + // }) + // ); + // } + }; + const paymentSectionRef = useRef(null); + + const scrollToPayment = () => { + if (paymentSectionRef.current) { + paymentSectionRef.current.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }; + + // Отключаем общий Header в TemplateLayout для этого экрана + const screenWithoutHeader = { + ...screen, + header: { + ...(screen.header || {}), + show: false, + showBackButton: false, + showProgress: false, + }, + } as typeof screen; + + // Убираем title/subtitle из общего Layout для этого экрана + const screenForLayout = { + ...screenWithoutHeader, + title: undefined, + subtitle: undefined, + } as typeof screenWithoutHeader; + + const layoutProps = createTemplateLayoutProps( + screenForLayout, + { canGoBack, onBack }, + screenProgress, + { + preset: "center", + actionButton: + screen.bottomActionButton?.show === false + ? undefined + : { + defaultText: defaultTexts?.nextButton || "Continue", + disabled: false, + onClick: scrollToPayment, + }, + } + ); + + return ( + +
+ {/* Header block */} + {screen.headerBlock && ( +
+ )} + + {/* UnlockYourSketch section */} + {screen.unlockYourSketch && ( + + + + ) : undefined, + }} + button={ + screen.unlockYourSketch.buttonText + ? { + children: screen.unlockYourSketch.buttonText, + onClick: scrollToPayment, + } + : undefined + } + /> + )} + + {screen.joinedToday && ( + + + + } + /> + )} + + {screen.trustedByOver && ( + + + + } + /> + )} + + {screen.findingOneGuide && ( + + + + ) : undefined, + }} + /> + )} + + {screen.tryForDays && ( + + buildTypographyProps(it, { + as: "li", + defaults: { font: "inter", size: "sm" }, + })! + ), + } + : undefined + } + /> + )} + + {screen.totalPrice && ( + + )} + + {screen.paymentButtons && ( +
+ { + const icon = + b.icon === "pay" ? ( + + + + + + + + + + + ) : b.icon === "google" ? ( + + + + + + + ) : b.icon === "card" ? ( + + + + + + + + + + + ) : undefined; + + const className = b.primary ? "bg-primary" : undefined; + + return { + children: b.text, + icon, + className, + onClick: handlePayClick, + }; + })} + /> +
+ )} + + {screen.moneyBackGuarantee && ( + + )} + + {screen.policy && ( + + )} + + {screen.usersPortraits && ( + ({ + src: img.src, + alt: "user portrait", + }))} + button={ + screen.usersPortraits.buttonText + ? { + children: screen.usersPortraits.buttonText, + onClick: scrollToPayment, + } + : undefined + } + /> + )} + + {screen.joinedTodayWithAvatars && ( + ({ + imageProps: { src: img.src, alt: "avatar" }, + }) + ), + } + : undefined + } + count={buildTypographyProps(screen.joinedTodayWithAvatars.count, { + as: "span", + defaults: { font: "inter", weight: "bold", size: "sm" }, + })} + text={buildTypographyProps(screen.joinedTodayWithAvatars.text, { + as: "p", + defaults: { font: "inter", weight: "semiBold", size: "sm" }, + })} + /> + )} + + {screen.progressToSeeSoulmate && ( + + )} + + {screen.stepsToSeeSoulmate && ( + ({ + title: buildTypographyProps(s.title, { + as: "h4", + defaults: { font: "inter", weight: "semiBold", size: "sm" }, + })!, + description: buildTypographyProps(s.description, { + as: "p", + defaults: { font: "inter", size: "xs" }, + })!, + icon: + s.icon === "questions" ? ( + + + + ) : s.icon === "profile" ? ( + + + + ) : s.icon === "sketch" ? ( + + + + ) : s.icon === "astro" ? ( + + + + ) : s.icon === "chat" ? ( + + + + ) : null, + isActive: s.isActive ?? false, + }))} + button={ + screen.stepsToSeeSoulmate.buttonText + ? { + children: screen.stepsToSeeSoulmate.buttonText, + onClick: scrollToPayment, + } + : undefined + } + /> + )} + + {screen.reviews && ( + ({ + name: buildTypographyProps(r.name, { + as: "span", + defaults: { font: "inter", weight: "semiBold", size: "sm" }, + }), + text: buildTypographyProps(r.text, { + as: "p", + defaults: { font: "inter", size: "sm" }, + }), + date: buildTypographyProps(r.date, { + as: "span", + defaults: { font: "inter", size: "xs" }, + }), + avatar: r.avatar + ? { + imageProps: { src: r.avatar.src, alt: "avatar" }, + } + : undefined, + stars: r.rating ? { value: r.rating } : undefined, + portrait: r.portrait + ? { src: r.portrait.src, alt: "Portrait" } + : undefined, + photo: r.photo ? { src: r.photo.src, alt: "Photo" } : undefined, + }))} + /> + )} + + {screen.commonQuestions && ( + ({ + value: `q-${index}`, + trigger: { children: q.question }, + content: { children: q.answer }, + }))} + accordionProps={{ defaultValue: "q-0", type: "single" }} + /> + )} + + {screen.stillHaveQuestions && ( + + )} + + {screen.footer && ( +
({ + href: l.href, + children: l.text, + })) || [], + copyright: buildTypographyProps( + screen.footer.legal.copyright, + { as: "p", defaults: { font: "inter", size: "xs" } } + ), + } + : undefined + } + paymentMethods={ + screen.footer.paymentMethods + ? { + title: buildTypographyProps( + screen.footer.paymentMethods.title, + { as: "h3", defaults: { font: "inter", weight: "bold" } } + ), + methods: + screen.footer.paymentMethods.methods?.map((m) => ({ + src: m.src, + alt: m.alt, + })) || [], + } + : undefined + } + /> + )} +
+
+ ); +} diff --git a/src/components/funnel/templates/TrialPaymentTemplate/index.ts b/src/components/funnel/templates/TrialPaymentTemplate/index.ts new file mode 100644 index 0000000..94deec1 --- /dev/null +++ b/src/components/funnel/templates/TrialPaymentTemplate/index.ts @@ -0,0 +1,3 @@ +export { TrialPaymentTemplate } from "./TrialPaymentTemplate"; + + diff --git a/src/components/funnel/templates/index.ts b/src/components/funnel/templates/index.ts index cf85da7..77bde3e 100644 --- a/src/components/funnel/templates/index.ts +++ b/src/components/funnel/templates/index.ts @@ -7,6 +7,7 @@ export { EmailTemplate } from "./EmailTemplate"; export { CouponTemplate } from "./CouponTemplate"; export { LoadersTemplate } from "./LoadersTemplate"; export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; +export { TrialPaymentTemplate } from "./TrialPaymentTemplate/index"; // Layout Templates export { TemplateLayout } from "./layouts/TemplateLayout"; diff --git a/src/components/funnel/templates/layouts/TemplateLayout.tsx b/src/components/funnel/templates/layouts/TemplateLayout.tsx index 2867abd..cb6e191 100644 --- a/src/components/funnel/templates/layouts/TemplateLayout.tsx +++ b/src/components/funnel/templates/layouts/TemplateLayout.tsx @@ -45,6 +45,10 @@ interface TemplateLayoutProps { // Дополнительные props для Title childrenAboveTitle?: React.ReactNode; + // Переопределения стилей LayoutQuestion (контент и обертка контента) + contentProps?: React.ComponentProps<"div">; + childrenWrapperProps?: React.ComponentProps<"div">; + // Контент template children: React.ReactNode; } @@ -64,6 +68,8 @@ export function TemplateLayout({ childrenAboveButton, childrenUnderButton, childrenAboveTitle, + contentProps, + childrenWrapperProps, children, }: TemplateLayoutProps) { // 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON @@ -121,7 +127,12 @@ export function TemplateLayout({ // 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ return (
- + {children} diff --git a/src/components/ui/GPTAnimationText/GPTAnimationText.module.css b/src/components/ui/GPTAnimationText/GPTAnimationText.module.css new file mode 100644 index 0000000..3a1e944 --- /dev/null +++ b/src/components/ui/GPTAnimationText/GPTAnimationText.module.css @@ -0,0 +1,82 @@ +.list { + position: relative; + width: 100%; + margin-top: 16px; + display: flex; + flex-direction: column; + align-items: center; + /* gap: 32px; */ + font-size: 20px; + /* color: #1A6697; */ + color: #acacac; + line-height: 25px; + text-align: center; + overflow: hidden; +} + +.list > .item { + transition: margin-top 0.5s ease-in-out; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + display: block; + background: var(--background); + /* padding: 16px 0; */ + overflow: hidden; + opacity: 0; + animation: list-item ease-in-out forwards; +} + +.list > .item > .line { + display: block; + height: 100%; + width: 64px; + background: linear-gradient( + to right, + #acacac 0%, + #333333 50%, + #acacac 100% + ); + top: 0; + left: 50%; + position: absolute; + mix-blend-mode: color-burn; + filter: blur(3px); + animation: line-move cubic-bezier(0.65, 0, 0.46, 1.02) infinite; +} + +.list > .item > .text { + position: relative; + color: #000; + z-index: 1; +} + +@keyframes line-move { + 0% { + left: -64px; + } + + 100% { + left: 100%; + } +} + +@keyframes list-item { + 0% { + opacity: 0; + } + + 10% { + opacity: 1; + } + + 90% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} diff --git a/src/components/ui/GPTAnimationText/GPTAnimationText.tsx b/src/components/ui/GPTAnimationText/GPTAnimationText.tsx new file mode 100644 index 0000000..b43c153 --- /dev/null +++ b/src/components/ui/GPTAnimationText/GPTAnimationText.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import styles from "./GPTAnimationText.module.css"; + +interface GPTAnimationTextProps { + points: Array; + totalAnimationTime: number; +} + +function GPTAnimationText({ + points, + totalAnimationTime, +}: GPTAnimationTextProps) { + const listRef = useRef>([]); + const [listHeight, setListHeight] = useState(0); + + useEffect(() => { + let maxHeight = 0; + listRef.current.forEach(item => { + if (item?.offsetHeight && item.offsetHeight > maxHeight) { + maxHeight = item.offsetHeight; + } + }); + setListHeight(maxHeight); + }, [listRef]); + + return ( +
+ {points.map((element, index) => ( +

{ + listRef.current[index] = el; + }} + style={{ + animationDuration: `${totalAnimationTime / points.length}ms`, + animationDelay: `${index * (totalAnimationTime / points.length)}ms`, + }} + > + {element} + +

+ ))} +
+ ); +} + +export default GPTAnimationText; diff --git a/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx b/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx new file mode 100644 index 0000000..0e523bc --- /dev/null +++ b/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; + +import GPTAnimationText from "@/components/ui/GPTAnimationText/GPTAnimationText"; +import Typography from "@/components/ui/Typography/Typography"; +import { ActionButton } from "@/components/ui/ActionButton/ActionButton"; + +interface AnimatedInfoScreenProps { + lottieAnimation: React.ReactNode; + title: string; + animationTime?: number; + animationTexts?: string[]; + buttonText?: string; + nextRoute?: string; +} + +export default async function AnimatedInfoScreen({ + lottieAnimation, + title, + animationTime, + animationTexts, + buttonText, + nextRoute, +}: AnimatedInfoScreenProps) { + return ( +
+ {lottieAnimation} + + {title} + + {!!animationTexts?.length && animationTime && ( + + )} + {nextRoute && buttonText && ( + + {buttonText} + + )} +
+ ); +} diff --git a/src/components/widgets/LottieAnimation/LottieAnimation.tsx b/src/components/widgets/LottieAnimation/LottieAnimation.tsx new file mode 100644 index 0000000..e409942 --- /dev/null +++ b/src/components/widgets/LottieAnimation/LottieAnimation.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { + DotLottieReact, + DotLottieReactProps, +} from "@lottiefiles/dotlottie-react"; +import clsx from "clsx"; + +import { useLottie } from "@/hooks/lottie/useLottie"; +import { ELottieKeys } from "@/shared/constants/lottie"; + +interface LottieAnimationProps { + loadKey: ELottieKeys; + width?: number | string; + height?: number | string; + className?: string; + animationProps?: DotLottieReactProps; +} + +export default function LottieAnimation({ + loadKey, + width = 80, + height = 80, + className, + animationProps, +}: LottieAnimationProps) { + const { animationData } = useLottie({ + loadKey, + }); + + return ( +
+ {animationData && ( + + )} +
+ ); +} diff --git a/src/entities/payment/actions.ts b/src/entities/payment/actions.ts new file mode 100644 index 0000000..8f3fa7b --- /dev/null +++ b/src/entities/payment/actions.ts @@ -0,0 +1,33 @@ +"use server"; + +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; +import { ActionResponse } from "@/shared/types"; + +import { + SingleCheckoutRequest, + SingleCheckoutResponse, + SingleCheckoutResponseSchema, +} from "./types"; + +export async function performSingleCheckout( + payload: SingleCheckoutRequest +): Promise> { + try { + const response = await http.post( + API_ROUTES.paymentSingleCheckout(), + payload, + { + schema: SingleCheckoutResponseSchema, + revalidate: 0, + } + ); + + return { data: response, error: null }; + } catch (error) { + console.error("Failed to perform single checkout:", error); + const errorMessage = + error instanceof Error ? error.message : "Something went wrong."; + return { data: null, error: errorMessage }; + } +} diff --git a/src/entities/payment/api.ts b/src/entities/payment/api.ts new file mode 100644 index 0000000..d1635f6 --- /dev/null +++ b/src/entities/payment/api.ts @@ -0,0 +1,31 @@ +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +import { + CheckoutRequest, + CheckoutResponse, + CheckoutResponseSchema, + SingleCheckoutRequest, + SingleCheckoutResponse, + SingleCheckoutResponseSchema, +} from "./types"; + +export async function createPaymentCheckout(payload: CheckoutRequest) { + return http.post(API_ROUTES.paymentCheckout(), payload, { + schema: CheckoutResponseSchema, + revalidate: 0, + }); +} + +export async function createSinglePaymentCheckout( + payload: SingleCheckoutRequest +) { + return http.post( + API_ROUTES.paymentSingleCheckout(), + payload, + { + schema: SingleCheckoutResponseSchema, + revalidate: 0, + } + ); +} diff --git a/src/entities/payment/types.ts b/src/entities/payment/types.ts new file mode 100644 index 0000000..314ee97 --- /dev/null +++ b/src/entities/payment/types.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +export const CheckoutRequestSchema = z.object({ + productId: z.string(), + placementId: z.string(), + paywallId: z.string(), +}); +export type CheckoutRequest = z.infer; + +export const CheckoutResponseSchema = z.object({ + status: z.string(), + invoiceId: z.string(), + paymentUrl: z.string().url(), +}); +export type CheckoutResponse = z.infer; + +export const PaymentInfoSchema = z.object({ + productId: z.string(), + key: z.string(), + isAutoTopUp: z.boolean().optional(), +}); +export type PaymentInfo = z.infer; + +export const SingleCheckoutRequestSchema = z.object({ + paymentInfo: PaymentInfoSchema, + return_url: z.string().optional(), + pageUrl: z.string().optional(), +}); +export type SingleCheckoutRequest = z.infer; + +export const SingleCheckoutSuccessSchema = z.object({ + payment: z.object({ + status: z.string(), + invoiceId: z.string(), + paymentUrl: z.string().url().optional(), + }), +}); +export type SingleCheckoutSuccess = z.infer; + +export const SingleCheckoutErrorSchema = z.object({ + status: z.string(), + message: z.string(), +}); +export type SingleCheckoutError = z.infer; + +export const SingleCheckoutResponseSchema = z.union([ + SingleCheckoutSuccessSchema, + SingleCheckoutErrorSchema, +]); +export type SingleCheckoutResponse = z.infer< + typeof SingleCheckoutResponseSchema +>; diff --git a/src/entities/session/funnel/api.ts b/src/entities/session/funnel/api.ts new file mode 100644 index 0000000..1f75d41 --- /dev/null +++ b/src/entities/session/funnel/api.ts @@ -0,0 +1,12 @@ +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +import { FunnelRequest, FunnelResponse, FunnelResponseSchema } from "./types"; + +export const getFunnel = async (payload: FunnelRequest) => { + return http.post(API_ROUTES.funnel(), payload, { + tags: ["funnel"], + schema: FunnelResponseSchema, + revalidate: 0, + }); +}; diff --git a/src/entities/session/funnel/loaders.ts b/src/entities/session/funnel/loaders.ts new file mode 100644 index 0000000..875cf3f --- /dev/null +++ b/src/entities/session/funnel/loaders.ts @@ -0,0 +1,41 @@ +import { cache } from "react"; + +import { getFunnel } from "./api"; +import type { FunnelRequest } from "./types"; + +export const loadFunnel = cache((payload: FunnelRequest) => getFunnel(payload)); + +export const loadFunnelData = cache((payload: FunnelRequest) => + loadFunnel(payload).then(d => d.data) +); + +export const loadFunnelStatus = cache((payload: FunnelRequest) => + loadFunnel(payload).then(d => d.status) +); + +export const loadFunnelCurrency = cache((payload: FunnelRequest) => + loadFunnelData(payload).then(d => d.currency) +); + +export const loadFunnelLocale = cache((payload: FunnelRequest) => + loadFunnelData(payload).then(d => d.locale) +); + +export const loadFunnelPayment = cache((payload: FunnelRequest) => + loadFunnelData(payload).then(d => d.payment) +); + +export const loadFunnelPaymentById = cache( + (payload: FunnelRequest, paymentId: string) => + loadFunnelData(payload).then(d => d.payment[paymentId]) +); + +// export const loadFunnelProducts = cache( +// (payload: FunnelRequest, paymentId: string) => +// loadFunnelPaymentById(payload, paymentId).then(d => d?.variants ?? []) +// ); + +// export const loadFunnelProperties = cache( +// (payload: FunnelRequest, paymentId: string) => +// loadFunnelPaymentById(payload, paymentId).then(d => d?.properties ?? []) +// ); diff --git a/src/entities/session/funnel/types.ts b/src/entities/session/funnel/types.ts new file mode 100644 index 0000000..067b4af --- /dev/null +++ b/src/entities/session/funnel/types.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +import { Currency } from "@/shared/types"; + +// Request schemas +export const FunnelRequestSchema = z.object({ + // funnel: z.enum(ELocalesPlacement), + funnel: z.string(), +}); + +// Response schemas +export const FunnelPaymentPropertySchema = z.object({ + key: z.string(), + value: z.union([z.string(), z.number()]), +}); + +export const FunnelPaymentVariantSchema = z.object({ + id: z.string(), + key: z.string(), + type: z.string(), + price: z.number(), + oldPrice: z.number().optional(), + trialPrice: z.number().optional(), +}); + +export const FunnelPaymentPlacementSchema = z.object({ + price: z.number().optional(), + currency: z.enum(Currency).optional(), + billingPeriod: z.enum(["DAY", "WEEK", "MONTH", "YEAR"]).optional(), + billingInterval: z.number().optional(), + trialPeriod: z.enum(["DAY", "WEEK", "MONTH", "YEAR"]).optional(), + trialInterval: z.number().optional(), + placementId: z.string().optional(), + paywallId: z.string().optional(), + properties: z.array(FunnelPaymentPropertySchema).optional(), + variants: z.array(FunnelPaymentVariantSchema).optional(), + paymentUrl: z.string().optional(), + type: z.string().optional(), +}); + +export const FunnelSchema = z.object({ + currency: z.enum(Currency), + // funnel: z.enum(ELocalesPlacement), + funnel: z.string(), + locale: z.string(), + payment: z.record( + z.string(), + z.union([ + FunnelPaymentPlacementSchema.nullable(), + z.array(FunnelPaymentPlacementSchema), + ]) + ), +}); + +export const FunnelResponseSchema = z.object({ + status: z.union([z.literal("success"), z.string()]), + data: FunnelSchema, +}); + +// Type exports +export type FunnelRequest = z.infer; +export type IFunnelPaymentProperty = z.infer< + typeof FunnelPaymentPropertySchema +>; +export type IFunnelPaymentVariant = z.infer; +export type IFunnelPaymentPlacement = z.infer< + typeof FunnelPaymentPlacementSchema +>; +export type IFunnel = z.infer; +export type FunnelResponse = z.infer; diff --git a/src/hooks/lottie/useLottie.ts b/src/hooks/lottie/useLottie.ts new file mode 100644 index 0000000..504b8fc --- /dev/null +++ b/src/hooks/lottie/useLottie.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Data } from "@lottiefiles/dotlottie-react"; + +import indexedDB, { EObjectStores } from "@/shared/utils/indexedDB"; +import { ELottieKeys, lottieUrls } from "@/shared/constants/lottie"; + +interface IUseLottieProps { + preloadKey?: ELottieKeys; + loadKey?: ELottieKeys; +} + +export const useLottie = ({ preloadKey, loadKey }: IUseLottieProps) => { + const [animationData, setAnimationData] = useState(); + const [isError, setIsError] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const getAnimationDataFromLottie = async (key: ELottieKeys) => { + try { + const animation = await fetch(lottieUrls[key]); + if (!animation.ok) { + throw new Error(`HTTP error! status: ${animation.status}`); + } + const arrayBuffer = await animation.arrayBuffer(); + return arrayBuffer; + } catch (error) { + console.error("Error loading animation:", error); + setIsError(true); + return null; + } + }; + + const preload = useCallback(async (key: ELottieKeys) => { + console.log("preload", key); + + const arrayBuffer = await getAnimationDataFromLottie(key); + indexedDB.set(EObjectStores.Lottie, key, arrayBuffer); + }, []); + + const load = useCallback(async (key: ELottieKeys) => { + setIsLoading(true); + setIsError(false); + try { + const animationFromDB = await indexedDB.get( + EObjectStores.Lottie, + key + ); + if (animationFromDB) { + setAnimationData(animationFromDB); + setIsLoading(false); + return; + } + + const arrayBuffer = await getAnimationDataFromLottie(key); + if (!arrayBuffer) { + setIsLoading(false); + return; + } + + setAnimationData(arrayBuffer); + await indexedDB.set(EObjectStores.Lottie, key, arrayBuffer); + } catch (error) { + console.error("Error in load process:", error); + setIsError(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (preloadKey) { + preload(preloadKey); + } + if (loadKey) { + load(loadKey); + } + }, [load, loadKey, preload, preloadKey]); + + return useMemo( + () => ({ + animationData, + isError, + isLoading, + }), + [animationData, isError, isLoading] + ); +}; diff --git a/src/hooks/payment/usePaymentPlacement.ts b/src/hooks/payment/usePaymentPlacement.ts new file mode 100644 index 0000000..76c6553 --- /dev/null +++ b/src/hooks/payment/usePaymentPlacement.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { FunnelDefinition } from "@/lib/funnel/types"; +import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders"; +import type { IFunnelPaymentPlacement } from "@/entities/session/funnel/types"; + +interface UsePaymentPlacementArgs { + funnel: FunnelDefinition; + paymentId: string; +} + +interface UsePaymentPlacementResult { + placement: IFunnelPaymentPlacement | null; + isLoading: boolean; + error: string | null; +} + +export function usePaymentPlacement({ + funnel, + paymentId, +}: UsePaymentPlacementArgs): UsePaymentPlacementResult { + const [placement, setPlacement] = useState( + null + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const funnelKey = useMemo(() => funnel?.meta?.id ?? "", [funnel]); + + useEffect(() => { + let isMounted = true; + if (!funnelKey || !paymentId) return; + + (async () => { + try { + setIsLoading(true); + setError(null); + + const data = await loadFunnelPaymentById( + { funnel: funnelKey }, + paymentId + ); + + // Normalize union: record value can be IFunnelPaymentPlacement or IFunnelPaymentPlacement[] or null + const normalized: IFunnelPaymentPlacement | null = Array.isArray(data) + ? data[0] ?? null + : data ?? null; + + if (!isMounted) return; + setPlacement(normalized); + } catch (e) { + if (!isMounted) return; + const message = e instanceof Error ? e.message : "Failed to load payment placement"; + setError(message); + } finally { + if (isMounted) setIsLoading(false); + } + })(); + + return () => { + isMounted = false; + }; + }, [funnelKey, paymentId]); + + return { placement, isLoading, error }; +} + + diff --git a/src/lib/admin/builder/state/defaults/index.ts b/src/lib/admin/builder/state/defaults/index.ts index c0268fc..7fe5fd7 100644 --- a/src/lib/admin/builder/state/defaults/index.ts +++ b/src/lib/admin/builder/state/defaults/index.ts @@ -29,3 +29,4 @@ export { buildCouponDefaults } from "./coupon"; export { buildEmailDefaults } from "./email"; export { buildLoadersDefaults } from "./loaders"; export { buildSoulmateDefaults } from "./soulmate"; +export { buildTrialPaymentDefaults } from "./trialPayment"; diff --git a/src/lib/admin/builder/state/defaults/trialPayment.ts b/src/lib/admin/builder/state/defaults/trialPayment.ts new file mode 100644 index 0000000..3231c0d --- /dev/null +++ b/src/lib/admin/builder/state/defaults/trialPayment.ts @@ -0,0 +1,208 @@ +import { nanoid } from "nanoid"; +import type { TrialPaymentScreenDefinition } from "@/lib/funnel/types"; +import { + buildDefaultBottomActionButton, + buildDefaultNavigation, + buildDefaultTitle, + buildDefaultSubtitle, + buildDefaultImage, +} from "./blocks"; + +export function buildTrialPaymentDefaults(id?: string): TrialPaymentScreenDefinition { + return { + id: id || `trial-${nanoid(6)}`, + template: "trialPayment", + header: { show: false }, + title: buildDefaultTitle({ show: false }), + subtitle: buildDefaultSubtitle({ show: false }), + bottomActionButton: buildDefaultBottomActionButton({ show: false }), + navigation: buildDefaultNavigation(), + headerBlock: { + text: { text: "⚠️ Your sketch expires soon!" }, + timer: { text: "" }, + timerSeconds: 600, + }, + unlockYourSketch: { + title: { text: "Unlock Your Sketch" }, + subtitle: { text: "Just One Click to Reveal Your Match!" }, + image: buildDefaultImage({ src: "/trial-payment/portrait-female.jpg" }), + blur: { text: { text: "Unlock to reveal your personalized portrait" }, icon: "lock" }, + buttonText: "Get Me Soulmate Sketch", + }, + joinedToday: { count: { text: "954" }, text: { text: "Joined today" } }, + trustedByOver: { text: { text: "Trusted by over 355,000 people." } }, + findingOneGuide: { + header: { emoji: { text: "❤️" }, title: { text: "Finding the One Guide" } }, + text: { text: "You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're. You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're" }, + blur: { text: { text: "Чтобы открыть весь отчёт, нужен полный доступ." }, icon: "lock" }, + }, + tryForDays: { + title: { text: "Попробуйте в течение 7 дней!" }, + textList: { + items: [ + { text: "Receive a hand-drawn sketch of your soulmate." }, + { text: "Reveal the path with the guide." }, + { text: "Talk to live experts and get guidance." }, + { text: "Start your 7-day trial for just $1.00." }, + { text: "Cancel anytime—just 24 hours before renewal." }, + ], + }, + }, + totalPrice: { + couponContainer: { title: { text: "Coupon\nCode" }, buttonText: "SOULMATE94" }, + priceContainer: { + title: { text: "Total" }, + price: { text: "$1.00" }, + oldPrice: { text: "$14.99" }, + discount: { text: "94% discount applied" }, + }, + }, + paymentButtons: { + buttons: [ + { text: "Pay", icon: "pay" }, + { text: "Pay", icon: "google" }, + { text: "Credit or debit card", icon: "card", primary: true }, + ], + }, + moneyBackGuarantee: { + title: { text: "30-DAY MONEY-BACK GUARANTEE" }, + text: { text: "If you don't receive your soulmate sketch, we'll refund your money!" }, + }, + policy: { + text: { text: "By clicking Continue, you agree to our Terms of Use & Service and Privacy Policy. You also acknowledge that your 1 week introductory plan to Respontika, billed at $1.00, will automatically renew at $14.50 every 1 week unless canceled before the end of the trial period." }, + }, + usersPortraits: { + title: { text: "Our Users' Soulmate Portraits" }, + images: [ + { src: "/trial-payment/users-portraits/1.jpg" }, + { src: "/trial-payment/users-portraits/2.jpg" }, + { src: "/trial-payment/users-portraits/3.jpg" }, + ], + buttonText: "Get me soulmate sketch", + }, + joinedTodayWithAvatars: { + count: { text: "954" }, + text: { text: "people joined today" }, + avatars: { + images: [ + { src: "/trial-payment/avatars/1.jpg" }, + { src: "/trial-payment/avatars/2.jpg" }, + { src: "/trial-payment/avatars/3.jpg" }, + { src: "/trial-payment/avatars/4.jpg" }, + { src: "/trial-payment/avatars/5.jpg" }, + ], + }, + }, + progressToSeeSoulmate: { + title: { text: "See Your Soulmate – Just One Step Away" }, + progress: { value: 92 }, + leftText: { text: "Step 2 of 5" }, + rightText: { text: "99% Complete" }, + }, + stepsToSeeSoulmate: { + steps: [ + { title: { text: "Questions Answered" }, description: { text: "You've provided all the necessary information." }, icon: "questions", isActive: true }, + { title: { text: "Profile Analysis" }, description: { text: "Creating your perfect soulmate profile." }, icon: "profile", isActive: true }, + { title: { text: "Sketch Creation" }, description: { text: "Your personalized soulmate sketch will be created." }, icon: "sketch" }, + { title: { text: "Астрологические Идеи" }, description: { text: "Уникальные астрологические рекомендации." }, icon: "astro" }, + { title: { text: "Персонализированный чат с экспертом" }, description: { text: "Персональные советы." }, icon: "chat" }, + ], + buttonText: "Show Me My Soulmate", + }, + reviews: { + title: { text: "Loved and Trusted Worldwide" }, + items: [ + { + name: { text: "Jennifer Wilson 🇺🇸" }, + text: { text: "**“Я увидела свои ошибки… и нашла мужа”**\nПортрет сразу зацепил — было чувство, что я уже где-то его видела. Но настоящий перелом произошёл после гайда: я поняла, почему снова и снова выбирала «не тех». И самое удивительное — вскоре я познакомилась с мужчиной, который оказался точной копией того самого портрета. Сейчас он мой муж, и когда мы сравнили рисунок с его фото, сходство было просто вау." }, + avatar: { src: "/trial-payment/reviews/avatars/1.jpg" }, + portrait: { src: "/trial-payment/reviews/portraits/1.jpg" }, + photo: { src: "/trial-payment/reviews/photos/1.jpg" }, + rating: 5, + date: { text: "1 day ago" }, + }, + { + name: { text: "Amanda Davis 🇨🇦" }, + text: { text: "**“Я поняла своего партнёра лучше за один вечер, чем за несколько лет”**\nПрошла тест ради интереса — портрет нас удивил. Но настоящий прорыв случился, когда я прочитала гайд о второй половинке. Там были точные подсказки о том, как мы можем поддерживать друг друга. Цена смешная, а ценность огромная: теперь у нас меньше недопониманий и больше тепла." }, + avatar: { src: "/trial-payment/reviews/avatars/2.jpg" }, + portrait: { src: "/trial-payment/reviews/portraits/2.jpg" }, + photo: { src: "/trial-payment/reviews/photos/2.jpg" }, + rating: 5, + date: { text: "4 days ago" }, + }, + { + name: { text: "Michael Johnson 🇬🇧" }, + text: { text: "**“Увидел её лицо — и мурашки по коже”**\nКогда пришёл результат теста и показали портрет, я реально замер. Это была та самая девушка, с которой я начал встречаться пару недель назад. И гайд прямо описал, почему мы тянемся друг к другу. Честно, я не ожидал такого совпадения." }, + avatar: { src: "/trial-payment/reviews/avatars/3.jpg" }, + portrait: { src: "/trial-payment/reviews/portraits/3.jpg" }, + photo: { src: "/trial-payment/reviews/photos/3.jpg" }, + rating: 5, + date: { text: "1 week ago" }, + }, + ], + }, + stillHaveQuestions: { + title: { text: "Still have questions? We're here to help!" }, + actionButtonText: "Get me Soulmate Sketch", + contactButtonText: "Contact Support", + }, + commonQuestions: { + title: { text: "Common Questions" }, + items: [ + { + question: "When will I receive my sketch?", + answer: + "Your personalized soulmate sketch will be delivered within 24-48 hours after completing your order. You'll receive an email notification when it's ready for viewing in your account.", + }, + { + question: "How do I cancel my subscription?", + answer: + "You can cancel anytime from your account settings. Make sure to cancel at least 24 hours before the renewal date to avoid being charged.", + }, + { + question: "How accurate are the readings?", + answer: + "Our readings are based on a combination of your answers and advanced pattern analysis. While they provide valuable insights, they are intended for guidance and entertainment purposes.", + }, + { + question: "Is my data secure and private?", + answer: + "Yes. We follow strict data protection standards. Your data is encrypted and never shared with third parties without your consent.", + }, + ], + }, + footer: { + title: { text: "WIT LAB ©" }, + contacts: { + title: { text: "CONTACTS" }, + email: { href: "support@witlab.com", text: "support@witlab.com" }, + address: { text: "Wit Lab 2108 N ST STE N SACRAMENTO, CA95816, US" }, + }, + legal: { + title: { text: "LEGAL" }, + links: [ + { href: "https://witlab.com/terms", text: "Terms of Service" }, + { href: "https://witlab.com/privacy", text: "Privacy Policy" }, + { href: "https://witlab.com/refund", text: "Refund Policy" }, + ], + copyright: { + text: + "Copyright © 2025 Wit Lab™. All rights reserved. All trademarks referenced herein are the properties of their respective owners.", + }, + }, + paymentMethods: { + title: { text: "PAYMENT METHODS" }, + methods: [ + { src: "/trial-payment/payment-methods/visa.svg", alt: "visa" }, + { src: "/trial-payment/payment-methods/mastercard.svg", alt: "mastercard" }, + { src: "/trial-payment/payment-methods/discover.svg", alt: "discover" }, + { src: "/trial-payment/payment-methods/apple.svg", alt: "apple" }, + { src: "/trial-payment/payment-methods/google.svg", alt: "google" }, + { src: "/trial-payment/payment-methods/paypal.svg", alt: "paypal" }, + ], + }, + }, + }; +} + + diff --git a/src/lib/admin/builder/state/utils.ts b/src/lib/admin/builder/state/utils.ts index 0312c28..96ce21f 100644 --- a/src/lib/admin/builder/state/utils.ts +++ b/src/lib/admin/builder/state/utils.ts @@ -9,6 +9,7 @@ import { buildCouponDefaults } from "./defaults/coupon"; import { buildEmailDefaults } from "./defaults/email"; import { buildLoadersDefaults } from "./defaults/loaders"; import { buildSoulmateDefaults } from "./defaults/soulmate"; +import { buildTrialPaymentDefaults } from "./defaults/trialPayment"; /** * Marks the state as dirty if it has changed @@ -57,6 +58,8 @@ export function createScreenByTemplate( return buildLoadersDefaults(id); case "soulmate": return buildSoulmateDefaults(id); + case "trialPayment": + return buildTrialPaymentDefaults(id); default: throw new Error(`Unknown template: ${template}`); } diff --git a/src/lib/funnel/mappers.tsx b/src/lib/funnel/mappers.tsx index fe52558..35b4aa1 100644 --- a/src/lib/funnel/mappers.tsx +++ b/src/lib/funnel/mappers.tsx @@ -24,7 +24,9 @@ type TypographyAs = | "h4" | "h5" | "h6" - | "div"; + | "div" + | "li" + | "address"; interface TypographyDefaults { font?: TypographyVariant["font"]; diff --git a/src/lib/funnel/screenRenderer.tsx b/src/lib/funnel/screenRenderer.tsx index 5d9431f..f12ea81 100644 --- a/src/lib/funnel/screenRenderer.tsx +++ b/src/lib/funnel/screenRenderer.tsx @@ -11,6 +11,7 @@ import { EmailTemplate, LoadersTemplate, SoulmatePortraitTemplate, + TrialPaymentTemplate, } from "@/components/funnel/templates"; import type { ListScreenDefinition, @@ -21,6 +22,7 @@ import type { EmailScreenDefinition, LoadersScreenDefinition, SoulmatePortraitScreenDefinition, + TrialPaymentScreenDefinition, ScreenDefinition, DefaultTexts, FunnelDefinition, @@ -297,6 +299,29 @@ const TEMPLATE_REGISTRY: Record< /> ); }, + trialPayment: ({ + screen, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, + funnel, + }) => { + const trialPaymentScreen = screen as TrialPaymentScreenDefinition; + + return ( + + ); + }, }; export function renderScreen(props: ScreenRenderProps): JSX.Element { diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index 7f00112..91ce2a4 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -332,7 +332,159 @@ export interface SoulmatePortraitScreenDefinition { variants?: ScreenVariantDefinition[]; } -export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition | EmailScreenDefinition | LoadersScreenDefinition | SoulmatePortraitScreenDefinition; +// TrialPayment Screen Definition (лендинг оплаты с большим количеством секций) +export interface TrialPaymentScreenDefinition { + id: string; + template: "trialPayment"; + header?: HeaderDefinition; + // В TrialPayment заголовок и подзаголовок используются опционально сверху экрана + title?: TitleDefinition; + subtitle?: SubtitleDefinition; + // Глобальная нижняя кнопка экрана + bottomActionButton?: BottomActionButtonDefinition; + navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; + // Минимальные секции для первого шага миграции + headerBlock?: { + text?: TypographyVariant; + timer?: TypographyVariant; + timerSeconds?: number; + }; + unlockYourSketch?: { + title?: TypographyVariant; + subtitle?: TypographyVariant; + image?: ImageDefinition; + blur?: { + text?: TypographyVariant; + icon?: "lock"; + }; + buttonText?: string; + }; + joinedToday?: { + count?: TypographyVariant; + text?: TypographyVariant; + }; + trustedByOver?: { + text?: TypographyVariant; + }; + findingOneGuide?: { + header?: { + emoji?: TypographyVariant; + title?: TypographyVariant; + }; + text?: TypographyVariant; + blur?: { + text?: TypographyVariant; + icon?: "lock"; + }; + }; + tryForDays?: { + title?: TypographyVariant; + textList?: { + items: TypographyVariant[]; + }; + }; + totalPrice?: { + couponContainer: { + title?: TypographyVariant; + buttonText?: string; + }; + priceContainer?: { + title?: TypographyVariant; + price?: TypographyVariant; + oldPrice?: TypographyVariant; + discount?: TypographyVariant; + }; + }; + paymentButtons?: { + buttons: Array<{ + text: string; + icon?: "pay" | "google" | "card"; + primary?: boolean; + }>; + }; + moneyBackGuarantee?: { + title?: TypographyVariant; + text?: TypographyVariant; + }; + policy?: { + text?: TypographyVariant; + }; + usersPortraits?: { + title?: TypographyVariant; + images?: ImageDefinition[]; + buttonText?: string; + }; + joinedTodayWithAvatars?: { + count?: TypographyVariant; + text?: TypographyVariant; + avatars?: { + // minimal: только пути + images: ImageDefinition[]; + }; + }; + progressToSeeSoulmate?: { + title?: TypographyVariant; + progress?: { + value: number; + }; + leftText?: TypographyVariant; + rightText?: TypographyVariant; + }; + stepsToSeeSoulmate?: { + steps: Array<{ + title: TypographyVariant; + description: TypographyVariant; + icon?: "questions" | "profile" | "sketch" | "astro" | "chat"; + isActive?: boolean; + }>; + buttonText?: string; + }; + reviews?: { + title?: TypographyVariant; + items: Array<{ + // минимальный набор, дальше расширим + name: TypographyVariant; + text: TypographyVariant; + avatar?: ImageDefinition; + rating?: number; + date?: TypographyVariant; + portrait?: ImageDefinition; + photo?: ImageDefinition; + }>; + }; + commonQuestions?: { + title?: TypographyVariant; + items: Array<{ + question: string; + answer: string; + }>; + }; + stillHaveQuestions?: { + title?: TypographyVariant; + actionButtonText?: string; + contactButtonText?: string; + }; + footer?: { + title?: TypographyVariant; + contacts?: { + title?: TypographyVariant; + email?: { href: string; text: string }; + address?: TypographyVariant; + }; + legal?: { + title?: TypographyVariant; + links?: Array<{ href: string; text: string }>; + copyright?: TypographyVariant; + }; + paymentMethods?: { + title?: TypographyVariant; + methods?: Array<{ src: string; alt: string }>; + }; + }; +} + +export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition | EmailScreenDefinition | LoadersScreenDefinition | SoulmatePortraitScreenDefinition | TrialPaymentScreenDefinition; export interface FunnelMetaDefinition { id: string; diff --git a/src/lib/models/Funnel.ts b/src/lib/models/Funnel.ts index 441ba16..80be137 100644 --- a/src/lib/models/Funnel.ts +++ b/src/lib/models/Funnel.ts @@ -1,29 +1,29 @@ -import mongoose, { Schema, Document, Model } from 'mongoose'; -import type { FunnelDefinition } from '@/lib/funnel/types'; +import mongoose, { Schema, Document, Model } from "mongoose"; +import type { FunnelDefinition } from "@/lib/funnel/types"; // Extend FunnelDefinition with MongoDB specific fields export interface IFunnel extends Document { // Основные данные воронки funnelData: FunnelDefinition; - + // Метаданные для админки name: string; // Человеко-читаемое имя для каталога description?: string; - status: 'draft' | 'published' | 'archived'; - + status: "draft" | "published" | "archived"; + // Система версий и истории version: number; parentFunnelId?: string; // Для создания копий - + // Timestamps createdAt: Date; updatedAt: Date; publishedAt?: Date; - + // Пользовательские данные createdBy?: string; // User ID in future lastModifiedBy?: string; - + // Статистика использования usage: { totalViews: number; @@ -33,231 +33,291 @@ export interface IFunnel extends Document { } // Вложенные схемы для валидации структуры данных воронки -const TypographyVariantSchema = new Schema({ - text: { - type: String, - // НЕ required - позволяет { show: false } без текста - validate: { - validator: function(v: string | undefined): boolean { - // Если текст указан, он не может быть пустым - if (v === undefined || v === null) return true; - return v.trim().length > 0; - }, - message: 'Text field cannot be empty if provided' - } +const TypographyVariantSchema = new Schema( + { + text: { + type: String, + // НЕ required - позволяет { show: false } без текста + validate: { + validator: function(v: string | undefined): boolean { + // Если текст указан, он не может быть пустым + if (v === undefined || v === null) return true; + return v.trim().length > 0; + }, + message: 'Text field cannot be empty if provided' + } + }, + show: { type: Boolean, default: true }, // Добавляем поддержку show флага + font: { + type: String, + enum: ["manrope", "inter", "geistSans", "geistMono"], + default: "manrope", + }, + weight: { + type: String, + enum: ["regular", "medium", "semiBold", "bold", "extraBold", "black"], + default: "regular", + }, + size: { + type: String, + enum: ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"], + default: "md", + }, + align: { + type: String, + enum: ["center", "left", "right"], + default: "center", + }, + color: { + type: String, + enum: [ + "default", + "primary", + "secondary", + "destructive", + "success", + "card", + "accent", + "muted", + ], + default: "default", + }, + className: String, }, - show: { type: Boolean, default: true }, // Добавляем поддержку show флага - font: { - type: String, - enum: ['manrope', 'inter', 'geistSans', 'geistMono'], - default: 'manrope' - }, - weight: { - type: String, - enum: ['regular', 'medium', 'semiBold', 'bold', 'extraBold', 'black'], - default: 'regular' - }, - size: { - type: String, - enum: ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'], - default: 'md' - }, - align: { - type: String, - enum: ['center', 'left', 'right'], - default: 'center' - }, - color: { - type: String, - enum: ['default', 'primary', 'secondary', 'destructive', 'success', 'card', 'accent', 'muted'], - default: 'default' - }, - className: String -}, { _id: false }); + { _id: false } +); -const HeaderDefinitionSchema = new Schema({ - progress: { - current: Number, - total: Number, - value: Number, - label: String, - className: String +const HeaderDefinitionSchema = new Schema( + { + progress: { + current: Number, + total: Number, + value: Number, + label: String, + className: String, + }, + showBackButton: { type: Boolean, default: true }, + show: { type: Boolean, default: true }, }, - showBackButton: { type: Boolean, default: true }, - show: { type: Boolean, default: true } -}, { _id: false }); + { _id: false } +); -const ListOptionDefinitionSchema = new Schema({ - id: { type: String, required: true }, - label: { type: String, required: true }, - description: String, - emoji: String, - value: String, - disabled: { type: Boolean, default: false } -}, { _id: false }); - -const NavigationConditionSchema = new Schema({ - screenId: { type: String, required: true }, - conditionType: { type: String, enum: ['options', 'values'], default: 'options' }, - operator: { - type: String, - enum: ['includesAny', 'includesAll', 'includesExactly', 'equals'], - default: 'includesAny' +const ListOptionDefinitionSchema = new Schema( + { + id: { type: String, required: true }, + label: { type: String, required: true }, + description: String, + emoji: String, + value: String, + disabled: { type: Boolean, default: false }, }, - optionIds: [{ type: String }], - values: [{ type: String }], -}, { _id: false }); + { _id: false } +); -const NavigationRuleSchema = new Schema({ - conditions: [NavigationConditionSchema], - nextScreenId: { type: String, required: true } -}, { _id: false }); - -const NavigationDefinitionSchema = new Schema({ - rules: [NavigationRuleSchema], - defaultNextScreenId: String, - isEndScreen: { type: Boolean, default: false }, -}, { _id: false }); - -const BottomActionButtonSchema = new Schema({ - show: { type: Boolean, default: true }, - text: String, - cornerRadius: { - type: String, - enum: ['3xl', 'full'], - default: '3xl' +const NavigationConditionSchema = new Schema( + { + screenId: { type: String, required: true }, + conditionType: { + type: String, + enum: ["options", "values"], + default: "options", + }, + operator: { + type: String, + enum: ["includesAny", "includesAll", "includesExactly", "equals"], + default: "includesAny", + }, + optionIds: [{ type: String }], + values: [{ type: String }], }, - showPrivacyTermsConsent: { type: Boolean, default: false }, -}, { _id: false }); + { _id: false } +); + +const NavigationRuleSchema = new Schema( + { + conditions: [NavigationConditionSchema], + nextScreenId: { type: String, required: true }, + }, + { _id: false } +); + +const NavigationDefinitionSchema = new Schema( + { + rules: [NavigationRuleSchema], + defaultNextScreenId: String, + isEndScreen: { type: Boolean, default: false }, + }, + { _id: false } +); + +const BottomActionButtonSchema = new Schema( + { + show: { type: Boolean, default: true }, + text: String, + cornerRadius: { + type: String, + enum: ["3xl", "full"], + default: "3xl", + }, + showPrivacyTermsConsent: { type: Boolean, default: false }, + }, + { _id: false } +); // Схемы для различных типов экранов (используем Mixed для гибкости) -const ScreenDefinitionSchema = new Schema({ - id: { type: String, required: true }, - template: { - type: String, - enum: ['info', 'date', 'coupon', 'form', 'list', 'email', 'loaders', 'soulmate'], - required: true - }, - header: HeaderDefinitionSchema, - title: { type: TypographyVariantSchema, required: true }, - subtitle: TypographyVariantSchema, - bottomActionButton: BottomActionButtonSchema, - navigation: NavigationDefinitionSchema, - - // Специфичные для template поля (используем Mixed для максимальной гибкости) - description: TypographyVariantSchema, // info, soulmate - icon: Schema.Types.Mixed, // info - variables: [Schema.Types.Mixed], // info - динамические переменные для подстановки в текст - dateInput: Schema.Types.Mixed, // date - infoMessage: Schema.Types.Mixed, // date - coupon: Schema.Types.Mixed, // coupon - copiedMessage: String, // coupon - fields: [Schema.Types.Mixed], // form - validationMessages: Schema.Types.Mixed, // form - list: { // list - selectionType: { - type: String, - enum: ['single', 'multi'] +const ScreenDefinitionSchema = new Schema( + { + id: { type: String, required: true }, + template: { + type: String, + enum: [ + "info", + "date", + "coupon", + "form", + "list", + "email", + "loaders", + "soulmate", + "trialPayment", + ], + required: true, }, - options: [ListOptionDefinitionSchema] - }, - emailInput: Schema.Types.Mixed, // email - image: Schema.Types.Mixed, // email, soulmate - // loaders - progressbars: Schema.Types.Mixed, // preferred key used by runtime/templates - variants: [Schema.Types.Mixed] // variants для всех типов -}, { _id: false }); + header: HeaderDefinitionSchema, + title: { type: TypographyVariantSchema, required: true }, + subtitle: TypographyVariantSchema, + bottomActionButton: BottomActionButtonSchema, + navigation: NavigationDefinitionSchema, -const FunnelMetaSchema = new Schema({ - id: { type: String, required: true }, - version: String, - title: String, - description: String, - firstScreenId: String -}, { _id: false }); - -const DefaultTextsSchema = new Schema({ - nextButton: { type: String, default: 'Next' }, - privacyBanner: { type: String }, -}, { _id: false }); - -const FunnelDataSchema = new Schema({ - meta: { type: FunnelMetaSchema, required: true }, - defaultTexts: DefaultTextsSchema, - screens: [ScreenDefinitionSchema] -}, { _id: false }); - -const FunnelSchema = new Schema({ - // Основные данные воронки - funnelData: { - type: FunnelDataSchema, - required: true, - validate: { - validator: function(v: FunnelDefinition): boolean { - // Базовая валидация структуры - return Boolean(v?.meta && v.meta.id && Array.isArray(v.screens)); + // Специфичные для template поля (используем Mixed для максимальной гибкости) + description: TypographyVariantSchema, // info, soulmate + icon: Schema.Types.Mixed, // info + variables: [Schema.Types.Mixed], // info - динамические переменные для подстановки в текст + dateInput: Schema.Types.Mixed, // date + infoMessage: Schema.Types.Mixed, // date + coupon: Schema.Types.Mixed, // coupon + copiedMessage: String, // coupon + fields: [Schema.Types.Mixed], // form + validationMessages: Schema.Types.Mixed, // form + list: { + // list + selectionType: { + type: String, + enum: ["single", "multi"], }, - message: 'Invalid funnel data structure' - } + options: [ListOptionDefinitionSchema], + }, + emailInput: Schema.Types.Mixed, // email + image: Schema.Types.Mixed, // email, soulmate + // loaders + progressbars: Schema.Types.Mixed, // preferred key used by runtime/templates + variants: [Schema.Types.Mixed], // variants для всех типов }, - - // Метаданные для админки - name: { - type: String, - required: true, - trim: true, - maxlength: 200 + { _id: false, strict: false } +); + +const FunnelMetaSchema = new Schema( + { + id: { type: String, required: true }, + version: String, + title: String, + description: String, + firstScreenId: String, }, - description: { - type: String, - trim: true, - maxlength: 1000 + { _id: false } +); + +const DefaultTextsSchema = new Schema( + { + nextButton: { type: String, default: "Next" }, + privacyBanner: { type: String }, }, - status: { - type: String, - enum: ['draft', 'published', 'archived'], - default: 'draft', - required: true + { _id: false } +); + +const FunnelDataSchema = new Schema( + { + meta: { type: FunnelMetaSchema, required: true }, + defaultTexts: DefaultTextsSchema, + screens: [ScreenDefinitionSchema], }, - - // Система версий - version: { - type: Number, - default: 1, - min: 1 + { _id: false } +); + +const FunnelSchema = new Schema( + { + // Основные данные воронки + funnelData: { + type: FunnelDataSchema, + required: true, + validate: { + validator: function (v: FunnelDefinition): boolean { + // Базовая валидация структуры + return Boolean(v?.meta && v.meta.id && Array.isArray(v.screens)); + }, + message: "Invalid funnel data structure", + }, + }, + + // Метаданные для админки + name: { + type: String, + required: true, + trim: true, + maxlength: 200, + }, + description: { + type: String, + trim: true, + maxlength: 1000, + }, + status: { + type: String, + enum: ["draft", "published", "archived"], + default: "draft", + required: true, + }, + + // Система версий + version: { + type: Number, + default: 1, + min: 1, + }, + parentFunnelId: { + type: Schema.Types.ObjectId, + ref: "Funnel", + }, + + // Пользовательские данные + createdBy: String, // В будущем можно заменить на ObjectId ref на User + lastModifiedBy: String, + + // Статистика + usage: { + totalViews: { type: Number, default: 0, min: 0 }, + totalCompletions: { type: Number, default: 0, min: 0 }, + lastUsed: Date, + }, + + // Timestamps + publishedAt: Date, }, - parentFunnelId: { - type: Schema.Types.ObjectId, - ref: 'Funnel' - }, - - // Пользовательские данные - createdBy: String, // В будущем можно заменить на ObjectId ref на User - lastModifiedBy: String, - - // Статистика - usage: { - totalViews: { type: Number, default: 0, min: 0 }, - totalCompletions: { type: Number, default: 0, min: 0 }, - lastUsed: Date - }, - - // Timestamps - publishedAt: Date -}, { - timestamps: true, // Автоматически добавляет createdAt и updatedAt - collection: 'funnels' -}); + { + timestamps: true, // Автоматически добавляет createdAt и updatedAt + collection: "funnels", + } +); // Индексы для производительности -FunnelSchema.index({ 'funnelData.meta.id': 1 }); // Для поиска по ID воронки +FunnelSchema.index({ "funnelData.meta.id": 1 }); // Для поиска по ID воронки FunnelSchema.index({ status: 1, updatedAt: -1 }); // Для каталога воронок -FunnelSchema.index({ name: 'text', description: 'text' }); // Для поиска по тексту +FunnelSchema.index({ name: "text", description: "text" }); // Для поиска по тексту FunnelSchema.index({ createdBy: 1 }); // Для фильтра по автору -FunnelSchema.index({ 'usage.lastUsed': -1 }); // Для сортировки по использованию +FunnelSchema.index({ "usage.lastUsed": -1 }); // Для сортировки по использованию // Методы модели -FunnelSchema.methods.toPublicJSON = function(this: IFunnel) { +FunnelSchema.methods.toPublicJSON = function (this: IFunnel) { return { _id: this._id, name: this.name, @@ -268,14 +328,17 @@ FunnelSchema.methods.toPublicJSON = function(this: IFunnel) { updatedAt: this.updatedAt, publishedAt: this.publishedAt, usage: this.usage, - funnelData: this.funnelData + funnelData: this.funnelData, }; }; -FunnelSchema.methods.incrementUsage = function(this: IFunnel, type: 'view' | 'completion') { - if (type === 'view') { +FunnelSchema.methods.incrementUsage = function ( + this: IFunnel, + type: "view" | "completion" +) { + if (type === "view") { this.usage.totalViews += 1; - } else if (type === 'completion') { + } else if (type === "completion") { this.usage.totalCompletions += 1; } this.usage.lastUsed = new Date(); @@ -283,35 +346,45 @@ FunnelSchema.methods.incrementUsage = function(this: IFunnel, type: 'view' | 'co }; // Статические методы -FunnelSchema.statics.findPublished = function() { - return this.find({ status: 'published' }).sort({ publishedAt: -1 }); +FunnelSchema.statics.findPublished = function () { + return this.find({ status: "published" }).sort({ publishedAt: -1 }); }; -FunnelSchema.statics.findByFunnelId = function(funnelId: string) { - return this.findOne({ 'funnelData.meta.id': funnelId }); +FunnelSchema.statics.findByFunnelId = function (funnelId: string) { + return this.findOne({ "funnelData.meta.id": funnelId }); }; // Pre-save хуки -FunnelSchema.pre('save', function(next) { +FunnelSchema.pre("save", function (next) { // Автоматически устанавливаем publishedAt при первой публикации - if (this.status === 'published' && !this.publishedAt) { + if (this.status === "published" && !this.publishedAt) { this.publishedAt = new Date(); } - + // Валидация: firstScreenId должен существовать в screens if (this.funnelData.meta.firstScreenId) { const firstScreenExists = this.funnelData.screens.some( - screen => screen.id === this.funnelData.meta.firstScreenId + (screen) => screen.id === this.funnelData.meta.firstScreenId ); if (!firstScreenExists) { - return next(new Error('firstScreenId must reference an existing screen')); + return next(new Error("firstScreenId must reference an existing screen")); } } - + next(); }); // Экспорт модели с проверкой на существование -const FunnelModel: Model = mongoose.models.Funnel || mongoose.model('Funnel', FunnelSchema); +// В dev окружении пересоздаём модель, чтобы подтянуть изменения схемы (enums и т.п.) +if (process.env.NODE_ENV !== "production" && typeof mongoose.models.Funnel !== "undefined") { + try { + (mongoose as unknown as { deleteModel: (name: string) => void }).deleteModel("Funnel"); + } catch { + // no-op + } +} + +const FunnelModel: Model = + mongoose.models.Funnel || mongoose.model("Funnel", FunnelSchema); export default FunnelModel; diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts index 2df4c7d..8feda64 100644 --- a/src/shared/constants/api-routes.ts +++ b/src/shared/constants/api-routes.ts @@ -11,4 +11,7 @@ const createRoute = ( export const API_ROUTES = { session: (id?: string) => createRoute(["session", id], ROOT_ROUTE_V2), authorization: () => createRoute(["users", "auth"]), + paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2), + paymentSingleCheckout: () => createRoute(["payment", "checkout"]), + funnel: () => createRoute(["session", "funnel"], ROOT_ROUTE_V2), }; diff --git a/src/shared/constants/client-routes.ts b/src/shared/constants/client-routes.ts new file mode 100644 index 0000000..50cce37 --- /dev/null +++ b/src/shared/constants/client-routes.ts @@ -0,0 +1,22 @@ +const ROOT_ROUTE = "/"; + +const createRoute = ( + segments: Array, + queryParams?: Record +): string => { + const url = ROOT_ROUTE + segments.filter(Boolean).join("/"); + if (queryParams) { + return url + "?" + new URLSearchParams(queryParams).toString(); + } + return url; +}; + +export const ROUTES = { + home: () => createRoute([]), + + // Payment + payment: (queryParams?: Record) => + createRoute(["payment"], queryParams), + paymentSuccess: () => createRoute(["payment", "success"]), + paymentFailed: () => createRoute(["payment", "failed"]), +}; diff --git a/src/shared/constants/lottie.ts b/src/shared/constants/lottie.ts new file mode 100644 index 0000000..0ba5723 --- /dev/null +++ b/src/shared/constants/lottie.ts @@ -0,0 +1,77 @@ +export enum ELottieKeys { + goal = "goal", + magnifyingGlassAndPlanet = "magnifyingGlassAndPlanet", + scalesNeutral = "scalesNeutral", + scalesHead = "scalesHead", + scalesHeart = "scalesHeart", + compass = "compass", + handWithStars = "handWithStars", + key = "key", + cloudAndStars = "cloudAndStars", + darts = "darts", + umbrella = "umbrella", + hourglass = "hourglass", + lightBulb = "lightBulb", + sun = "sun", + handSymbols = "handSymbols", + scalesNeutralPalmistry = "scalesNeutralPalmistry", + scalesHeadPalmistry = "scalesHeadPalmistry", + scalesHeartPalmistry = "scalesHeartPalmistry", + letScan = "letScan", + letScanDark = "letScanDark", + scannedPhoto = "scannedPhoto", + loaderCheckMark = "loaderCheckMark", + loaderCheckMark2 = "loaderCheckMark2", + confetti = "confetti", +} + +export const lottieUrls = { + [ELottieKeys.goal]: + "https://lottie.host/a86e1531-7028-4688-a836-ea9d71dafa3b/Pe5G1g9s9L.lottie", + [ELottieKeys.magnifyingGlassAndPlanet]: + "https://lottie.host/beaa1dc6-cd60-4bbe-a222-c039b04c630f/ZktoTHROIW.lottie", + [ELottieKeys.scalesNeutral]: + "https://lottie.host/ddd2cb46-d62f-4808-a10d-1dd5ce8d42d2/6hgUBBGjaJ.lottie", + [ELottieKeys.scalesHead]: + "https://lottie.host/19fe41d7-d26f-431c-b063-8e123ce3d57a/HiucMMidQT.lottie", + [ELottieKeys.scalesHeart]: + "https://lottie.host/9eb3f7a1-83c2-495a-9342-c234bfebc40c/0T90l2xSWl.lottie", + [ELottieKeys.compass]: + "https://lottie.host/15b235d7-b8c9-487f-8d65-73143afc9ecc/czTjX9Lwp1.lottie", + [ELottieKeys.handWithStars]: + "https://lottie.host/25105d46-cc0a-4f76-9ad0-5e64e3eb0e52/OenfEsMruV.lottie", + [ELottieKeys.key]: + "https://lottie.host/a80ec293-6f3d-4d21-a19e-9dfb40b86a14/clQys1OEAL.lottie", + [ELottieKeys.cloudAndStars]: + "https://lottie.host/6010e02c-da90-4089-982c-177f3b5dbc05/fXkYv6hGPc.lottie", + [ELottieKeys.darts]: + "https://lottie.host/c3856d09-bfe9-44de-8712-f935f5deed67/rtD0j4YfnN.lottie", + [ELottieKeys.umbrella]: + "https://lottie.host/e353e80c-fd4a-4eca-a930-d9bf923466e0/G4sxbtkhIA.lottie", + [ELottieKeys.hourglass]: + "https://lottie.host/c1b52c33-1a3c-4759-9c5d-090ed2a62c77/IqHW4RCqVH.lottie", + [ELottieKeys.lightBulb]: + "https://lottie.host/07e33753-d13c-4469-ad33-26e57017b0ec/qMVfYwwLqs.lottie", + [ELottieKeys.sun]: + "https://lottie.host/8ae9682d-93d3-4988-8745-e7134daed217/lZG1RZgqaP.lottie", + [ELottieKeys.handSymbols]: + "https://lottie.host/ae56bb19-96e6-4147-ac94-6c9a5a24bd9d/bDBUSdzN5e.lottie", + [ELottieKeys.scalesNeutralPalmistry]: + "https://lottie.host/9027e5a7-d5e8-4e60-b097-ba4bf099b433/UsCKDjKVUr.lottie", + [ELottieKeys.scalesHeadPalmistry]: + "https://lottie.host/d16336c4-2622-48f8-b361-8d9d50b3c8a6/wWSM7JMCHu.lottie", + [ELottieKeys.scalesHeartPalmistry]: + "https://lottie.host/fa931c2d-07f5-4c57-a4bb-8302b411ecca/zy9ag3MyMe.lottie", + [ELottieKeys.letScan]: + "https://lottie.host/77c3c34b-4c1e-4cab-87f4-40d7534fea3d/wMg1wqtSS6.lottie", //"https://lottie.host/f87184ec-aa5e-4cf4-82a5-9ab5e60c22d5/qpgweCSCtn.lottie", + [ELottieKeys.letScanDark]: + "https://lottie.host/71623941-9182-4d58-8a1d-cb05cc5732ad/fEXKgPZQYq.lottie", //"https://lottie.host/c890243e-c61a-4e76-8b93-e8d24b25dd97/leetT4srXt.lottie", + [ELottieKeys.scannedPhoto]: + "https://lottie.host/0570b1a3-2441-486e-909b-bc2a6ceb692b/KAHTUVUb8C.lottie", + [ELottieKeys.loaderCheckMark]: + "https://lottie.host/c29ba802-17b4-4ddb-a733-5385b91394f2/qnFaLSA5p3.lottie", + [ELottieKeys.loaderCheckMark2]: + "https://lottie.host/6e249251-0469-43b2-9582-822e8f701ce2/sjRwaq20Dr.lottie", + [ELottieKeys.confetti]: + "https://lottie.host/ee592a75-4a56-4d3b-b671-b0695715a021/NYbdrg8EEb.lottie", +}; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..db54500 --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1,11 @@ +export type ActionResponse = { + data: T | null; + error: string | null; +}; + +export enum Currency { + USD = "USD", + EUR = "EUR", + usd = "usd", + eur = "eur", +} diff --git a/src/shared/utils/indexedDB/index.ts b/src/shared/utils/indexedDB/index.ts new file mode 100644 index 0000000..86c10e5 --- /dev/null +++ b/src/shared/utils/indexedDB/index.ts @@ -0,0 +1,52 @@ +"use client"; + +import { IDBPDatabase, openDB } from "idb"; + +export enum EObjectStores { + Lottie = "lottie", +} + +const objectStores: EObjectStores[] = [EObjectStores.Lottie]; + +let dbPromise: Promise | null = null; + +function getDB() { + if (typeof window === "undefined") { + throw new Error("IndexedDB is unavailable on the server."); + } + if (!dbPromise) { + dbPromise = openDB("wit-store", 1, { + upgrade(db) { + db.createObjectStore("lottie"); + }, + }); + } + return dbPromise; +} + +async function get( + store: EObjectStores, + key: string +): Promise { + return (await getDB()).get(store, key); +} +async function set(store: EObjectStores, key: string, val: T) { + return (await getDB()).put(store, val, key); +} +async function del(store: EObjectStores, key: string) { + return (await getDB()).delete(store, key); +} +async function clear() { + return Promise.all(objectStores.map(async s => (await getDB()).clear(s))); +} +async function keys() { + return Promise.all( + objectStores.map(async s => ({ + objectStore: s, + keys: await (await getDB()).getAllKeys(s), + })) + ); +} + +const indexedDBService = { get, set, del, clear, keys }; +export default indexedDBService;