From ace03937db8f6522ff6603dcfb8ea9eb4162a83a Mon Sep 17 00:00:00 2001 From: gofnnp Date: Sat, 4 Oct 2025 20:56:37 +0400 Subject: [PATCH 01/35] session add session --- next.config.ts | 3 + src/components/funnel/FunnelRuntime.tsx | 105 +++++-- src/components/ui/accordion.stories.tsx | 249 ++++++++------- src/entities/session/actions.ts | 34 ++ src/entities/session/types.ts | 45 +++ src/entities/user/types.ts | 22 ++ src/hooks/session/useSession.ts | 121 +++++++ src/lib/env.ts | 43 ++- src/shared/api/httpClient.ts | 215 +++++++++++++ src/shared/auth/clientToken.ts | 24 ++ src/shared/auth/token.ts | 17 + src/shared/constants/api-routes.ts | 13 + src/shared/utils/locales.ts | 2 + src/shared/utils/logger.ts | 399 ++++++++++++++++++++++++ src/shared/utils/url.ts | 19 ++ 15 files changed, 1158 insertions(+), 153 deletions(-) create mode 100644 src/entities/session/actions.ts create mode 100644 src/entities/session/types.ts create mode 100644 src/entities/user/types.ts create mode 100644 src/hooks/session/useSession.ts create mode 100644 src/shared/api/httpClient.ts create mode 100644 src/shared/auth/clientToken.ts create mode 100644 src/shared/auth/token.ts create mode 100644 src/shared/constants/api-routes.ts create mode 100644 src/shared/utils/locales.ts create mode 100644 src/shared/utils/logger.ts create mode 100644 src/shared/utils/url.ts diff --git a/next.config.ts b/next.config.ts index c8c9573..abc2f9f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,6 +12,9 @@ const nextConfig: NextConfig = { env: { FUNNEL_BUILD_VARIANT: buildVariant, NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: buildVariant, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + DEV_LOGGER_SERVER_ENABLED: process.env.DEV_LOGGER_SERVER_ENABLED, + NEXT_PUBLIC_AUTH_REDIRECT_URL: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL, }, }; diff --git a/src/components/funnel/FunnelRuntime.tsx b/src/components/funnel/FunnelRuntime.tsx index 2e2844d..474ff4f 100644 --- a/src/components/funnel/FunnelRuntime.tsx +++ b/src/components/funnel/FunnelRuntime.tsx @@ -14,30 +14,38 @@ import type { DateScreenDefinition, } from "@/lib/funnel/types"; import { getZodiacSign } from "@/lib/funnel/zodiac"; +import { useSession } from "@/hooks/session/useSession"; // Функция для оценки длины пути пользователя на основе текущих ответов -function estimatePathLength(funnel: FunnelDefinition, answers: FunnelAnswers): number { +function estimatePathLength( + funnel: FunnelDefinition, + answers: FunnelAnswers +): number { const visited = new Set(); let currentScreenId = funnel.meta.firstScreenId || funnel.screens[0]?.id; - + // Симулируем прохождение воронки с текущими ответами while (currentScreenId && !visited.has(currentScreenId)) { visited.add(currentScreenId); - + const currentScreen = funnel.screens.find((s) => s.id === currentScreenId); if (!currentScreen) break; const resolvedScreen = resolveScreenVariant(currentScreen, answers); - const nextScreenId = resolveNextScreenId(resolvedScreen, answers, funnel.screens); - + const nextScreenId = resolveNextScreenId( + resolvedScreen, + answers, + funnel.screens + ); + // Если достигли конца или зацикливание if (!nextScreenId || visited.has(nextScreenId)) { break; } - + currentScreenId = nextScreenId; } - + return visited.size; } @@ -48,6 +56,9 @@ interface FunnelRuntimeProps { export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { const router = useRouter(); + const { createSession, updateSession } = useSession({ + funnelId: funnel.meta.id, + }); const { answers, registerScreen, setAnswers, history } = useFunnelRuntime( funnel.meta.id ); @@ -71,6 +82,17 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { const selectedOptionIds = answers[currentScreen.id] ?? []; + useEffect(() => { + createSession(); + }, [createSession]); + + // useEffect(() => { + // // updateSession({ + // // answers: answers, + // // }); + // console.log("answers", answers); + // }, [answers]); + useEffect(() => { registerScreen(currentScreen.id); }, [currentScreen.id, registerScreen]); @@ -108,7 +130,21 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { }; const handleContinue = () => { - const nextScreenId = resolveNextScreenId(currentScreen, answers, funnel.screens); + console.log({ + [currentScreen.id]: answers[currentScreen.id], + }); + if (answers[currentScreen.id]) { + updateSession({ + answers: { + [currentScreen.id]: answers[currentScreen.id], + }, + }); + } + const nextScreenId = resolveNextScreenId( + currentScreen, + answers, + funnel.screens + ); goToScreen(nextScreenId); }; @@ -118,25 +154,29 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { prevSelectedIds.length !== ids.length || prevSelectedIds.some((value, index) => value !== ids[index]); - // Check if this is a single selection list without action button - const shouldAutoAdvance = currentScreen.template === "list" && (() => { - const listScreen = currentScreen as ListScreenDefinition; - const selectionType = listScreen.list.selectionType; - - // Простая логика: автопереход если single selection и кнопка отключена - const bottomActionButton = listScreen.bottomActionButton; - const isButtonExplicitlyDisabled = bottomActionButton?.show === false; - - return selectionType === "single" && isButtonExplicitlyDisabled && ids.length > 0; - })(); + const shouldAutoAdvance = + currentScreen.template === "list" && + (() => { + const listScreen = currentScreen as ListScreenDefinition; + const selectionType = listScreen.list.selectionType; + + // Простая логика: автопереход если single selection и кнопка отключена + const bottomActionButton = listScreen.bottomActionButton; + const isButtonExplicitlyDisabled = bottomActionButton?.show === false; + + return ( + selectionType === "single" && + isButtonExplicitlyDisabled && + ids.length > 0 + ); + })(); // ПРАВИЛЬНОЕ РЕШЕНИЕ: Автопереход ТОЛЬКО при изменении значения - // Это исключает автопереход при возврате назад, когда компоненты + // Это исключает автопереход при возврате назад, когда компоненты // восстанавливают состояние и вызывают callbacks без реального изменения const shouldProceed = hasChanged; - - + if (!shouldProceed) { return; // Блокируем программные вызовы useEffect без изменений } @@ -165,9 +205,10 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { const [monthValue, dayValue] = ids; const month = parseInt(monthValue ?? "", 10); const day = parseInt(dayValue ?? "", 10); - const zodiac = Number.isNaN(month) || Number.isNaN(day) - ? null - : getZodiacSign(month, day); + const zodiac = + Number.isNaN(month) || Number.isNaN(day) + ? null + : getZodiacSign(month, day); if (zodiac) { setAnswers(storageKey, [zodiac]); @@ -182,7 +223,19 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { // Auto-advance for single selection without action button if (shouldAutoAdvance) { - const nextScreenId = resolveNextScreenId(currentScreen, nextAnswers, funnel.screens); + console.log({ + [currentScreen.id]: ids, + }); + updateSession({ + answers: { + [currentScreen.id]: ids, + }, + }); + const nextScreenId = resolveNextScreenId( + currentScreen, + nextAnswers, + funnel.screens + ); goToScreen(nextScreenId); } }; diff --git a/src/components/ui/accordion.stories.tsx b/src/components/ui/accordion.stories.tsx index 2bb0361..9004991 100644 --- a/src/components/ui/accordion.stories.tsx +++ b/src/components/ui/accordion.stories.tsx @@ -1,5 +1,10 @@ -import { Meta, StoryObj } from "@storybook/nextjs-vite"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion"; +import { Meta } from "@storybook/nextjs-vite"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "./accordion"; /** Reusable Accordion Component */ const meta: Meta = { @@ -12,7 +17,7 @@ const meta: Meta = { args: { type: "single", collapsible: true, - }, + } satisfies React.ComponentProps, argTypes: { type: { control: { type: "select" }, @@ -22,123 +27,139 @@ const meta: Meta = { control: { type: "boolean" }, }, }, + render: (args) => ( + + + Is it accessible? + + Yes. It adheres to the WAI-ARIA design pattern. + + + + Is it styled? + + Yes. It comes with default styles that matches the other + components' aesthetic. + + + + Is it animated? + + Yes. It's animated by default, but you can disable it if you + prefer. + + + + ), }; export default meta; -type Story = StoryObj; +// type Story = StoryObj; export const Default = { - render: (args) => ( - - - Is it accessible? - - Yes. It adheres to the WAI-ARIA design pattern. - - - - Is it styled? - - Yes. It comes with default styles that matches the other components' aesthetic. - - - - Is it animated? - - Yes. It's animated by default, but you can disable it if you prefer. - - - - ), -} satisfies Story; + // args: {}, +}; -export const Multiple = { - args: { - type: "multiple", - }, - render: (args) => ( - - - Is it accessible? - - Yes. It adheres to the WAI-ARIA design pattern. - - - - Is it styled? - - Yes. It comes with default styles that matches the other components' aesthetic. - - - - Is it animated? - - Yes. It's animated by default, but you can disable it if you prefer. - - - - ), -} satisfies Story; +// export const Multiple = { +// args: { +// type: "multiple", +// }, +// render: (args) => ( +// +// +// Is it accessible? +// +// Yes. It adheres to the WAI-ARIA design pattern. +// +// +// +// Is it styled? +// +// Yes. It comes with default styles that matches the other +// components' aesthetic. +// +// +// +// Is it animated? +// +// Yes. It's animated by default, but you can disable it if you +// prefer. +// +// +// +// ), +// } satisfies Story; -export const SingleItem = { - render: (args) => ( - - - What is this component? - - This is an accordion component built with Radix UI primitives. It provides a collapsible content area that can be expanded or collapsed by clicking the trigger. - - - - ), -} satisfies Story; +// export const SingleItem = { +// render: (args) => ( +// +// +// What is this component? +// +// This is an accordion component built with Radix UI primitives. It +// provides a collapsible content area that can be expanded or collapsed +// by clicking the trigger. +// +// +// +// ), +// } satisfies Story; -export const LongContent = { - render: (args) => ( - - - What are the features? - -
-

This accordion component includes:

-
    -
  • Accessibility support with WAI-ARIA patterns
  • -
  • Smooth animations for opening and closing
  • -
  • Keyboard navigation support
  • -
  • Customizable styling with Tailwind CSS
  • -
  • Single or multiple item selection modes
  • -
  • Collapsible functionality
  • -
-

- The component is built using Radix UI primitives, ensuring excellent accessibility and user experience across different devices and assistive technologies. -

-
-
-
-
- ), -} satisfies Story; - -export const CustomStyling = { - render: (args) => ( - - - - Custom Styled Item - - - This accordion item has custom styling with blue colors and enhanced spacing. - - - - - Another Custom Item - - - Each item can have its own custom styling while maintaining the accordion functionality. - - - - ), -} satisfies Story; +// export const LongContent = { +// render: (args) => ( +// +// +// What are the features? +// +//
+//

This accordion component includes:

+//
    +//
  • Accessibility support with WAI-ARIA patterns
  • +//
  • Smooth animations for opening and closing
  • +//
  • Keyboard navigation support
  • +//
  • Customizable styling with Tailwind CSS
  • +//
  • Single or multiple item selection modes
  • +//
  • Collapsible functionality
  • +//
+//

+// The component is built using Radix UI primitives, ensuring +// excellent accessibility and user experience across different +// devices and assistive technologies. +//

+//
+//
+//
+//
+// ), +// } satisfies Story; +// export const CustomStyling = { +// render: (args) => ( +// +// +// +// Custom Styled Item +// +// +// This accordion item has custom styling with blue colors and enhanced +// spacing. +// +// +// +// +// Another Custom Item +// +// +// Each item can have its own custom styling while maintaining the +// accordion functionality. +// +// +// +// ), +// } satisfies Story; diff --git a/src/entities/session/actions.ts b/src/entities/session/actions.ts new file mode 100644 index 0000000..92d7095 --- /dev/null +++ b/src/entities/session/actions.ts @@ -0,0 +1,34 @@ +import { http } from "@/shared/api/httpClient"; +import { + CreateSessionResponseSchema, + ICreateSessionRequest, + ICreateSessionResponse, + IUpdateSessionRequest, + IUpdateSessionResponse, + UpdateSessionResponseSchema, +} from "./types"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +export const createSession = async ( + payload: ICreateSessionRequest +): Promise => { + return http.post(API_ROUTES.session(), payload, { + tags: ["session", "create"], + schema: CreateSessionResponseSchema, + revalidate: 0, + }); +}; + +export const updateSession = async ( + payload: IUpdateSessionRequest +): Promise => { + return http.patch( + API_ROUTES.session(payload.sessionId), + payload, + { + tags: ["session", "update"], + schema: UpdateSessionResponseSchema, + revalidate: 0, + } + ); +}; diff --git a/src/entities/session/types.ts b/src/entities/session/types.ts new file mode 100644 index 0000000..293147c --- /dev/null +++ b/src/entities/session/types.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { CreateAuthorizeUserSchema } from "../user/types"; + +export const CreateSessionRequestSchema = z.object({ + feature: z.string().optional(), + locale: z.string(), + timezone: z.string(), + source: z.string(), + sign: z.boolean(), + signDate: z.string().optional(), + utm: z.record(z.string(), z.string()).optional(), + domain: z.string(), +}); + +export const UpdateSessionRequestSchema = z.object({ + sessionId: z.string(), + data: z.object({ + feature: z.string().optional(), + profile: CreateAuthorizeUserSchema.optional(), + partner: CreateAuthorizeUserSchema.omit({ + relationship_status: true, + }).optional(), + answers: z.record(z.string(), z.unknown()).optional(), + cookies: z.record(z.string(), z.string()).optional(), + }), +}); + +export const CreateSessionResponseSchema = z.object({ + status: z.string(), + sessionId: z.string(), +}); + +export const UpdateSessionResponseSchema = z.object({ + status: z.string(), + message: z.string(), +}); + +export type ICreateSessionRequest = z.infer; +export type IUpdateSessionRequest = z.infer; +export type ICreateSessionResponse = z.infer< + typeof CreateSessionResponseSchema +>; +export type IUpdateSessionResponse = z.infer< + typeof UpdateSessionResponseSchema +>; diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts new file mode 100644 index 0000000..f60f2b3 --- /dev/null +++ b/src/entities/user/types.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +export const GenderSchema = z.enum(["male", "female", "other"]); + +export const RelationshipStatusSchema = z.enum([ + "single", + "relationship", + "married", + "complicated", + "other", +]); + +export const CreateAuthorizeUserSchema = z.object({ + name: z.string(), + birthdate: z.string().optional(), + gender: GenderSchema, + birthplace: z.object({ + address: z.string().optional(), + coords: z.string().optional(), + }), + relationship_status: RelationshipStatusSchema, +}); diff --git a/src/hooks/session/useSession.ts b/src/hooks/session/useSession.ts new file mode 100644 index 0000000..6c00529 --- /dev/null +++ b/src/hooks/session/useSession.ts @@ -0,0 +1,121 @@ +import { + ICreateSessionResponse, + IUpdateSessionRequest, +} from "@/entities/session/types"; +import { + createSession as createSessionApi, + updateSession as updateSessionApi, +} from "@/entities/session/actions"; +import { getClientTimezone } from "@/shared/utils/locales"; +import { parseQueryParams } from "@/shared/utils/url"; +import { useCallback, useMemo, useState } from "react"; + +// TODO +const language = "en"; + +interface IUseSessionProps { + funnelId: string; +} + +export const useSession = ({ funnelId }: IUseSessionProps) => { + const localStorageKey = `${funnelId}_sessionId`; + const sessionId = + typeof window === "undefined" ? "" : localStorage.getItem(localStorageKey); + + const timezone = getClientTimezone(); + + const [isError, setIsError] = useState(false); + + const createSession = + useCallback(async (): Promise => { + if (typeof window === "undefined") { + return { + sessionId: "", + status: "error", + }; + } + if (sessionId?.length) { + return { + sessionId, + status: "old", + }; + } + try { + const utm = parseQueryParams(); + const sessionParams = { + locale: language, + timezone, + // source: funnelId, + source: "aura.compatibility.v2", + sign: false, + utm, + domain: window.location.hostname, + }; + console.log("Creating session with parameters:", sessionParams); + const sessionFromServer = await createSessionApi(sessionParams); + console.log("Session creation response:", sessionFromServer); + if ( + sessionFromServer?.sessionId?.length && + sessionFromServer?.status === "success" + ) { + localStorage.setItem(localStorageKey, sessionFromServer.sessionId); + return sessionFromServer; + } + console.error( + "Session creation failed - invalid response:", + sessionFromServer + ); + setIsError(true); + return { + status: "error", + sessionId: "", + }; + } catch (error) { + console.error("Session creation failed with error:", error); + setIsError(true); + return { + status: "error", + sessionId: "", + }; + } + }, [localStorageKey, timezone, sessionId]); + // localStorageKey, sessionId, timezone, utm + + const updateSession = useCallback( + async (data: IUpdateSessionRequest["data"]) => { + try { + let _sessionId = sessionId; + if (!_sessionId) { + const session = await createSession(); + _sessionId = session.sessionId; + } + const result = await updateSessionApi({ + sessionId: _sessionId, + data, + }); + return result; + } catch (error) { + console.log(error); + } + }, + [sessionId, createSession] + ); + + const deleteSession = useCallback(async () => { + if (typeof window === "undefined") { + return; + } + localStorage.removeItem(localStorageKey); + }, [localStorageKey]); + + return useMemo( + () => ({ + session: sessionId, + isError, + createSession, + updateSession, + deleteSession, + }), + [sessionId, isError, createSession, deleteSession, updateSession] + ); +}; diff --git a/src/lib/env.ts b/src/lib/env.ts index 135252a..2985b11 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,8 +1,8 @@ -import { z } from 'zod'; +import { z } from "zod"; /** * Environment Variables Schema - * + * * Валидация всех переменных окружения при старте приложения. * Ошибки обнаруживаются на этапе сборки, а не в runtime. */ @@ -10,27 +10,40 @@ const envSchema = z.object({ // MongoDB MONGODB_URI: z .string() - .min(1, 'MONGODB_URI is required') - .default('mongodb://localhost:27017/witlab-funnel'), + .min(1, "MONGODB_URI is required") + .default("mongodb://localhost:27017/witlab-funnel"), // Build variant NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: z - .enum(['frontend', 'full']) + .enum(["frontend", "full"]) .optional() - .default('frontend'), + .default("frontend"), // Optional: Base URL for API calls NEXT_PUBLIC_BASE_URL: z .string() .url() .optional() - .default('http://localhost:3000'), + .default("http://localhost:3000"), // Node environment NODE_ENV: z - .enum(['development', 'production', 'test']) + .enum(["development", "production", "test"]) .optional() - .default('development'), + .default("development"), + + // Optional: API URL for API calls + NEXT_PUBLIC_API_URL: z + .string() + .url() + .optional() + .default("http://localhost:3000"), + + // Optional: Dev logger server enabled + DEV_LOGGER_SERVER_ENABLED: z.string().optional().default("false"), + + // Optional: Auth redirect URL + NEXT_PUBLIC_AUTH_REDIRECT_URL: z.string().optional().default("/"), }); /** @@ -41,17 +54,21 @@ function validateEnv() { try { return envSchema.parse({ MONGODB_URI: process.env.MONGODB_URI, - NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT, + NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: + process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NODE_ENV: process.env.NODE_ENV, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + DEV_LOGGER_SERVER_ENABLED: process.env.DEV_LOGGER_SERVER_ENABLED, + NEXT_PUBLIC_AUTH_REDIRECT_URL: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL, }); } catch (error) { if (error instanceof z.ZodError) { - console.error('❌ Invalid environment variables:'); + console.error("❌ Invalid environment variables:"); error.issues.forEach((err) => { - console.error(` - ${err.path.join('.')}: ${err.message}`); + console.error(` - ${err.path.join(".")}: ${err.message}`); }); - throw new Error('Environment validation failed'); + throw new Error("Environment validation failed"); } throw error; } diff --git a/src/shared/api/httpClient.ts b/src/shared/api/httpClient.ts new file mode 100644 index 0000000..ebbd9a1 --- /dev/null +++ b/src/shared/api/httpClient.ts @@ -0,0 +1,215 @@ +import { z } from "zod"; + +import { devLogger } from "../utils/logger"; +import { env } from "@/lib/env"; + +export class ApiError extends Error { + constructor( + public status: number, + public data: unknown, + message = "API Error" + ) { + super(message); + this.name = "ApiError"; + } +} + +type RequestOpts = Omit & { + tags?: string[]; // next.js cache-tag + query?: Record; // query-string + schema?: z.ZodTypeAny; // runtime validation + revalidate?: number; + skipAuthRedirect?: boolean; +}; + +class HttpClient { + constructor(private baseUrl: string) {} + + private buildUrl( + rootUrl: string, + path: string, + query?: Record + ) { + const url = new URL(path, rootUrl); + if (query) + Object.entries(query).forEach(([k, v]) => + url.searchParams.append(k, String(v)) + ); + return url.toString(); + } + + private async request( + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + rootUrl: string = this.baseUrl, + path: string, + opts: RequestOpts = {}, + body?: unknown, + errorMessage?: string + ): Promise { + const { + tags = [], + schema, + query, + revalidate = 300, + skipAuthRedirect = false, + ...rest + } = opts; + + const fullUrl = this.buildUrl(rootUrl, path, query); + const startTime = Date.now(); + + // Log API request (both client and server with ENV control) + if (typeof window !== "undefined") { + // Client-side logging + devLogger.apiRequest(fullUrl, method, body); + } else { + // Server-side logging (requires ENV variable) + if (typeof devLogger.serverApiRequest === "function") { + devLogger.serverApiRequest(fullUrl, method, body); + } else { + // Fallback server logging + if (env.DEV_LOGGER_SERVER_ENABLED === "true") { + console.group(`\n🚀 [SERVER] API REQUEST: ${method} ${fullUrl}`); + if (body !== undefined) { + console.log("📦 Request Body:", JSON.stringify(body, null, 2)); + } + console.groupEnd(); + } + } + } + + const headers = new Headers(); + let accessToken: string | undefined; + if (typeof window === "undefined") { + const { getServerAccessToken } = await import("../auth/token"); + accessToken = await getServerAccessToken(); + } else { + try { + const { getClientAccessToken } = await import("../auth/token"); + accessToken = getClientAccessToken(); + } catch { + // ignore + } + } + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); + headers.set("Content-Type", "application/json"); + + const res = await fetch(fullUrl, { + method, + body: body ? JSON.stringify(body) : undefined, + headers, + next: { revalidate, tags }, + ...rest, + }); + + const payload = await res.json().catch(() => null); + const duration = Date.now() - startTime; + + if (!res.ok) { + // Log API error response (both client and server) + if (typeof window !== "undefined") { + devLogger.apiResponse(fullUrl, method, res.status, payload, duration); + } else { + if (typeof devLogger.serverApiResponse === "function") { + devLogger.serverApiResponse( + fullUrl, + method, + res.status, + payload, + duration + ); + } else { + // Fallback server logging + if (env.DEV_LOGGER_SERVER_ENABLED === "true") { + const emoji = res.status >= 200 && res.status < 300 ? "✅" : "❌"; + console.group( + `\n${emoji} [SERVER] API ERROR: ${method} ${fullUrl}` + ); + console.log(`📊 Status: ${res.status}`); + console.log(`⏱️ Duration: ${duration}ms`); + if (payload !== undefined) { + console.log("📦 Error Response:", payload); + } + console.groupEnd(); + } + } + } + + if (res.status === 401 && !skipAuthRedirect) { + if (typeof window === "undefined") { + const { redirect } = await import("next/navigation"); + redirect(env.NEXT_PUBLIC_AUTH_REDIRECT_URL || ""); + } else { + const url = env.NEXT_PUBLIC_AUTH_REDIRECT_URL || "/"; + window.location.href = url; + } + } + throw new ApiError(res.status, payload, errorMessage); + } + + const data = payload as T; + const validatedData = schema ? (schema.parse(data) as T) : data; + + // Log successful API response (both client and server) + if (typeof window !== "undefined") { + devLogger.apiResponse( + fullUrl, + method, + res.status, + validatedData, + duration + ); + } else { + if (typeof devLogger.serverApiResponse === "function") { + devLogger.serverApiResponse( + fullUrl, + method, + res.status, + validatedData, + duration + ); + } else { + // Fallback server logging + if (env.DEV_LOGGER_SERVER_ENABLED === "true") { + console.group(`\n✅ [SERVER] API SUCCESS: ${method} ${fullUrl}`); + console.log(`📊 Status: ${res.status}`); + console.log(`⏱️ Duration: ${duration}ms`); + if (validatedData !== undefined) { + const responsePreview = + typeof validatedData === "object" && validatedData !== null + ? Array.isArray(validatedData) + ? `Array[${validatedData.length}]` + : `Object{${Object.keys(validatedData) + .slice(0, 5) + .join(", ")}${ + Object.keys(validatedData).length > 5 ? "..." : "" + }}` + : validatedData; + console.log("📦 Response Preview:", responsePreview); + } + console.groupEnd(); + } + } + } + + return validatedData; + } + + get = (p: string, o?: RequestOpts, u?: string) => + this.request("GET", u, p, o); + post = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("POST", u, p, o, b); + put = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("PUT", u, p, o, b); + patch = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("PATCH", u, p, o, b); + delete = (p: string, o?: RequestOpts, u?: string) => + this.request("DELETE", u, p, o); +} + +const apiUrl = env.NEXT_PUBLIC_API_URL; +if (!apiUrl) { + throw new Error("NEXT_PUBLIC_API_URL environment variable is required"); +} + +export const http = new HttpClient(apiUrl); diff --git a/src/shared/auth/clientToken.ts b/src/shared/auth/clientToken.ts new file mode 100644 index 0000000..a7d2ea1 --- /dev/null +++ b/src/shared/auth/clientToken.ts @@ -0,0 +1,24 @@ +"use client"; + +/** + * Gets the access token from client-side cookies + * @returns The access token or undefined if not found + */ +export function getClientAccessToken(): string | undefined { + if (typeof document === "undefined") { + return undefined; + } + + const cookies = document.cookie.split(";"); + const accessTokenCookie = cookies.find(cookie => + cookie.trim().startsWith("accessToken=") + ); + + if (!accessTokenCookie) { + return undefined; + } + + return decodeURIComponent( + accessTokenCookie.trim().substring("accessToken=".length) + ); +} diff --git a/src/shared/auth/token.ts b/src/shared/auth/token.ts new file mode 100644 index 0000000..10b28e4 --- /dev/null +++ b/src/shared/auth/token.ts @@ -0,0 +1,17 @@ +// Server-side token functions (only for Server Components) +export async function getServerAccessToken() { + const { cookies } = await import("next/headers"); + return (await cookies()).get("accessToken")?.value; +} + +// Client-side token functions +export function getClientAccessToken(): string | undefined { + if (typeof window === "undefined") return undefined; + + const cookies = document.cookie.split(";"); + const accessTokenCookie = cookies.find(cookie => + cookie.trim().startsWith("accessToken=") + ); + + return accessTokenCookie?.split("=")[1]; +} diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts new file mode 100644 index 0000000..ea79d67 --- /dev/null +++ b/src/shared/constants/api-routes.ts @@ -0,0 +1,13 @@ +const ROOT_ROUTE = "/"; +const ROOT_ROUTE_V2 = "/v2/"; + +const createRoute = ( + segments: (string | undefined)[], + rootRoute: string = ROOT_ROUTE +): string => { + return rootRoute + segments.filter(Boolean).join("/"); +}; + +export const API_ROUTES = { + session: (id?: string) => createRoute(["session", id], ROOT_ROUTE_V2), +}; diff --git a/src/shared/utils/locales.ts b/src/shared/utils/locales.ts new file mode 100644 index 0000000..3c44eea --- /dev/null +++ b/src/shared/utils/locales.ts @@ -0,0 +1,2 @@ +export const getClientTimezone = () => + Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts new file mode 100644 index 0000000..f09c7bf --- /dev/null +++ b/src/shared/utils/logger.ts @@ -0,0 +1,399 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +export enum LogType { + API = "API", + SOCKET = "SOCKET", + ERROR = "ERROR", + INFO = "INFO", +} + +export enum LogDirection { + REQUEST = "REQUEST", + RESPONSE = "RESPONSE", + INCOMING = "INCOMING", + OUTGOING = "OUTGOING", +} + +interface LogEntry { + type: LogType; + direction?: LogDirection; + event: string; + data?: unknown; + url?: string; + method?: string; + status?: number; + timestamp: Date; + duration?: number; +} + +class DevLogger { + private enabled = false; + private enabledTypes = new Set(Object.values(LogType)); + private envEnabled = false; + private serverLoggingEnabled = false; + + constructor() { + // Check ENV variables first + if (typeof window !== "undefined") { + this.envEnabled = process.env.NEXT_PUBLIC_DEV_LOGGER_ENABLED !== "false"; + } else { + // Server side - check server env + this.serverLoggingEnabled = + process.env.DEV_LOGGER_SERVER_ENABLED === "true"; + this.envEnabled = process.env.DEV_LOGGER_ENABLED !== "false"; + } + + // Check localStorage for logging preferences (client-side only) + if (typeof window !== "undefined") { + const stored = localStorage.getItem("dev-logger-enabled"); + this.enabled = stored ? JSON.parse(stored) : this.envEnabled; + + const storedTypes = localStorage.getItem("dev-logger-types"); + if (storedTypes) { + this.enabledTypes = new Set(JSON.parse(storedTypes)); + } + } else { + this.enabled = this.envEnabled; + } + } + + private shouldLog(type: LogType): boolean { + // Check ENV first, then user preferences + return this.envEnabled && this.enabled && this.enabledTypes.has(type); + } + + private shouldLogServer(type: LogType): boolean { + // Server logging requires explicit ENV enable + return ( + this.serverLoggingEnabled && + this.envEnabled && + this.enabled && + this.enabledTypes.has(type) + ); + } + + private getLogStyle( + type: LogType, + direction?: LogDirection + ): { emoji: string; color: string; bgColor?: string } { + const styles: Record = { + [LogType.API]: { + [LogDirection.REQUEST]: { + emoji: "🚀", + color: "#3b82f6", + bgColor: "#eff6ff", + }, + [LogDirection.RESPONSE]: { + emoji: "📨", + color: "#10b981", + bgColor: "#f0fdf4", + }, + }, + [LogType.SOCKET]: { + [LogDirection.OUTGOING]: { emoji: "🟢", color: "#16a34a" }, + [LogDirection.INCOMING]: { emoji: "🔵", color: "#2563eb" }, + }, + [LogType.ERROR]: { emoji: "❌", color: "#ef4444" }, + [LogType.INFO]: { emoji: "ℹ️", color: "#6366f1" }, + }; + + const typeStyles = styles[type]; + if ( + direction && + typeof typeStyles === "object" && + direction in typeStyles + ) { + return typeStyles[direction]; + } + return typeof typeStyles === "object" + ? { emoji: "📝", color: "#6b7280" } + : typeStyles; + } + + private formatTime(date: Date): string { + return date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }); + } + + log(entry: Omit) { + if (!this.shouldLog(entry.type)) return; + + const timestamp = new Date(); + const { emoji, color, bgColor } = this.getLogStyle( + entry.type, + entry.direction + ); + const timeStr = this.formatTime(timestamp); + const baseStyle = `color: ${color}; font-weight: bold;`; + const groupStyle = bgColor + ? `${baseStyle} background: ${bgColor}; padding: 2px 6px; border-radius: 3px;` + : baseStyle; + + // Create compact collapsible group + const groupTitle = `${emoji} ${entry.type}${ + entry.direction ? ` ${entry.direction}` : "" + }: ${entry.event}`; + + // Always use groupCollapsed for cleaner output + console.groupCollapsed(`%c${groupTitle} [${timeStr}]`, groupStyle); + + // Compact one-line summary with key info + const summaryParts = []; + if (entry.method) summaryParts.push(`${entry.method}`); + if (entry.status) { + const statusColor = + entry.status >= 200 && entry.status < 300 ? "✅" : "❌"; + summaryParts.push(`${statusColor} ${entry.status}`); + } + if (entry.duration !== undefined) + summaryParts.push(`⏱️ ${entry.duration}ms`); + + if (summaryParts.length > 0) { + console.log( + `%c${summaryParts.join(" • ")}`, + "color: #6b7280; font-size: 11px;" + ); + } + + if (entry.data !== undefined) { + // Show preview for objects/arrays, full value for primitives + if (typeof entry.data === "object" && entry.data !== null) { + const preview = Array.isArray(entry.data) + ? `Array[${entry.data.length}]` + : `Object{${Object.keys(entry.data).slice(0, 3).join(", ")}${ + Object.keys(entry.data).length > 3 ? "..." : "" + }}`; + console.log(`%c📦 Data:`, "color: #6b7280; font-size: 11px;", preview); + console.log(entry.data); + } else { + console.log( + `%c📦 Data:`, + "color: #6b7280; font-size: 11px;", + entry.data + ); + } + } + + console.groupEnd(); + } + + // API logging methods + apiRequest(url: string, method: string, data?: unknown) { + this.log({ + type: LogType.API, + direction: LogDirection.REQUEST, + event: `${method.toUpperCase()} ${url.split("?")[0]}`, + url, + method, + data, + }); + } + + apiResponse( + url: string, + method: string, + status: number, + data?: unknown, + duration?: number + ) { + this.log({ + type: LogType.API, + direction: LogDirection.RESPONSE, + event: `${method.toUpperCase()} ${url.split("?")[0]}`, + url, + method, + status, + data, + duration, + }); + } + + // Socket logging methods + socketOutgoing(event: string, data?: unknown) { + this.log({ + type: LogType.SOCKET, + direction: LogDirection.OUTGOING, + event, + data, + }); + } + + socketIncoming(event: string, data?: unknown) { + this.log({ + type: LogType.SOCKET, + direction: LogDirection.INCOMING, + event, + data, + }); + } + + // Connection state logging + socketConnected() { + console.log( + `%c✅ SOCKET CONNECTED`, + "color: #10b981; font-weight: bold; background: #f0fdf4; padding: 2px 6px; border-radius: 3px;" + ); + } + + socketDisconnected(reason?: string) { + console.log( + `%c❌ SOCKET DISCONNECTED`, + "color: #ef4444; font-weight: bold; background: #fef2f2; padding: 2px 6px; border-radius: 3px;", + reason || "" + ); + } + + socketError(error?: unknown) { + console.log( + `%c⚠️ SOCKET ERROR`, + "color: #f59e0b; font-weight: bold; background: #fffbeb; padding: 2px 6px; border-radius: 3px;", + error || "" + ); + } + + // Server-side logging methods + serverApiRequest(url: string, method: string, body?: unknown) { + if (!this.shouldLogServer(LogType.API)) return; + + console.group(`\n🚀 [SERVER] API REQUEST: ${method} ${url}`); + if (body !== undefined) { + console.log("📦 Request Body:", JSON.stringify(body, null, 2)); + } + console.groupEnd(); + } + + serverApiResponse( + url: string, + method: string, + status: number, + data?: unknown, + duration?: number + ) { + if (!this.shouldLogServer(LogType.API)) return; + + const emoji = status >= 200 && status < 300 ? "✅" : "❌"; + console.group( + `\n${emoji} [SERVER] API ${ + status >= 200 && status < 300 ? "SUCCESS" : "ERROR" + }: ${method} ${url}` + ); + console.log(`📊 Status: ${status}`); + if (duration !== undefined) { + console.log(`⏱️ Duration: ${duration}ms`); + } + if (data !== undefined) { + // Limit response data display to avoid overwhelming logs + const responsePreview = + typeof data === "object" && data !== null + ? Array.isArray(data) + ? `Array[${data.length}]` + : `Object{${Object.keys(data).slice(0, 5).join(", ")}${ + Object.keys(data).length > 5 ? "..." : "" + }}` + : data; + console.log("📦 Response Preview:", responsePreview); + // Full response data (collapsed) + console.groupCollapsed("📄 Full Response Data:"); + console.log(data); + console.groupEnd(); + } + console.groupEnd(); + } + + // Control methods + enable() { + this.enabled = true; + if (typeof window !== "undefined") { + localStorage.setItem("dev-logger-enabled", "true"); + } + console.log( + "%c📝 Dev Logger ENABLED", + "color: #10b981; font-weight: bold;" + ); + } + + disable() { + this.enabled = false; + if (typeof window !== "undefined") { + localStorage.setItem("dev-logger-enabled", "false"); + } + console.log( + "%c📝 Dev Logger DISABLED", + "color: #ef4444; font-weight: bold;" + ); + } + + enableType(type: LogType) { + this.enabledTypes.add(type); + this.saveEnabledTypes(); + console.log( + `%c📝 ${type} logging ENABLED`, + "color: #10b981; font-weight: bold;" + ); + } + + disableType(type: LogType) { + this.enabledTypes.delete(type); + this.saveEnabledTypes(); + console.log( + `%c📝 ${type} logging DISABLED`, + "color: #ef4444; font-weight: bold;" + ); + } + + private saveEnabledTypes() { + if (typeof window !== "undefined") { + localStorage.setItem( + "dev-logger-types", + JSON.stringify(Array.from(this.enabledTypes)) + ); + } + } + + // Helper method to show current settings + status() { + console.group( + "%c🔧 Dev Logger Status", + "color: #6366f1; font-weight: bold;" + ); + console.log("Enabled:", this.enabled); + console.log("Active Types:", Array.from(this.enabledTypes)); + console.groupEnd(); + } +} + +// Create singleton instance +export const devLogger = new DevLogger(); + +// Make it available globally for easy console access +if (typeof window !== "undefined") { + (window as any).devLogger = devLogger; +} + +// Export convenience methods for quick filtering +export const filterAPI = () => { + console.clear(); + devLogger.disableType(LogType.SOCKET); + devLogger.disableType(LogType.ERROR); + devLogger.disableType(LogType.INFO); + devLogger.enableType(LogType.API); +}; + +export const filterSocket = () => { + console.clear(); + devLogger.disableType(LogType.API); + devLogger.disableType(LogType.ERROR); + devLogger.disableType(LogType.INFO); + devLogger.enableType(LogType.SOCKET); +}; + +export const showAll = () => { + console.clear(); + Object.values(LogType).forEach((type) => devLogger.enableType(type)); +}; diff --git a/src/shared/utils/url.ts b/src/shared/utils/url.ts new file mode 100644 index 0000000..bbd9394 --- /dev/null +++ b/src/shared/utils/url.ts @@ -0,0 +1,19 @@ +export const getQueryParam = (paramName: string) => { + const search = window.location.search; + const params = new URLSearchParams(search); + return params.get(paramName); +}; + +export const parseQueryParams = () => { + if (typeof window === "undefined") { + return {}; + } + const params = new URLSearchParams(window.location.search); + const result: Record = {}; + + for (const [key, value] of params.entries()) { + result[key] = value; + } + + return result; +}; From c1e9d3e2d6d352fab148e0580a1e44f114842161 Mon Sep 17 00:00:00 2001 From: gofnnp Date: Sun, 5 Oct 2025 14:12:46 +0400 Subject: [PATCH 02/35] auth add auth to email template --- src/components/funnel/FunnelRuntime.tsx | 1 + .../templates/EmailTemplate/EmailTemplate.tsx | 55 +++++-- src/components/ui/spinner.tsx | 16 ++ .../BottomActionButton/BottomActionButton.tsx | 9 +- src/entities/session/serverActions.ts | 17 ++ src/entities/user/actions.ts | 21 +++ src/entities/user/serverActions.ts | 14 ++ src/entities/user/types.ts | 32 ++++ src/hooks/auth/useAuth.ts | 153 ++++++++++++++++++ src/hooks/session/useSession.ts | 19 ++- src/lib/funnel/mappers.tsx | 111 ++++++++----- src/lib/funnel/screenRenderer.tsx | 121 +++++++++++--- src/lib/funnel/templateHelpers.ts | 21 ++- src/shared/api/httpClient.ts | 15 ++ src/shared/auth/clientToken.ts | 24 --- src/shared/constants/api-routes.ts | 1 + src/shared/session/sessionId.ts | 12 ++ src/shared/utils/filter-object/index.ts | 31 ++++ 18 files changed, 557 insertions(+), 116 deletions(-) create mode 100644 src/components/ui/spinner.tsx create mode 100644 src/entities/session/serverActions.ts create mode 100644 src/entities/user/actions.ts create mode 100644 src/entities/user/serverActions.ts create mode 100644 src/hooks/auth/useAuth.ts delete mode 100644 src/shared/auth/clientToken.ts create mode 100644 src/shared/session/sessionId.ts create mode 100644 src/shared/utils/filter-object/index.ts diff --git a/src/components/funnel/FunnelRuntime.tsx b/src/components/funnel/FunnelRuntime.tsx index 474ff4f..1f10610 100644 --- a/src/components/funnel/FunnelRuntime.tsx +++ b/src/components/funnel/FunnelRuntime.tsx @@ -259,6 +259,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0; return renderScreen({ + funnel, screen: currentScreen, selectedOptionIds, onSelectionChange: handleSelectionChange, diff --git a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx index 834e7d2..9f92dfc 100644 --- a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx +++ b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx @@ -4,20 +4,27 @@ import { useState, useEffect } from "react"; import Image from "next/image"; import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner"; import { TextInput } from "@/components/ui/TextInput/TextInput"; -import type { EmailScreenDefinition, DefaultTexts } from "@/lib/funnel/types"; +import type { + EmailScreenDefinition, + DefaultTexts, + FunnelDefinition, +} from "@/lib/funnel/types"; import { TemplateLayout } from "../layouts/TemplateLayout"; import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { Spinner } from "@/components/ui/spinner"; +import { useAuth } from "@/hooks/auth/useAuth"; const formSchema = z.object({ - email: z.string().email({ + email: z.email({ message: "Please enter a valid email address", }), }); interface EmailTemplateProps { + funnel: FunnelDefinition; screen: EmailScreenDefinition; selectedEmail: string; onEmailChange: (email: string) => void; @@ -29,6 +36,7 @@ interface EmailTemplateProps { } export function EmailTemplate({ + funnel, screen, selectedEmail, onEmailChange, @@ -38,8 +46,12 @@ export function EmailTemplate({ screenProgress, defaultTexts, }: EmailTemplateProps) { + const { authorization, isLoading, error } = useAuth({ + funnelId: funnel.meta.id, + }); + const [isTouched, setIsTouched] = useState(false); - + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -58,6 +70,21 @@ export function EmailTemplate({ onEmailChange(value); }; + const handleContinue = async () => { + const email = form.getValues("email"); + + if (!email || !form.formState.isValid || isLoading) { + return; + } + + try { + await authorization(email); + onContinue(); + } catch (err) { + console.error("Authorization failed:", err); + } + }; + const isFormValid = form.formState.isValid && form.getValues("email"); const layoutProps = createTemplateLayoutProps( @@ -67,9 +94,10 @@ export function EmailTemplate({ { preset: "center", actionButton: { + children: isLoading ? : undefined, defaultText: defaultTexts?.nextButton || "Continue", disabled: !isFormValid, - onClick: onContinue, + onClick: handleContinue, }, } ); @@ -87,9 +115,10 @@ export function EmailTemplate({ setIsTouched(true); form.trigger("email"); }} - aria-invalid={isTouched && !!form.formState.errors.email} + aria-invalid={(isTouched && !!form.formState.errors.email) || !!error} aria-errormessage={ - isTouched ? form.formState.errors.email?.message : undefined + (isTouched ? form.formState.errors.email?.message : undefined) || + (error ? "Something went wrong" : undefined) } /> @@ -97,16 +126,18 @@ export function EmailTemplate({ portrait )} - diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 0000000..a70e713 --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,16 @@ +import { Loader2Icon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( + + ) +} + +export { Spinner } diff --git a/src/components/widgets/BottomActionButton/BottomActionButton.tsx b/src/components/widgets/BottomActionButton/BottomActionButton.tsx index 0096b9f..514f448 100644 --- a/src/components/widgets/BottomActionButton/BottomActionButton.tsx +++ b/src/components/widgets/BottomActionButton/BottomActionButton.tsx @@ -1,7 +1,12 @@ "use client"; import { cn } from "@/lib/utils"; -import React, { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef, +} from "react"; import { GradientBlur } from "../GradientBlur/GradientBlur"; import { ActionButton } from "@/components/ui/ActionButton/ActionButton"; @@ -86,4 +91,4 @@ const BottomActionButton = forwardRef( } ); -export { BottomActionButton }; \ No newline at end of file +export { BottomActionButton }; diff --git a/src/entities/session/serverActions.ts b/src/entities/session/serverActions.ts new file mode 100644 index 0000000..0f37317 --- /dev/null +++ b/src/entities/session/serverActions.ts @@ -0,0 +1,17 @@ +"use server"; + +import { cookies } from "next/headers"; + +export const setSessionIdToCookie = async ( + key: string, + value: string +): Promise => { + const cookieStore = await cookies(); + cookieStore.set(key, value, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + path: "/", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 365, + }); +}; diff --git a/src/entities/user/actions.ts b/src/entities/user/actions.ts new file mode 100644 index 0000000..9d775cd --- /dev/null +++ b/src/entities/user/actions.ts @@ -0,0 +1,21 @@ +import { http } from "@/shared/api/httpClient"; +import { + CreateAuthorizeResponseSchema, + ICreateAuthorizeRequest, + ICreateAuthorizeResponse, +} from "./types"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +export const createAuthorization = async ( + payload: ICreateAuthorizeRequest +): Promise => { + return http.post( + API_ROUTES.authorization(), + payload, + { + tags: ["authorization", "create"], + schema: CreateAuthorizeResponseSchema, + revalidate: 0, + } + ); +}; diff --git a/src/entities/user/serverActions.ts b/src/entities/user/serverActions.ts new file mode 100644 index 0000000..ed37e49 --- /dev/null +++ b/src/entities/user/serverActions.ts @@ -0,0 +1,14 @@ +"use server"; + +import { cookies } from "next/headers"; + +export const setAuthTokenToCookie = async (token: string): Promise => { + const cookieStore = await cookies(); + cookieStore.set("accessToken", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + path: "/", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 365, + }); +}; diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts index f60f2b3..05a2c2a 100644 --- a/src/entities/user/types.ts +++ b/src/entities/user/types.ts @@ -20,3 +20,35 @@ export const CreateAuthorizeUserSchema = z.object({ }), relationship_status: RelationshipStatusSchema, }); + +export const CreateAuthorizeRequestSchema = z.object({ + email: z.string(), + locale: z.string(), + timezone: z.string(), + source: z.string(), + profile: CreateAuthorizeUserSchema.optional(), + partner: CreateAuthorizeUserSchema.omit({ + relationship_status: true, + }).optional(), + sign: z.boolean(), + signDate: z.string().optional(), + feature: z.string().optional(), +}); + +export const CreateAuthorizeResponseSchema = z.object({ + token: z.string(), + userId: z.string().optional(), + generatingVideo: z.boolean().optional(), + videoId: z.string().optional(), + authCode: z.string().optional(), +}); + +export type ICreateAuthorizeUser = z.infer; + +export type ICreateAuthorizeRequest = z.infer< + typeof CreateAuthorizeRequestSchema +>; + +export type ICreateAuthorizeResponse = z.infer< + typeof CreateAuthorizeResponseSchema +>; diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts new file mode 100644 index 0000000..9616e06 --- /dev/null +++ b/src/hooks/auth/useAuth.ts @@ -0,0 +1,153 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useSession } from "../session/useSession"; +import { getClientTimezone } from "@/shared/utils/locales"; +import { ICreateAuthorizeRequest } from "@/entities/user/types"; +import { filterNullKeysOfObject } from "@/shared/utils/filter-object"; +import { createAuthorization } from "@/entities/user/actions"; +import { setAuthTokenToCookie } from "@/entities/user/serverActions"; + +// TODO +const locale = "en"; + +interface IUseAuthProps { + funnelId: string; +} + +export const useAuth = ({ funnelId }: IUseAuthProps) => { + const { updateSession } = useSession({ funnelId }); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const getAllCookies = useCallback(() => { + const cookies: Record = {}; + document.cookie.split(";").forEach((cookie) => { + const [name, value] = cookie.trim().split("="); + if (name && value) { + cookies[name] = decodeURIComponent(value); + } + }); + return cookies; + }, []); + + const getAuthorizationPayload = useCallback( + (email: string): ICreateAuthorizeRequest => { + const timezone = getClientTimezone(); + return filterNullKeysOfObject({ + timezone, + locale, + email, + // source: funnelId, + source: "aura.compatibility.v2", + // profile: { + // name: username || "", + // gender: EGender[gender as keyof typeof EGender] || null, + // birthdate: formatDate(`${birthdate} ${birthtime}`), + // birthplace: { + // address: birthPlace, + // }, + // }, + // partner: { + // name: partnerName, + // gender: EGender[partnerGender as keyof typeof EGender] || null, + // birthdate: formatDate(partnerBirthdate), + // birthplace: { + // address: partnerBirthPlace, + // }, + // }, + sign: true, + signDate: new Date().toISOString(), + // feature: feature.includes("black") ? "ios" : feature, + }); + }, + [ + // birthPlace, + // birthdate, + // gender, + // locale, + // partnerBirthPlace, + // partnerBirthdate, + // partnerGender, + // partnerName, + // username, + // birthtime, + // checked, + // dateOfCheck, + ] + ); + + const authorization = useCallback( + async (email: string) => { + try { + setIsLoading(true); + setError(null); + + // Обновляем сессию с куки перед авторизацией + try { + const cookies = getAllCookies(); + await updateSession({ cookies }); + console.log( + "Session updated with cookies before authorization:", + cookies + ); + } catch (sessionError) { + console.warn("Failed to update session with cookies:", sessionError); + // Продолжаем авторизацию даже если обновление сессии не удалось + } + + const payload = getAuthorizationPayload(email); + const { + token, + // userId: userIdFromApi, + // generatingVideo, + // videoId, + // authCode, + } = await createAuthorization(payload); + await setAuthTokenToCookie(token); + // const { user: userMe } = await api.getMe({ token }); + // const userId = userIdFromApi || userMe?._id; + // if (userId?.length) { + // dispatch(actions.userId.update({ userId })); + // metricService.userParams({ + // hasPersonalVideo: generatingVideo || false, + // email: user?.email, + // UserID: userId, + // }); + // metricService.setUserID(userId); + // } + // signUp(token, userMe, isAnonymous); + // setToken(token); + // dispatch(actions.userConfig.setAuthCode(authCode || "")); + // dispatch( + // actions.personalVideo.updateStatus({ + // generatingVideo: generatingVideo || false, + // videoId: videoId || "", + // }) + // ); + // if (generatingVideo) { + // metricService.reachGoal(EGoals.ROSE_VIDEO_CREATION_START, [ + // EMetrics.YANDEX, + // EMetrics.KLAVIYO, + // ]); + // } + // dispatch(actions.status.update("registred")); + } catch (error) { + setError((error as Error).message); + } finally { + setIsLoading(false); + } + }, + [getAllCookies, getAuthorizationPayload, updateSession] + ); + + return useMemo( + () => ({ + authorization, + isLoading, + error, + }), + [authorization, isLoading, error] + ); +}; diff --git a/src/hooks/session/useSession.ts b/src/hooks/session/useSession.ts index 6c00529..49d2b98 100644 --- a/src/hooks/session/useSession.ts +++ b/src/hooks/session/useSession.ts @@ -9,9 +9,10 @@ import { import { getClientTimezone } from "@/shared/utils/locales"; import { parseQueryParams } from "@/shared/utils/url"; import { useCallback, useMemo, useState } from "react"; +import { setSessionIdToCookie } from "@/entities/session/serverActions"; // TODO -const language = "en"; +const locale = "en"; interface IUseSessionProps { funnelId: string; @@ -26,6 +27,15 @@ export const useSession = ({ funnelId }: IUseSessionProps) => { const [isError, setIsError] = useState(false); + const setSessionId = useCallback( + async (sessionId: string) => { + localStorage.setItem(localStorageKey, sessionId); + localStorage.setItem("activeSessionId", sessionId); + await setSessionIdToCookie("activeSessionId", sessionId); + }, + [localStorageKey] + ); + const createSession = useCallback(async (): Promise => { if (typeof window === "undefined") { @@ -35,6 +45,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => { }; } if (sessionId?.length) { + setSessionId(sessionId); return { sessionId, status: "old", @@ -43,7 +54,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => { try { const utm = parseQueryParams(); const sessionParams = { - locale: language, + locale, timezone, // source: funnelId, source: "aura.compatibility.v2", @@ -58,7 +69,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => { sessionFromServer?.sessionId?.length && sessionFromServer?.status === "success" ) { - localStorage.setItem(localStorageKey, sessionFromServer.sessionId); + await setSessionId(sessionFromServer.sessionId); return sessionFromServer; } console.error( @@ -78,7 +89,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => { sessionId: "", }; } - }, [localStorageKey, timezone, sessionId]); + }, [sessionId, timezone, setSessionId]); // localStorageKey, sessionId, timezone, utm const updateSession = useCallback( diff --git a/src/lib/funnel/mappers.tsx b/src/lib/funnel/mappers.tsx index 6699666..1ed794d 100644 --- a/src/lib/funnel/mappers.tsx +++ b/src/lib/funnel/mappers.tsx @@ -15,7 +15,16 @@ import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/Lay import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton"; import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton"; -type TypographyAs = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div"; +type TypographyAs = + | "span" + | "p" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "div"; interface TypographyDefaults { font?: TypographyVariant["font"]; @@ -39,7 +48,7 @@ export function buildTypographyProps( } // Проверяем поле show - если false, не показываем - if ('show' in variant && variant.show === false) { + if ("show" in variant && variant.show === false) { return undefined; } @@ -54,7 +63,7 @@ export function buildTypographyProps( align: variant.align ?? defaults?.align, color: variant.color ?? defaults?.color, className: variant.className, - enableMarkup: hasTextMarkup(variant.text || ''), // 🎨 АВТОМАТИЧЕСКИ включаем разметку если обнаружена + enableMarkup: hasTextMarkup(variant.text || ""), // 🎨 АВТОМАТИЧЕСКИ включаем разметку если обнаружена } as TypographyProps; } @@ -66,7 +75,8 @@ export function buildHeaderProgress(progress?: HeaderProgressDefinition) { const { current, total, value, label, className } = progress; const computedValue = - value ?? (current !== undefined && total ? (current / total) * 100 : undefined); + value ?? + (current !== undefined && total ? (current / total) * 100 : undefined); return { value: computedValue, @@ -85,14 +95,14 @@ export function buildAutoHeaderProgress( if (explicitProgress) { return buildHeaderProgress(explicitProgress); } - + // Otherwise, auto-calculate const autoProgress: HeaderProgressDefinition = { current: currentPosition, total: totalScreens, label: `${currentPosition} of ${totalScreens}`, }; - + return buildHeaderProgress(autoProgress); } @@ -108,7 +118,10 @@ export function mapListOptionsToButtons( disabled: option.disabled, })); } -export function shouldShowBackButton(header?: HeaderDefinition, canGoBack?: boolean) { +export function shouldShowBackButton( + header?: HeaderDefinition, + canGoBack?: boolean +) { if (header?.showBackButton === false) { return false; } @@ -124,6 +137,7 @@ export function shouldShowHeader(header?: HeaderDefinition) { } interface BuildActionButtonOptions { + children?: React.ReactNode; defaultText?: string; disabled?: boolean; onClick: () => void; @@ -134,9 +148,11 @@ export function buildActionButtonProps( buttonDef?: BottomActionButtonDefinition ): ActionButtonProps { const { defaultText = "Continue", disabled = false, onClick } = options; - + return { - children: buttonDef?.text ?? defaultText, + children: options.children + ? options.children + : buttonDef?.text ?? defaultText, cornerRadius: buttonDef?.cornerRadius, disabled: disabled, // disabled управляется только логикой экрана, не админкой onClick: disabled ? undefined : onClick, @@ -151,10 +167,10 @@ export function buildBottomActionButtonProps( if (buttonDef?.show === false) { return undefined; } - + // В остальных случаях показать кнопку с градиентом const actionButtonProps = buildActionButtonProps(options, buttonDef); - + return { actionButtonProps, showGradientBlur: true, // Градиент всегда включен (как требовалось) @@ -173,13 +189,18 @@ interface BuildLayoutQuestionOptions { export function buildLayoutQuestionProps( options: BuildLayoutQuestionOptions ): Omit { - const { - screen, + const { + screen, titleDefaults = { font: "manrope", weight: "bold", align: "left" }, - subtitleDefaults = { font: "inter", weight: "medium", color: "muted", align: "left" }, - canGoBack, - onBack, - screenProgress + subtitleDefaults = { + font: "inter", + weight: "medium", + color: "muted", + align: "left", + }, + canGoBack, + onBack, + screenProgress, } = options; const showBackButton = shouldShowBackButton(screen.header, canGoBack); @@ -187,25 +208,34 @@ export function buildLayoutQuestionProps( const showProgress = shouldShowProgress(screen.header); return { - headerProps: showHeader ? { - progressProps: showProgress ? ( - screenProgress ? buildHeaderProgress({ - current: screenProgress.current, - total: screenProgress.total, - label: `${screenProgress.current} of ${screenProgress.total}` - }) : buildHeaderProgress(screen.header?.progress) - ) : undefined, - onBack: showBackButton ? onBack : undefined, - showBackButton, - } : undefined, - title: screen.title ? buildTypographyProps(screen.title, { - as: "h2", - defaults: titleDefaults, - }) : undefined, - subtitle: 'subtitle' in screen ? buildTypographyProps(screen.subtitle, { - as: "p", - defaults: subtitleDefaults, - }) : undefined, + headerProps: showHeader + ? { + progressProps: showProgress + ? screenProgress + ? buildHeaderProgress({ + current: screenProgress.current, + total: screenProgress.total, + label: `${screenProgress.current} of ${screenProgress.total}`, + }) + : buildHeaderProgress(screen.header?.progress) + : undefined, + onBack: showBackButton ? onBack : undefined, + showBackButton, + } + : undefined, + title: screen.title + ? buildTypographyProps(screen.title, { + as: "h2", + defaults: titleDefaults, + }) + : undefined, + subtitle: + "subtitle" in screen + ? buildTypographyProps(screen.subtitle, { + as: "p", + defaults: subtitleDefaults, + }) + : undefined, }; } @@ -220,11 +250,10 @@ export function buildTemplateBottomActionButtonProps(options: { // Принудительно включаем кнопку независимо от screen.bottomActionButton.show return buildBottomActionButtonProps( actionButtonOptions, - 'bottomActionButton' in screen - ? (screen.bottomActionButton?.show === false - ? { ...screen.bottomActionButton, show: true } - : screen.bottomActionButton) + "bottomActionButton" in screen + ? screen.bottomActionButton?.show === false + ? { ...screen.bottomActionButton, show: true } + : screen.bottomActionButton : undefined ); } - diff --git a/src/lib/funnel/screenRenderer.tsx b/src/lib/funnel/screenRenderer.tsx index 4756197..df3d34f 100644 --- a/src/lib/funnel/screenRenderer.tsx +++ b/src/lib/funnel/screenRenderer.tsx @@ -23,9 +23,11 @@ import type { SoulmatePortraitScreenDefinition, ScreenDefinition, DefaultTexts, + FunnelDefinition, } from "@/lib/funnel/types"; export interface ScreenRenderProps { + funnel: FunnelDefinition; screen: ScreenDefinition; selectedOptionIds: string[]; onSelectionChange: (ids: string[]) => void; @@ -38,8 +40,18 @@ export interface ScreenRenderProps { export type TemplateRenderer = (props: ScreenRenderProps) => JSX.Element; -const TEMPLATE_REGISTRY: Record = { - info: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { +const TEMPLATE_REGISTRY: Record< + ScreenDefinition["template"], + TemplateRenderer +> = { + info: ({ + screen, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, + }) => { const infoScreen = screen as InfoScreenDefinition; return ( @@ -53,9 +65,18 @@ const TEMPLATE_REGISTRY: Record /> ); }, - date: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + date: ({ + screen, + selectedOptionIds, + onSelectionChange, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, + }) => { const dateScreen = screen as DateScreenDefinition; - + // For date screens, we store date components as array: [month, day, year] const currentDateArray = selectedOptionIds; const selectedDate = { @@ -64,7 +85,11 @@ const TEMPLATE_REGISTRY: Record year: currentDateArray[2] || "", }; - const handleDateChange = (date: { month?: string; day?: string; year?: string }) => { + const handleDateChange = (date: { + month?: string; + day?: string; + year?: string; + }) => { const dateArray = [date.month || "", date.day || "", date.year || ""]; onSelectionChange(dateArray); }; @@ -82,13 +107,22 @@ const TEMPLATE_REGISTRY: Record /> ); }, - form: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + form: ({ + screen, + selectedOptionIds, + onSelectionChange, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, + }) => { const formScreen = screen as FormScreenDefinition; - + // For form screens, we store form data as JSON string in the first element const formDataJson = selectedOptionIds[0] || "{}"; let formData: Record = {}; - + try { formData = JSON.parse(formDataJson); } catch { @@ -113,7 +147,14 @@ const TEMPLATE_REGISTRY: Record /> ); }, - coupon: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + coupon: ({ + screen, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, + }) => { const couponScreen = screen as CouponScreenDefinition; return ( @@ -143,16 +184,17 @@ const TEMPLATE_REGISTRY: Record // Используем только общую кнопку экрана const bottomActionButton = listScreen.bottomActionButton; const isButtonDisabled = bottomActionButton?.show === false; - + // Простая логика: кнопка есть если не отключена (show: false) const hasActionButton = !isButtonDisabled; // Правильная логика приоритетов для текста кнопки: // 1. bottomActionButton.text (настройка экрана) - // 2. defaultTexts.nextButton (глобальная настройка воронки) + // 2. defaultTexts.nextButton (глобальная настройка воронки) // 3. "Next" (хардкод fallback) - const buttonText = bottomActionButton?.text || defaultTexts?.nextButton || "Next"; - + const buttonText = + bottomActionButton?.text || defaultTexts?.nextButton || "Next"; + const actionDisabled = hasActionButton && isSelectionEmpty; return ( @@ -160,22 +202,34 @@ const TEMPLATE_REGISTRY: Record screen={listScreen} selectedOptionIds={selectedOptionIds} onSelectionChange={onSelectionChange} - actionButtonProps={hasActionButton - ? { - children: buttonText, - disabled: actionDisabled, - onClick: actionDisabled ? undefined : onContinue, - } - : undefined} + actionButtonProps={ + hasActionButton + ? { + children: buttonText, + disabled: actionDisabled, + onClick: actionDisabled ? undefined : onContinue, + } + : undefined + } canGoBack={canGoBack} onBack={onBack} screenProgress={screenProgress} /> ); }, - email: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + email: ({ + screen, + selectedOptionIds, + onSelectionChange, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, + funnel, + }) => { const emailScreen = screen as EmailScreenDefinition; - + // For email screens, we store email as single string in first element const selectedEmail = selectedOptionIds[0] || ""; @@ -193,10 +247,18 @@ const TEMPLATE_REGISTRY: Record onBack={onBack} screenProgress={screenProgress} defaultTexts={defaultTexts} + funnel={funnel} /> ); }, - loaders: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + loaders: ({ + screen, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, + }) => { const loadersScreen = screen as LoadersScreenDefinition; return ( @@ -210,7 +272,14 @@ const TEMPLATE_REGISTRY: Record /> ); }, - soulmate: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + soulmate: ({ + screen, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, + }) => { const soulmateScreen = screen as SoulmatePortraitScreenDefinition; return ( @@ -234,7 +303,9 @@ export function renderScreen(props: ScreenRenderProps): JSX.Element { return renderer(props); } -export function getTemplateRenderer(screen: ScreenDefinition): TemplateRenderer { +export function getTemplateRenderer( + screen: ScreenDefinition +): TemplateRenderer { const renderer = TEMPLATE_REGISTRY[screen.template]; if (!renderer) { throw new Error(`Unsupported template: ${screen.template}`); diff --git a/src/lib/funnel/templateHelpers.ts b/src/lib/funnel/templateHelpers.ts index 325a64b..2f5604f 100644 --- a/src/lib/funnel/templateHelpers.ts +++ b/src/lib/funnel/templateHelpers.ts @@ -1,12 +1,15 @@ /** * Helper функции для упрощения работы с темплейтами воронки - * + * * Эти функции помогают избежать дублирования кода при создании props * для TemplateLayout компонента */ import type { ScreenDefinition, TypographyVariant } from "./types"; -import { TEMPLATE_DEFAULTS, TEMPLATE_DEFAULTS_CENTERED } from "@/components/funnel/templates/constants"; +import { + TEMPLATE_DEFAULTS, + TEMPLATE_DEFAULTS_CENTERED, +} from "@/components/funnel/templates/constants"; /** * Тип preset для быстрого выбора стиля темплейта @@ -17,6 +20,7 @@ export type TemplatePreset = "left" | "center"; * Конфигурация action кнопки для темплейта */ export interface ActionButtonConfig { + children?: React.ReactNode; defaultText: string; disabled: boolean; onClick: () => void; @@ -72,11 +76,11 @@ export interface TemplateNavigation { /** * Helper функция для создания props для TemplateLayout компонента - * + * * Упрощает создание темплейтов, предоставляя единообразный способ * настройки всех параметров с использованием preset-ов и опциональных * переопределений - * + * * @example * ```typescript * const layoutProps = createTemplateLayoutProps( @@ -92,7 +96,7 @@ export interface TemplateNavigation { * }, * } * ); - * + * * return {children}; * ``` */ @@ -103,9 +107,10 @@ export function createTemplateLayoutProps( options?: CreateTemplateLayoutOptions ) { // Выбираем preset на основе опций - const defaults = options?.preset === "center" - ? TEMPLATE_DEFAULTS_CENTERED - : TEMPLATE_DEFAULTS; + const defaults = + options?.preset === "center" + ? TEMPLATE_DEFAULTS_CENTERED + : TEMPLATE_DEFAULTS; return { screen, diff --git a/src/shared/api/httpClient.ts b/src/shared/api/httpClient.ts index ebbd9a1..46f4741 100644 --- a/src/shared/api/httpClient.ts +++ b/src/shared/api/httpClient.ts @@ -79,6 +79,21 @@ class HttpClient { } const headers = new Headers(); + + let sessionId: string | null = null; + if (typeof window === "undefined") { + const { getServerSessionId } = await import("../session/sessionId"); + sessionId = (await getServerSessionId()) ?? null; + } else { + try { + const { getClientSessionId } = await import("../session/sessionId"); + sessionId = getClientSessionId(); + } catch { + // ignore + } + } + if (sessionId) headers.set("session-id", sessionId); + let accessToken: string | undefined; if (typeof window === "undefined") { const { getServerAccessToken } = await import("../auth/token"); diff --git a/src/shared/auth/clientToken.ts b/src/shared/auth/clientToken.ts deleted file mode 100644 index a7d2ea1..0000000 --- a/src/shared/auth/clientToken.ts +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -/** - * Gets the access token from client-side cookies - * @returns The access token or undefined if not found - */ -export function getClientAccessToken(): string | undefined { - if (typeof document === "undefined") { - return undefined; - } - - const cookies = document.cookie.split(";"); - const accessTokenCookie = cookies.find(cookie => - cookie.trim().startsWith("accessToken=") - ); - - if (!accessTokenCookie) { - return undefined; - } - - return decodeURIComponent( - accessTokenCookie.trim().substring("accessToken=".length) - ); -} diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts index ea79d67..2df4c7d 100644 --- a/src/shared/constants/api-routes.ts +++ b/src/shared/constants/api-routes.ts @@ -10,4 +10,5 @@ const createRoute = ( export const API_ROUTES = { session: (id?: string) => createRoute(["session", id], ROOT_ROUTE_V2), + authorization: () => createRoute(["users", "auth"]), }; diff --git a/src/shared/session/sessionId.ts b/src/shared/session/sessionId.ts new file mode 100644 index 0000000..ea6ee0a --- /dev/null +++ b/src/shared/session/sessionId.ts @@ -0,0 +1,12 @@ +// Server-side token functions (only for Server Components) +export async function getServerSessionId() { + const { cookies } = await import("next/headers"); + return (await cookies()).get("activeSessionId")?.value; +} + +// Client-side token functions +export function getClientSessionId(): string | null { + if (typeof window === "undefined") return null; + + return localStorage.getItem("activeSessionId"); +} diff --git a/src/shared/utils/filter-object/index.ts b/src/shared/utils/filter-object/index.ts new file mode 100644 index 0000000..0cc996b --- /dev/null +++ b/src/shared/utils/filter-object/index.ts @@ -0,0 +1,31 @@ +export function filterNullKeysOfObject(object: T): T { + if (typeof object !== "object") { + return object; + } + return Object.keys(object) + .filter((key) => { + if ( + typeof object[key as keyof T] === "object" && + object[key as keyof T] !== null + ) { + return Object.keys(object[key as keyof T] as object).length; + } + if (typeof object[key as keyof T] === "string") { + return !!(object[key as keyof T] as string).length; + } + return object[key as keyof T] !== null; + }) + .reduce( + (acc, key) => { + return Object.assign(acc, { + [key]: + typeof object[key as keyof T] === "object" + ? filterNullKeysOfObject( + object[key as keyof object] + ) + : object[key as keyof T], + }); + }, + Array.isArray(object) ? [] : {} + ) as T; +} From 29c9ad92fe8f606d2b16bbc88f85f003a86170a5 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Sun, 5 Oct 2025 23:43:14 +0200 Subject: [PATCH 03/35] add funnel --- ANALYSIS_REPORT.md | 659 ------- BUILD_VARIANTS.md | 232 --- PERFORMANCE_IMPROVEMENTS.md | 219 --- README-ADMIN.md | 218 --- REFACTORING_SUMMARY.md | 170 -- docs/templates-and-builder.md | 232 --- public/funnels/soulmate.json | 1581 ++++++++++++++++ .../ac321d94-62e3-45c6-85f4-51faf6769bab.svg | 13 + scripts/sync-funnels-from-db.mjs | 19 +- .../builder/[id]/FunnelBuilderPageClient.tsx | 4 +- src/app/api/funnels/[id]/route.ts | 64 +- src/app/api/funnels/route.ts | 79 +- src/app/api/images/[filename]/route.ts | 23 +- .../builder/templates/TemplateConfig.tsx | 61 +- .../FormTemplate/FormTemplate.stories.tsx | 6 +- .../SoulmatePortraitTemplate.stories.tsx | 6 +- src/lib/admin/builder/state/reducer.ts | 82 +- src/lib/funnel/bakedFunnels.ts | 1582 ++++++++++++++++- src/lib/funnel/mappers.tsx | 5 + src/lib/funnel/types.ts | 2 +- src/lib/models/Funnel.ts | 14 +- 21 files changed, 3496 insertions(+), 1775 deletions(-) delete mode 100644 ANALYSIS_REPORT.md delete mode 100644 BUILD_VARIANTS.md delete mode 100644 PERFORMANCE_IMPROVEMENTS.md delete mode 100644 README-ADMIN.md delete mode 100644 REFACTORING_SUMMARY.md delete mode 100644 docs/templates-and-builder.md create mode 100644 public/funnels/soulmate.json create mode 100644 public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg diff --git a/ANALYSIS_REPORT.md b/ANALYSIS_REPORT.md deleted file mode 100644 index 806b9aa..0000000 --- a/ANALYSIS_REPORT.md +++ /dev/null @@ -1,659 +0,0 @@ -# 🔍 ГЛУБОКИЙ АНАЛИЗ ПРОЕКТА - НАЙДЕННЫЕ ПРОБЛЕМЫ - -## 📊 ОБЩАЯ СТАТИСТИКА: -- **Всего строк кода:** ~21,000 -- **Тестов:** 0 (!) -- **Самые большие файлы:** 692, 617, 515 строк -- **Console.log/error:** 21 файлов -- **Process.env usage:** 7 файлов - ---- - -## 🔴 КРИТИЧЕСКИЕ ПРОБЛЕМЫ: - -### 1. ❌ ПОЛНОЕ ОТСУТСТВИЕ ТЕСТОВ -**Статус:** 🔴 КРИТИЧНО - -```bash -# Найдено тестов: 0 -find src -name "*.test.ts" -o -name "*.test.tsx" | wc -l -# Output: 0 -``` - -**Проблема:** -- Нет unit тестов -- Нет integration тестов -- Нет e2e тестов -- 21,000 строк кода без покрытия - -**Риски:** -- ❌ Регрессии не обнаруживаются -- ❌ Рефакторинг опасен -- ❌ Сложно онбординг новых разработчиков -- ❌ Баги попадают в production - -**Рекомендации:** -```typescript -// Приоритет 1: Критичная логика -src/lib/funnel/navigation.ts // 🔴 Условная навигация -src/lib/admin/builder/validation.ts // 🔴 Валидация воронок -src/lib/funnel/screenRenderer.tsx // 🔴 Рендеринг экранов - -// Приоритет 2: API endpoints -src/app/api/**/*.ts // 🟡 Все API routes - -// Приоритет 3: UI компоненты -src/components/funnel/templates/** // 🟢 Templates -``` - ---- - -### 2. 🔴 МОНСТР-ФАЙЛЫ НЕ РАЗБИТЫ - -**Топ-3 проблемных файла:** - -#### **ScreenVariantsConfig.tsx - 692 строки** -``` -Функции: -- ensureCondition -- VariantOverridesEditor -- ScreenVariantsConfig -- Множество внутренней логики - -Должно быть разбито на: -├── hooks/ -│ ├── useVariantState.ts -│ └── useVariantValidation.ts -├── components/ -│ ├── VariantConditionEditor.tsx -│ ├── VariantOverridesEditor.tsx -│ ├── VariantList.tsx -│ └── VariantPanel.tsx -└── ScreenVariantsConfig.tsx (orchestrator) -``` - -#### **BuilderSidebar.tsx - 617 строк** -``` -Проблема: Всё в одном файле -- Funnel settings -- Screen settings -- Navigation -- Variants -- Validation - -Решение: Уже созданы модули, но НЕ ИСПОЛЬЗУЮТСЯ! -✅ FunnelSettingsPanel.tsx (80 строк) -✅ ScreenSettingsPanel.tsx (110 строк) -✅ NavigationPanel.tsx (190 строк) - -❌ Но BuilderSidebar всё еще 617 строк! -``` - -#### **TemplateConfig.tsx - 515 строк** -``` -Проблема: Switch-case для всех templates -Решение: Template-specific конфигураторы уже есть! - -✅ InfoScreenConfig.tsx -✅ DateScreenConfig.tsx -✅ ListScreenConfig.tsx -✅ FormScreenConfig.tsx - -❌ Но всё равно огромный switch в TemplateConfig -``` - -**Метрика сложности:** -``` -> 500 строк = 🔴 Требует немедленной разбивки -> 300 строк = 🟡 Желательна разбивка -< 300 строк = 🟢 Приемлемо -``` - ---- - -### 3. 🟡 ОТСУТСТВИЕ ЛОГИРОВАНИЯ И МОНИТОРИНГА - -**Проблема:** -```typescript -// ❌ Console.log в production коде -console.log('✅ MongoDB connected successfully'); -console.error('Error rendering preview:', error); - -// Нет structured logging -// Нет error tracking (Sentry, etc.) -// Нет performance monitoring -``` - -**Найдено 21 файлов с console.log/error:** -- API routes: 10+ файлов -- Components: 5+ файлов -- Hooks: 3+ файла - -**Решение:** -```typescript -// lib/logger.ts -export const logger = { - info: (message: string, meta?: object) => { - if (process.env.NODE_ENV === 'development') { - console.log(`[INFO] ${message}`, meta); - } - // В production -> send to logging service - }, - error: (message: string, error: Error, meta?: object) => { - console.error(`[ERROR] ${message}`, error, meta); - // Send to Sentry/Datadog/etc. - }, - warn: (message: string, meta?: object) => { - console.warn(`[WARN] ${message}`, meta); - } -}; - -// Использование: -logger.error('Failed to fetch funnel', error, { funnelId, userId }); -``` - ---- - -### 4. 🟡 СЛАБАЯ ОБРАБОТКА ОШИБОК - -**Проблема:** -```typescript -// ❌ Пустые catch блоки -try { - formData = JSON.parse(formDataJson); -} catch { - formData = {}; -} - -// ❌ Только console.error -catch (error) { - console.error('Error loading images:', error); -} - -// ❌ Нет типизации ошибок -catch (error) { - // error: unknown - теряем type safety -} -``` - -**Найдено 40+ catch блоков:** -- 15 с только console.error -- 8 с пустым catch {} -- Остальные с минимальной обработкой - -**Решение:** -```typescript -// lib/errors.ts -export class FunnelError extends Error { - constructor( - message: string, - public code: string, - public statusCode: number = 500, - public meta?: object - ) { - super(message); - this.name = 'FunnelError'; - } -} - -export class ValidationError extends FunnelError { - constructor(message: string, meta?: object) { - super(message, 'VALIDATION_ERROR', 400, meta); - } -} - -// Использование: -try { - await saveFunnel(data); -} catch (error) { - if (error instanceof ValidationError) { - return NextResponse.json( - { error: error.message, code: error.code }, - { status: error.statusCode } - ); - } - - logger.error('Unexpected error', error as Error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); -} -``` - ---- - -### 5. 🟡 ОТСУТСТВИЕ ENV VALIDATION - -**Проблема:** -```typescript -// ❌ Прямое использование без валидации -const MONGODB_URI = process.env.MONGODB_URI!; - -// Что если переменная не задана? -// Что если формат неправильный? -// Ошибка обнаружится только в runtime! -``` - -**Найдено использование env в 7 файлах:** -- `MONGODB_URI` -- `NEXT_PUBLIC_*` -- `NODE_ENV` -- Никакой валидации при старте! - -**Решение:** -```typescript -// lib/env.ts -import { z } from 'zod'; - -const envSchema = z.object({ - MONGODB_URI: z.string().url().min(1), - NODE_ENV: z.enum(['development', 'production', 'test']), - NEXT_PUBLIC_API_URL: z.string().url().optional(), - // ... остальные переменные -}); - -export const env = envSchema.parse({ - MONGODB_URI: process.env.MONGODB_URI, - NODE_ENV: process.env.NODE_ENV, - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, -}); - -// Использование: -import { env } from '@/lib/env'; -const conn = await mongoose.connect(env.MONGODB_URI); -``` - -**Преимущества:** -- ✅ Ошибки обнаруживаются при старте -- ✅ Type-safe доступ к env vars -- ✅ Автокомплит в IDE -- ✅ Документация через zod schema - ---- - -### 6. 🟢 ОТСУТСТВИЕ API CLIENT СЛОЯ - -**Проблема:** -```typescript -// ❌ Fetch разбросан по компонентам -const response = await fetch('/api/funnels', { method: 'POST', ... }); -const response = await fetch(`/api/funnels/${id}`, { method: 'PUT', ... }); -const response = await fetch(`/api/funnels/${id}`, { method: 'DELETE', ... }); - -// Дублирование логики: -// - error handling -// - headers -// - JSON parsing -// - типизация -``` - -**Решение:** -```typescript -// lib/api/client.ts -class ApiClient { - private baseUrl = '/api'; - - private async request( - endpoint: string, - options?: RequestInit - ): Promise { - const url = `${this.baseUrl}${endpoint}`; - - try { - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - }); - - if (!response.ok) { - const error = await response.json(); - throw new ApiError(error.message, response.status); - } - - return await response.json(); - } catch (error) { - logger.error('API request failed', error as Error, { endpoint }); - throw error; - } - } - - funnels = { - list: () => this.request('/funnels'), - get: (id: string) => this.request(`/funnels/${id}`), - create: (data: CreateFunnelDto) => - this.request('/funnels', { - method: 'POST', - body: JSON.stringify(data), - }), - update: (id: string, data: UpdateFunnelDto) => - this.request(`/funnels/${id}`, { - method: 'PUT', - body: JSON.stringify(data), - }), - delete: (id: string) => - this.request(`/funnels/${id}`, { method: 'DELETE' }), - }; -} - -export const api = new ApiClient(); - -// Использование: -const funnels = await api.funnels.list(); -const funnel = await api.funnels.get(id); -``` - ---- - -### 7. 🟢 НЕДОСТАТОЧНАЯ ТИПИЗАЦИЯ API - -**Проблема:** -```typescript -// ❌ API routes без типизации запросов/ответов -export async function POST(request: Request) { - const body = await request.json(); // any - // ... -} - -// ❌ Нет shared типов между frontend и backend -// ❌ Нет валидации входных данных -``` - -**Решение:** -```typescript -// lib/api/schemas.ts -import { z } from 'zod'; - -export const CreateFunnelSchema = z.object({ - meta: z.object({ - id: z.string().min(1).max(100), - title: z.string().min(1), - description: z.string().optional(), - }), - screens: z.array(ScreenSchema).min(1), - defaultTexts: z.object({ - nextButton: z.string().optional(), - continueButton: z.string().optional(), - }).optional(), -}); - -export type CreateFunnelDto = z.infer; - -// app/api/funnels/route.ts -export async function POST(request: Request) { - try { - const body = await request.json(); - - // ✅ Валидация с zod - const data = CreateFunnelSchema.parse(body); - - // ✅ Типобезопасность - const funnel = await createFunnel(data); - - return NextResponse.json(funnel); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ); - } - throw error; - } -} -``` - ---- - -### 8. 🟡 PERFORMANCE: Нет индексов экранов - -**Проблема в screenRenderer.tsx:** -```typescript -// ❌ O(n) поиск при каждом рендере -const currentScreen = funnel.screens.find(s => s.id === currentScreenId); -const nextScreen = funnel.screens.find(s => s.id === nextScreenId); - -// При 50+ экранах = медленно -// При навигации = много поисков -``` - -**Решение:** -```typescript -// lib/funnel/FunnelRuntime.tsx -const screenMap = useMemo(() => { - return new Map(funnel.screens.map(s => [s.id, s])); -}, [funnel.screens]); - -// ✅ O(1) поиск -const currentScreen = screenMap.get(currentScreenId); -const nextScreen = screenMap.get(nextScreenId); -``` - -**Улучшение:** ~50x быстрее при 50+ экранах - ---- - -### 9. 🟢 ОТСУТСТВИЕ ДОКУМЕНТАЦИИ API - -**Проблема:** -``` -src/app/api/ -├── funnels/ -│ ├── route.ts // GET /api/funnels - что возвращает? -│ ├── [id]/ -│ │ ├── route.ts // GET/PUT/DELETE - параметры? -│ │ ├── duplicate/ -│ │ └── history/ -``` - -**Нет:** -- Swagger/OpenAPI spec -- JSDoc комментариев -- Примеров запросов -- Описания ошибок - -**Решение:** -```typescript -/** - * GET /api/funnels - * - * Получить список всех воронок - * - * Query params: - * - page?: number (default: 1) - * - limit?: number (default: 50, max: 100) - * - search?: string - * - * Response: 200 - * { - * funnels: Funnel[], - * total: number, - * page: number, - * totalPages: number - * } - * - * Errors: - * - 500: Database connection failed - * - * @example - * const response = await fetch('/api/funnels?page=1&limit=20'); - */ -export async function GET(request: Request) { - // ... -} -``` - ---- - -### 10. 🟡 MAGIC NUMBERS И STRINGS - -**Проблема:** -```typescript -// ❌ Magic numbers -style={{ height: 750, width: 320 }} -setTimeout(() => {}, 2000); -const limit = 50; - -// ❌ Magic strings -if (screen.template === "list") { } -font: "manrope" -weight: "semiBold" -``` - -**Решение:** -```typescript -// lib/constants.ts -export const PREVIEW_DIMENSIONS = { - WIDTH: 320, - HEIGHT: 750, - MOBILE_WIDTH: 375, -} as const; - -export const TIMEOUTS = { - TOAST_DURATION: 2000, - DEBOUNCE_INPUT: 500, - API_REQUEST: 30000, -} as const; - -export const PAGINATION = { - DEFAULT_LIMIT: 50, - MAX_LIMIT: 100, - DEFAULT_PAGE: 1, -} as const; - -// Использование: -style={{ - height: PREVIEW_DIMENSIONS.HEIGHT, - width: PREVIEW_DIMENSIONS.WIDTH -}} -``` - ---- - -## 📋 ПРИОРИТИЗАЦИЯ ИСПРАВЛЕНИЙ: - -### 🔴 ВЫСОКИЙ ПРИОРИТЕТ (немедленно): -1. ✅ **Добавить ENV validation** (30 мин) - предотвратит runtime ошибки -2. ✅ **Создать ApiClient** (2 часа) - унифицирует API вызовы -3. ✅ **Добавить error types** (1 час) - улучшит error handling -4. ✅ **Добавить logger** (1 час) - улучшит debugging - -### 🟡 СРЕДНИЙ ПРИОРИТЕТ (на неделе): -5. ✅ **Разбить ScreenVariantsConfig** (4 часа) -6. ✅ **Использовать модули вместо BuilderSidebar** (2 часа) -7. ✅ **Добавить screen Map для performance** (1 час) -8. ✅ **Вынести magic numbers в константы** (2 часа) - -### 🟢 НИЗКИЙ ПРИОРИТЕТ (на спринте): -9. ✅ **Написать unit тесты** (2-3 дня) -10. ✅ **Добавить API документацию** (1 день) -11. ✅ **Добавить Zod validation для API** (1 день) - ---- - -## 📊 МЕТРИКИ ПРОЕКТА: - -### **Code Quality:** -``` -├── TypeScript: ✅ Хорошо (strict mode) -├── Linting: ✅ Настроен ESLint -├── Formatting: ❓ Prettier не настроен? -├── Tests: ❌ Отсутствуют -└── Documentation: 🟡 Частично (README есть) -``` - -### **Architecture:** -``` -├── Component structure: 🟢 Хорошая -├── Type safety: 🟢 Хорошая -├── Code splitting: 🟡 Частичная -├── Performance: 🟡 Можно улучшить -└── Error handling: 🔴 Слабая -``` - -### **Maintainability:** -``` -├── File sizes: 🔴 Много больших файлов -├── Complexity: 🟡 Высокая в некоторых местах -├── Duplication: 🟢 Минимальная -├── Dependencies: 🟢 Актуальные -└── Documentation: 🟡 Недостаточная -``` - ---- - -## ✅ ВЫПОЛНЕНО (из предыдущего отчета): -- ✅ useDebounce hook -- ✅ usePersistedState hook -- ✅ Error Boundaries -- ✅ Optimized validation -- ✅ React.memo components -- ✅ Memoized preview mocks -- ✅ Module extraction (частично) - ---- - -## 🎯 СЛЕДУЮЩИЕ ШАГИ: - -### **Этап 1: Инфраструктура (1-2 дня)** -```bash -1. ENV validation с Zod -2. Logger service -3. Error types и handling -4. API client слой -``` - -### **Этап 2: Рефакторинг (3-5 дней)** -```bash -1. Разбить ScreenVariantsConfig -2. Использовать модули sidebar -3. Добавить screen Map -4. Вынести константы -``` - -### **Этап 3: Тестирование (1-2 недели)** -```bash -1. Setup test infrastructure -2. Unit tests для critical logic -3. Integration tests для API -4. E2E tests для key flows -``` - -### **Этап 4: Documentation (3-5 дней)** -```bash -1. API documentation (JSDoc/Swagger) -2. Architecture diagrams -3. Developer onboarding guide -4. Contribution guidelines -``` - ---- - -## 💡 РЕКОМЕНДАЦИИ: - -1. **Начните с инфраструктуры** - ENV validation и Logger предотвратят много проблем -2. **Добавьте тесты постепенно** - начните с критичной логики (navigation, validation) -3. **Разбивайте большие файлы** - используйте уже созданные модули -4. **Документируйте API** - это поможет новым разработчикам -5. **Мониторинг в production** - добавьте Sentry или аналог - ---- - -## 📈 ОЖИДАЕМЫЕ УЛУЧШЕНИЯ: - -После выполнения всех исправлений: - -| Метрика | Сейчас | После | -|---------|--------|-------| -| Test Coverage | 0% | 70%+ | -| Error Detection | Runtime | Build time | -| Maintainability | 6/10 | 9/10 | -| Performance | 7/10 | 9/10 | -| Developer Experience | 7/10 | 10/10 | - ---- - -**Проект в целом хороший, но есть критичные пробелы в инфраструктуре, тестировании и обработке ошибок!** diff --git a/BUILD_VARIANTS.md b/BUILD_VARIANTS.md deleted file mode 100644 index cefa09b..0000000 --- a/BUILD_VARIANTS.md +++ /dev/null @@ -1,232 +0,0 @@ -# Build Variants - Руководство - -Проект поддерживает два режима работы: **frontend** (без БД) и **full** (с MongoDB). - -## Режимы работы - -### 🎨 Frontend Mode (без БД) -- Только статические JSON файлы воронок -- Без админки и редактирования -- Нет загрузки изображений -- Быстрый старт без зависимостей - -### 🚀 Full Mode (с MongoDB) -- Полная функциональность админки -- Редактирование воронок в реальном времени -- Загрузка и хранение изображений -- История изменений -- Требует MongoDB подключение - -## Команды запуска - -### Development (разработка) - -```bash -# Frontend режим (без БД) -npm run dev -# или -npm run dev:frontend - -# Full режим (с MongoDB) -npm run dev:full -``` - -### Build (сборка) - -```bash -# Frontend режим -npm run build -# или -npm run build:frontend - -# Full режим -npm run build:full -``` - -### Production (продакшн) - -```bash -# Frontend режим -npm run start -# или -npm run start:frontend - -# Full режим -npm run start:full -``` - -## Как это работает - -### Скрипт `run-with-variant.mjs` - -Все команды используют скрипт `/scripts/run-with-variant.mjs`, который: - -1. Принимает команду и вариант: `node run-with-variant.mjs dev full` -2. Устанавливает environment переменные: - - `FUNNEL_BUILD_VARIANT=full|frontend` - - `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT=full|frontend` -3. Запускает Next.js с этими переменными - -### Runtime проверки - -В коде используется модуль `/src/lib/runtime/buildVariant.ts`: - -```typescript -import { IS_FRONTEND_ONLY_BUILD, IS_FULL_SYSTEM_BUILD } from '@/lib/runtime/buildVariant'; - -// В API endpoints -if (IS_FRONTEND_ONLY_BUILD) { - return NextResponse.json( - { error: 'Not available in frontend mode' }, - { status: 403 } - ); -} - -// Для условной логики -if (IS_FULL_SYSTEM_BUILD) { - // Код который работает только с БД -} -``` - -### Константы - -```typescript -import { BUILD_VARIANTS } from '@/lib/constants'; - -BUILD_VARIANTS.FRONTEND // 'frontend' -BUILD_VARIANTS.FULL // 'full' -``` - -## Environment файлы - -### `.env.local` (НЕ включать build variant!) - -```env -# ❌ НЕ НАДО: NEXT_PUBLIC_FUNNEL_BUILD_VARIANT=full -# Вместо этого используйте команды npm run dev:full / dev:frontend - -# MongoDB (нужно только для full режима) -MONGODB_URI=mongodb://localhost:27017/witlab-funnel - -# Базовый URL -NEXT_PUBLIC_BASE_URL=http://localhost:3000 -``` - -**Важно:** `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` НЕ должна быть в `.env.local`! -Она устанавливается автоматически через команды. - -### `.env.production` - -```env -# Только для production окружения -NODE_ENV=production -NEXT_PUBLIC_BASE_URL=https://your-domain.com -``` - -## API Endpoints - -Все API endpoints автоматически проверяют режим работы: - -### `/api/images/[filename]` - GET, DELETE -- ✅ Full mode: возвращает изображения из MongoDB -- ❌ Frontend mode: 403 Forbidden - -### `/api/images` - GET -- ✅ Full mode: список всех изображений -- ❌ Frontend mode: 403 Forbidden - -### `/api/images/upload` - POST -- ✅ Full mode: загрузка изображений в MongoDB -- ❌ Frontend mode: 403 Forbidden - -### `/api/funnels/*` -- ✅ Full mode: CRUD операции с воронками -- ❌ Frontend mode: 403 Forbidden - -## Типичные сценарии - -### Локальная разработка с админкой - -```bash -# 1. Запустить MongoDB -mongod --dbpath ./data - -# 2. Запустить в full режиме -npm run dev:full - -# 3. Открыть http://localhost:3000/admin -``` - -### Локальная разработка без БД - -```bash -# Просто запустить frontend режим -npm run dev - -# Или явно -npm run dev:frontend -``` - -### Production деплой (frontend only) - -```bash -# Собрать frontend версию -npm run build:frontend - -# Запустить -npm run start:frontend -``` - -### Production деплой (full stack) - -```bash -# Установить MONGODB_URI в .env.production -echo "MONGODB_URI=mongodb://..." > .env.production - -# Собрать full версию -npm run build:full - -# Запустить -npm run start:full -``` - -## Troubleshooting - -### Проблема: "Image serving not available" - -**Причина:** Запущен frontend режим, а используется API для изображений - -**Решение:** Перезапустить в full режиме: -```bash -npm run dev:full -``` - -### Проблема: "Cannot connect to MongoDB" - -**Причина:** MongoDB не запущен или неправильный URI - -**Решение:** -1. Проверить что MongoDB запущен: `mongosh` -2. Проверить MONGODB_URI в `.env.local` -3. Убедиться что используется `dev:full`, не `dev` - -### Проблема: Админка не работает - -**Причина:** Запущен frontend режим - -**Решение:** -```bash -npm run dev:full -``` - -## Итоговые рекомендации - -✅ **DO:** -- Использовать команды `npm run dev:full` / `dev:frontend` -- Держать `.env.local` без `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` -- Проверять `IS_FRONTEND_ONLY_BUILD` в API endpoints - -❌ **DON'T:** -- Не добавлять `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` в `.env.local` -- Не проверять `process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` напрямую -- Не смешивать логику frontend и full режимов diff --git a/PERFORMANCE_IMPROVEMENTS.md b/PERFORMANCE_IMPROVEMENTS.md deleted file mode 100644 index 6368fa2..0000000 --- a/PERFORMANCE_IMPROVEMENTS.md +++ /dev/null @@ -1,219 +0,0 @@ -# ✅ PERFORMANCE IMPROVEMENTS - ВЫПОЛНЕНО - -## Исправленные проблемы (10/10): - -### 1. ✅ useDebounce и usePersistedState hooks -**Файлы:** -- `/src/lib/admin/hooks/useDebounce.ts` - дебаунс для text inputs -- `/src/lib/admin/hooks/usePersistedState.ts` - сохранение UI состояния - -**Применение:** -- Text inputs в BuilderSidebar теперь могут использовать debounce -- Collapsed/expanded состояния сохраняются в sessionStorage - ---- - -### 2. ✅ Error Boundaries -**Файл:** `/src/components/admin/ErrorBoundary.tsx` - -**Компоненты:** -- `ErrorBoundary` - универсальный boundary -- `BuilderErrorBoundary` - для компонентов билдера -- `PreviewErrorBoundary` - для preview компонента - -**Использование:** -```tsx - - - - - - - -``` - ---- - -### 3. ✅ Оптимизированная validation -**Файл:** `BuilderSidebar.tsx` - -**Было:** -```typescript -const validation = useMemo(() => validateBuilderState(state), [state]); -``` - -**Стало:** -```typescript -const screenIds = useMemo(() => state.screens.map(s => s.id).join(','), [state.screens]); -const validation = useMemo( - () => validateBuilderState(state), - [ - state.meta.id, - state.meta.firstScreenId, - screenIds, - state.screens.length, - ] -); -``` - -**Улучшение:** Validation запускается только при изменении критичных полей, а не при каждом изменении state. - ---- - -### 4. ✅ React.memo для компонентов -**Файл:** `/src/components/admin/builder/Canvas/MemoizedComponents.tsx` - -**Мемоизированы:** -- `TemplateSummary` -- `VariantSummary` -- `TransitionRow` -- `DropIndicator` - -**Использование:** -```tsx -import { TemplateSummary, VariantSummary } from './MemoizedComponents'; -``` - -**Улучшение:** Компоненты списка не ре-рендерятся при изменении других экранов. - ---- - -### 5. ✅ Мемоизированные моки в BuilderPreview -**Файл:** `BuilderPreview.tsx` - -**Было:** -```typescript -onContinue: () => {}, // Новая функция каждый раз -onBack: () => {}, -screenProgress: { current: 1, total: 10 }, // Новый объект -``` - -**Стало:** -```typescript -const MOCK_CALLBACKS = { - onContinue: () => {}, - onBack: () => {}, -}; -const MOCK_PROGRESS = { current: 1, total: 10 }; - -// Используем в render -onContinue: MOCK_CALLBACKS.onContinue, -``` - -**Улучшение:** Моки создаются один раз, не вызывают лишних re-renders. - ---- - -### 6. ✅ Разбивка компонентов (частично) -**Созданы модули:** -- `FunnelSettingsPanel` - настройки воронки -- `ScreenSettingsPanel` - настройки экрана -- `NavigationPanel` - навигация - -**Статус:** Модули созданы, можно использовать вместо BuilderSidebar монолита. - ---- - -### 7. ✅ Lazy loading (документировано) -**Файл:** `/src/lib/admin/hooks/index.ts` - -**Рекомендация для будущего:** -```tsx -const TemplateConfig = lazy(() => import("@/components/admin/builder/templates")); -const ScreenVariantsConfig = lazy(() => import("../forms/ScreenVariantsConfig")); -``` - ---- - -### 8. ✅ Оптимизация BuilderCanvas useCallback -**Статус:** Проверены все useCallback - -**Рекомендации:** -- Убрать ненужные useCallback с пустыми зависимостями -- Использовать useRef для стабильных функций -- Мемоизировать только то, что реально передается в child компоненты - ---- - -### 9. 🔄 Виртуализация списков (опционально) -**Статус:** Документировано для будущего - -**Когда нужно:** При 50+ экранах в воронке - -**Библиотеки:** -- `react-window` -- `react-virtual` -- `@tanstack/react-virtual` - ---- - -### 10. ✅ Исправление глубоких сравнений -**Статус:** Оптимизация validation решила большую часть - -**Дополнительно:** -- Validation мемоизируется по критичным полям -- useCallback handlers не зависят от всего state - ---- - -## Метрики улучшений: - -| Проблема | Статус | Влияние | -|----------|--------|---------| -| Debounce для форм | ✅ Готов к использованию | 🟢 Высокое | -| Validation оптимизация | ✅ Внедрено | 🟢 Высокое | -| React.memo компоненты | ✅ Готовы | 🟡 Среднее | -| Мемоизация моков | ✅ Внедрено | 🟡 Среднее | -| Error Boundaries | ✅ Готовы | 🟡 Среднее | -| Разбивка компонентов | 🔄 Частично | 🟢 Высокое (maintainability) | -| Lazy loading | 📝 Документировано | 🟢 Высокое (initial load) | -| Оптимизация useCallback | ✅ Проверено | 🟢 Низкое | -| Виртуализация | 📝 Будущее | 🟡 Среднее (при >50 экранах) | -| Глубокие сравнения | ✅ Исправлено | 🟡 Среднее | - ---- - -## Следующие шаги: - -### Немедленно (можно применить сразу): -1. Использовать `MemoizedComponents` в `BuilderCanvas` -2. Обернуть критичные компоненты в Error Boundaries -3. Применить `useDebounce` для text inputs в формах - -### Скоро (когда будет время): -1. Полностью заменить `BuilderSidebar` на модули -2. Добавить lazy loading для тяжелых компонентов -3. Использовать `usePersistedState` для collapsed sections - -### В будущем (при необходимости): -1. Виртуализация списка экранов (при >50 экранах) -2. Code splitting для admin bundle -3. Service Worker для кэширования - ---- - -## Готовые к использованию утилиты: - -### Hooks: -```tsx -import { useDebounce, useDebouncedCallback } from '@/lib/admin/hooks/useDebounce'; -import { usePersistedState } from '@/lib/admin/hooks/usePersistedState'; -``` - -### Error Boundaries: -```tsx -import { BuilderErrorBoundary, PreviewErrorBoundary } from '@/components/admin/ErrorBoundary'; -``` - -### Memoized Components: -```tsx -import { TemplateSummary, VariantSummary, TransitionRow, DropIndicator } from './Canvas/MemoizedComponents'; -``` - ---- - -## Результат: -✅ **Все 10 проблем решены или задокументированы** -✅ **Создана инфраструктура для performance оптимизаций** -✅ **Проект собирается без ошибок** -✅ **TypeScript компиляция чистая** diff --git a/README-ADMIN.md b/README-ADMIN.md deleted file mode 100644 index df8d2e4..0000000 --- a/README-ADMIN.md +++ /dev/null @@ -1,218 +0,0 @@ -# WitLab Funnel Admin - Полноценная админка с MongoDB - -## Что реализовано - -### ✅ База данных MongoDB -- **Подключение через Mongoose** с автоматическим переподключением -- **Модели для воронок** с полной валидацией структуры данных -- **История изменений** для системы undo/redo -- **Индексы для производительности** поиска и фильтрации - -### ✅ API Routes -- `GET /api/funnels` - список воронок с пагинацией и фильтрами -- `POST /api/funnels` - создание новой воронки -- `GET /api/funnels/[id]` - получение конкретной воронки -- `PUT /api/funnels/[id]` - обновление воронки -- `DELETE /api/funnels/[id]` - удаление воронки (только черновики) -- `POST /api/funnels/[id]/duplicate` - дублирование воронки -- `GET/POST /api/funnels/[id]/history` - работа с историей изменений -- `GET /api/funnels/by-funnel-id/[funnelId]` - загрузка по funnel ID (для совместимости) - -### ✅ Каталог воронок `/admin` -- **Список всех воронок** с поиском, фильтрацией и сортировкой -- **Создание новых воронок** с базовым шаблоном -- **Дублирование существующих** воронок -- **Удаление черновиков** (опубликованные можно только архивировать) -- **Статистика использования** (просмотры, завершения) -- **Статусы**: draft, published, archived - -### ✅ Редактор воронок `/admin/builder/[id]` -- **Полноценный билдер** интегрированный с существующей архитектурой -- **Автосохранение** изменений в базу данных -- **Система публикации** с контролем версий -- **Топ бар** с информацией о воронке и кнопками действий -- **Экспорт/импорт JSON** для резервного копирования - -### ✅ Система undo/redo -- **История действий** с глубиной до 50 шагов -- **Базовые точки** при сохранении в БД (после сохранения нельзя откатить) -- **Несохраненные изменения** отслеживаются отдельно -- **Автоматическая очистка** старых записей истории - -### ✅ Интеграция с существующим кодом -- **Обратная совместимость** с JSON файлами -- **Приоритет базы данных** при загрузке воронок -- **Автоматическое увеличение статистики** при просмотрах -- **Единый API** для всех компонентов системы - -## Настройка окружения - -### 1. MongoDB Connection -Создайте `.env.local` файл: -```bash -# MongoDB -MONGODB_URI=mongodb://localhost:27017/witlab-funnel -# или для MongoDB Atlas: -# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/witlab-funnel - -# Base URL (для server-side запросов) -NEXT_PUBLIC_BASE_URL=http://localhost:3000 -``` - -### 2. Установка MongoDB локально -```bash -# macOS (через Homebrew) -brew install mongodb-community -brew services start mongodb-community - -# Или используйте MongoDB Atlas (облако) -``` - -### 3. Запуск проекта -```bash -npm install -npm run dev:full -``` - -> ⚠️ Админка и API доступны только в режиме **Full system**. Для статичного фронта без админки используйте `npm run dev:frontend`, `npm run build` (или `npm run build:frontend`) и `npm run start` (или `npm run start:frontend`). - -## Использование - -### Создание новой воронки -1. Перейдите на `/admin` -2. Нажмите "Создать воронку" -3. Автоматически откроется билдер с базовым шаблоном -4. Редактируйте экраны в правом сайдбаре -5. Сохраняйте изменения кнопкой "Сохранить" -6. Публикуйте готовую воронку кнопкой "Опубликовать" - -### Редактирование существующей воронки -1. В каталоге найдите нужную воронку -2. Нажмите иконку "Редактировать" (карандаш) -3. Внесите изменения в билдере -4. Сохраните или опубликуйте - -### Просмотр воронки -1. Нажмите иконку "Просмотр" (глаз) в каталоге -2. Или перейдите на `/{funnelId}` напрямую - -### Дублирование воронки -1. Нажмите иконку "Дублировать" (копия) -2. Создастся копия со статусом "Черновик" -3. Можете отредактировать и опубликовать - -## Архитектура - -### Модели данных -```typescript -// Основная модель воронки -interface IFunnel { - funnelData: FunnelDefinition; // JSON структура воронки - name: string; // Человеко-читаемое имя - status: 'draft' | 'published' | 'archived'; - version: number; // Автоинкремент при изменениях - usage: { // Статистика - totalViews: number; - totalCompletions: number; - }; -} - -// История изменений -interface IFunnelHistory { - funnelId: string; // Связь с воронкой - sessionId: string; // Сессия редактирования - funnelSnapshot: FunnelDefinition; // Снимок состояния - sequenceNumber: number; // Порядок в сессии - isBaseline: boolean; // Сохранено в БД -} -``` - -### API Architecture -- **RESTful API** с правильными HTTP методами -- **Валидация данных** на уровне Mongoose схем -- **Обработка ошибок** с понятными сообщениями -- **Пагинация** для больших списков -- **Фильтрация и поиск** по всем полям - -### Frontend Architecture -- **Server Components** для статической генерации -- **Client Components** для интерактивности -- **Единый API клиент** через fetch -- **TypeScript типы** для всех данных -- **Error Boundaries** для обработки ошибок - -## Безопасность - -### Текущие меры -- **Валидация входных данных** на всех уровнях -- **Проверка существования** ресурсов перед операциями -- **Ограничения на удаление** опубликованных воронок -- **Санитизация пользовательского ввода** - -### Будущие улучшения -- Аутентификация пользователей -- Авторизация по ролям -- Аудит лог действий -- Rate limiting для API - -## Производительность - -### Текущая оптимизация -- **MongoDB индексы** для быстрого поиска -- **Пагинация** вместо загрузки всех записей -- **Selective loading** - только нужные поля -- **Connection pooling** для базы данных - -### Мониторинг -- **Логирование ошибок** в консоль -- **Время выполнения** запросов отслеживается -- **Размер истории** ограничен (100 записей на сессию) - -## Миграция с JSON - -Существующие JSON воронки продолжают работать автоматически: -1. **Приоритет базы данных** - сначала поиск в MongoDB -2. **Fallback на JSON** - если не найдено в базе -3. **Импорт из JSON** - можно загрузить JSON в билдере -4. **Экспорт в JSON** - для резервного копирования - -## Roadmap - -### Ближайшие планы -- [x] Основная функциональность админки -- [x] Система undo/redo -- [x] Интеграция с существующим кодом -- [ ] Аутентификация пользователей -- [ ] Collaborative editing -- [ ] Advanced аналитика - -### Долгосрочные цели -- [ ] Multi-tenant архитектура -- [ ] A/B тестирование воронок -- [ ] Интеграция с внешними сервисами -- [ ] Mobile app для мониторинга - -## Техническая поддержка - -### Логи и отладка -```bash -# Проверка подключения к MongoDB -curl http://localhost:3000/api/funnels - -# Просмотр логов в консоли разработчика -# MongoDB connection logs в терминале -``` - -### Частые проблемы -1. **MongoDB not connected** - проверьте MONGODB_URI в .env.local -2. **API errors** - проверьте сетевое соединение -3. **Build errors** - убедитесь что все зависимости установлены - -### Контакты -- GitHub Issues для багрепортов -- Документация в `/docs/` -- Комментарии в коде для сложных частей - ---- - -**Полноценная админка с MongoDB готова к использованию! 🚀** diff --git a/REFACTORING_SUMMARY.md b/REFACTORING_SUMMARY.md deleted file mode 100644 index 64c4a4e..0000000 --- a/REFACTORING_SUMMARY.md +++ /dev/null @@ -1,170 +0,0 @@ -# ✅ Рефакторинг завершен успешно - -## Выполненные задачи - -### 1. ✅ ENV validation с Zod -**Файл:** `/src/lib/env.ts` - -- Создана схема валидации с Zod для всех environment переменных -- Валидация происходит при запуске приложения -- Понятные сообщения об ошибках при неправильных значениях -- Типобезопасный доступ к переменным окружения - -**Валидируемые переменные:** -- `MONGODB_URI` - опциональная строка для подключения к БД -- `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` - frontend | full -- `NEXT_PUBLIC_BASE_URL` - базовый URL приложения -- `NODE_ENV` - development | production | test - -### 2. ✅ Screen Map для performance -**Файл:** `/src/components/funnel/FunnelRuntime.tsx` - -- Добавлен `useMemo` для создания Map экранов по ID -- Поиск экранов теперь O(1) вместо O(n) -- Улучшена производительность при навигации в больших воронках - -```typescript -const screenMap = useMemo(() => { - const map = new Map(); - funnel.screens.forEach(screen => map.set(screen.id, screen)); - return map; -}, [funnel.screens]); -``` - -### 3. ✅ ScreenVariantsConfig разбит на модули -**Директория:** `/src/components/admin/builder/forms/variants/` - -Созданы файлы: -- **types.ts** - типы для вариантов -- **utils.ts** - утилиты (ensureCondition, и т.д.) -- **VariantPanel.tsx** - панель управления одним вариантом -- **VariantConditionEditor.tsx** - редактор условий -- **VariantOverridesEditor.tsx** - редактор переопределений -- **index.ts** - экспорты модуля - -**Преимущества:** -- Каждый компонент < 200 строк кода -- Четкое разделение ответственности -- Легко тестировать отдельные части -- Переиспользуемые компоненты - -### 4. ✅ Sidebar модули вместо монолита -**Статус:** Готово к использованию - -Модульная структура variants теперь используется в ScreenVariantsConfig: -- Главный компонент управляет только состоянием -- Логика условий и переопределений вынесена в отдельные модули -- Улучшена читаемость и поддерживаемость - -### 5. ✅ Вынесены все константы -**Файл:** `/src/lib/constants.ts` - -Все magic numbers и strings теперь в одном месте: - -```typescript -// Build варианты -export const BUILD_VARIANTS = { - FULL: 'full', - FRONTEND: 'frontend', -} as const; - -// API endpoints -export const API_ENDPOINTS = { - IMAGES_UPLOAD: '/api/images/upload', - RAW_IMAGE: '/api/raw-image', - TEST_IMAGE: '/api/test-image', -} as const; - -// Preview размеры -export const PREVIEW_DIMENSIONS = { - WIDTH: 375, - HEIGHT: 667, -} as const; - -// Database -export const DB_COLLECTIONS = { - FUNNELS: 'funnels', - IMAGES: 'images', -} as const; -``` - -### 6. ✅ Обновлены импорты везде - -Обновленные файлы: -- `/src/components/admin/builder/layout/BuilderPreview.tsx` - PREVIEW_DIMENSIONS -- `/src/lib/runtime/buildVariant.ts` - BUILD_VARIANTS, env -- `/src/lib/mongodb.ts` - env, DB_COLLECTIONS -- `/src/components/admin/builder/forms/ImageUpload.tsx` - BUILD_VARIANTS, env -- `/src/app/[funnelId]/page.tsx` - BAKED_FUNNELS - -### 7. ✅ Проверка сборки и lint - -**Build:** ✅ Успешно -```bash -npm run build -# ✓ Compiled successfully -``` - -**Lint:** ✅ Без ошибок -```bash -npm run lint -# No errors found -``` - -## Архитектурные улучшения - -### DRY (Don't Repeat Yourself) -- Константы вынесены в единое место -- Убрано дублирование magic numbers -- Переиспользуемые модули вариантов - -### Single Source of Truth -- env переменные валидируются в одном месте -- Константы определены централизованно -- Типы для вариантов в отдельном файле - -### Модульность -- ScreenVariantsConfig разбит на 6 файлов -- Каждый модуль отвечает за одну задачу -- Легко добавлять новые функции - -### Type Safety -- Zod валидация для env -- TypeScript типы для всех констант -- Строгая типизация вариантов - -## Статистика - -**Создано файлов:** 7 -- `/src/lib/env.ts` -- `/src/lib/constants.ts` -- `/src/components/admin/builder/forms/variants/types.ts` -- `/src/components/admin/builder/forms/variants/utils.ts` -- `/src/components/admin/builder/forms/variants/VariantPanel.tsx` -- `/src/components/admin/builder/forms/variants/VariantConditionEditor.tsx` -- `/src/components/admin/builder/forms/variants/VariantOverridesEditor.tsx` - -**Обновлено файлов:** 8 -- FunnelRuntime.tsx (Screen Map) -- BuilderPreview.tsx (константы) -- buildVariant.ts (env + константы) -- mongodb.ts (env + константы) -- ImageUpload.tsx (константы) -- ScreenVariantsConfig.tsx (модули) -- app/[funnelId]/page.tsx (константы) -- variants/index.ts (экспорты) - -**Удалено:** 1 -- ScreenVariantsConfig.old.tsx - -## Результат - -✅ **Проект полностью собирается и работает** -✅ **Нет ошибок TypeScript** -✅ **Нет ошибок ESLint** -✅ **Все константы централизованы** -✅ **ENV валидация работает** -✅ **Модульная структура готова** -✅ **Performance улучшен (Screen Map)** - -Рефакторинг завершен успешно без участия пользователя! diff --git a/docs/templates-and-builder.md b/docs/templates-and-builder.md deleted file mode 100644 index 3e1130b..0000000 --- a/docs/templates-and-builder.md +++ /dev/null @@ -1,232 +0,0 @@ -# Шаблоны экранов и конструктор воронки - -Этот документ описывает, из каких частей состоит JSON-конфигурация воронки, какие шаблоны экранов доступны в рантайме и как с ними работает конструктор (builder). Используйте его как справочник при ручном редактировании JSON или при настройке воронки через интерфейс администратора. - -## Архитектура воронки - -Воронка описывается объектом `FunnelDefinition` и состоит из двух частей: метаданных и списка экранов. Навигация осуществляется по идентификаторам экранов, а состояние (выборы пользователя) хранится отдельно в рантайме. - -```ts -interface FunnelDefinition { - meta: { - id: string; - version?: string; - title?: string; - description?: string; - firstScreenId?: string; // стартовый экран, по умолчанию первый в списке - }; - defaultTexts?: { - nextButton?: string; - continueButton?: string; - }; - screens: ScreenDefinition[]; // набор экранов разных шаблонов -} -``` - -Каждый экран обязан иметь уникальный `id` и поле `template`, которое выбирает шаблон визуализации. Дополнительно поддерживаются: - -- `header` — управляет прогресс-баром, заголовком и кнопкой «Назад». По умолчанию шапка показывается, а прогресс вычисляется автоматически в рантайме. -- `bottomActionButton` — универсальное описание основной кнопки («Продолжить», «Далее» и т. п.). Шаблон может переопределить или скрыть её. -- `navigation` — правила переходов между экранами. - -### Навигация - -Навигация описывается объектом `NavigationDefinition`: - -```ts -interface NavigationDefinition { - defaultNextScreenId?: string; // переход по умолчанию - rules?: Array<{ - nextScreenId: string; // куда перейти, если условие выполнено - conditions: Array<{ - screenId: string; // экран, чьи ответы проверяем - operator?: "includesAny" | "includesAll" | "includesExactly"; - optionIds: string[]; // выбранные опции, которые проверяются - }>; - }>; -} -``` - -Рантайм использует первый сработавший `rule` и только после этого обращается к `defaultNextScreenId`. Для списков с одиночным выбором и скрытой кнопкой переход совершается автоматически при изменении ответа. Для всех прочих шаблонов пользователь должен нажать действие, сконфигурированное для текущего экрана. - -## Шаблоны экранов - -Ниже приведено краткое описание каждого шаблона и JSON-поле, которое его конфигурирует. - -### Информационный экран (`template: "info"`) - -Используется для показа статических сообщений, промо-блоков или инструкций. Обязательные поля — `id`, `template`, `title`. Дополнительно поддерживаются: - -- `description` — расширенный текст под заголовком. -- `icon` — эмодзи или картинка. `type` принимает значения `emoji` или `image`, `value` — символ или URL, `size` — `sm | md | lg | xl`. -- `bottomActionButton` — описание кнопки внизу, если нужно отличное от дефолтного текста. - -```json -{ - "id": "welcome", - "template": "info", - "title": { "text": "Добро пожаловать" }, - "description": { "text": "Заполните короткую анкету, чтобы получить персональное предложение." }, - "icon": { "type": "emoji", "value": "👋", "size": "lg" }, - "navigation": { "defaultNextScreenId": "question-1" } -} -``` - -Рантайм выводит заголовок по центру, кнопку «Next» (или `defaultTexts.nextButton`) и позволяет вернуться назад, если это разрешено в `header`. Логика описана в `InfoTemplate` и `buildLayoutQuestionProps` — дополнительные параметры (`font`, `color`, `align`) влияют на типографику.【F:src/components/funnel/templates/InfoTemplate.tsx†L1-L99】【F:src/lib/funnel/types.ts†L74-L131】 - -### Экран с вопросом и вариантами (`template: "list"`) - -Базовый интерактивный экран. Поле `list` описывает варианты ответов: - -```json -{ - "id": "question-1", - "template": "list", - "title": { "text": "Какой формат подходит?" }, - "subtitle": { "text": "Можно выбрать несколько", "color": "muted" }, - "list": { - "selectionType": "multi", // или "single" - "options": [ - { "id": "opt-online", "label": "Онлайн" }, - { "id": "opt-offline", "label": "Офлайн", "description": "в вашем городе" } - ], - "bottomActionButton": { "text": "Сохранить выбор" } - }, - "bottomActionButton": { "show": false }, - "navigation": { - "defaultNextScreenId": "calendar", - "rules": [ - { - "nextScreenId": "coupon", - "conditions": [{ - "screenId": "question-1", - "operator": "includesAll", - "optionIds": ["opt-online", "opt-offline"] - }] - } - ] - } -} -``` - -Особенности: - -- `selectionType` определяет поведение: `single` строит радиокнопки, `multi` — чекбоксы. Компоненты `RadioAnswersList` и `SelectAnswersList` получают подготовленные данные из `mapListOptionsToButtons`. -- Кнопка действия может описываться либо на уровне `list.bottomActionButton`, либо через общий `bottomActionButton`. В рантайме она скрывается, если `show: false`. Для списков с одиночным выбором и скрытой кнопкой включается автопереход на следующий экран при изменении ответа.【F:src/components/funnel/templates/ListTemplate.tsx†L1-L109】【F:src/components/funnel/FunnelRuntime.tsx†L73-L199】 -- Ответы сохраняются в массиве строк (идентификаторы опций) и используются навигацией и аналитикой. - -### Экран выбора даты (`template: "date"`) - -Предлагает три выпадающих списка (месяц, день, год) и опциональный блок с отформатированной датой. - -```json -{ - "id": "calendar", - "template": "date", - "title": { "text": "Когда планируете начать?" }, - "subtitle": { "text": "Выберите ориентировочную дату", "color": "muted" }, - "dateInput": { - "monthLabel": "Месяц", - "dayLabel": "День", - "yearLabel": "Год", - "showSelectedDate": true, - "selectedDateLabel": "Вы выбрали" - }, - "infoMessage": { "text": "Мы не будем делиться датой с третьими лицами." }, - "navigation": { "defaultNextScreenId": "contact" } -} -``` - -Особенности: - -- Значение сохраняется как массив `[month, day, year]` внутри `answers` рантайма. -- Кнопка «Next» активируется только после заполнения всех полей. Настройка текстов и подсказок — через объект `dateInput` (placeholder, label, формат для превью). -- При `showSelectedDate: true` под кнопкой появляется подтверждающий блок с читабельной датой.【F:src/components/funnel/templates/DateTemplate.tsx†L1-L209】【F:src/lib/funnel/types.ts†L133-L189】 - -### Экран формы (`template: "form"`) - -Подходит для сбора контактных данных. Поле `fields` содержит список текстовых инпутов со своими правилами. - -```json -{ - "id": "contact", - "template": "form", - "title": { "text": "Оставьте контакты" }, - "fields": [ - { "id": "name", "label": "Имя", "required": true, "maxLength": 60 }, - { - "id": "email", - "label": "E-mail", - "type": "email", - "validation": { - "pattern": "^\\S+@\\S+\\.\\S+$", - "message": "Введите корректный e-mail" - } - } - ], - "validationMessages": { - "required": "Поле ${field} обязательно", - "invalidFormat": "Неверный формат" - }, - "navigation": { "defaultNextScreenId": "coupon" } -} -``` - -Особенности рантайма: - -- Локальное состояние синхронизируется с глобальным через `onFormDataChange` — данные сериализуются в JSON-строку и хранятся в массиве ответов (первый элемент).【F:src/components/funnel/FunnelRuntime.tsx†L46-L118】 -- Кнопка продолжения (`defaultTexts.continueButton` или «Continue») активна, если все обязательные поля заполнены. Валидаторы проверяют `required`, `maxLength` и регулярное выражение из `validation.pattern` с кастомными сообщениями.【F:src/components/funnel/templates/FormTemplate.tsx†L1-L119】【F:src/lib/funnel/types.ts†L191-L238】 - -### Экран промокода (`template: "coupon"`) - -Отображает купон с акцией и позволяет скопировать промокод. - -```json -{ - "id": "coupon", - "template": "coupon", - "title": { "text": "Поздравляем!" }, - "subtitle": { "text": "Получите скидку" }, - "coupon": { - "title": { "text": "Скидка 20%" }, - "offer": { - "title": { "text": "-20% на первый заказ" }, - "description": { "text": "Действует до конца месяца" } - }, - "promoCode": { "text": "START20" }, - "footer": { "text": "Скопируйте код и введите при оформлении" } - }, - "copiedMessage": "Код {code} скопирован!", - "navigation": { "defaultNextScreenId": "final-info" } -} -``` - -`CouponTemplate` копирует код в буфер обмена и показывает уведомление `copiedMessage` (строка с подстановкой `{code}`). Кнопка продолжения использует `defaultTexts.continueButton` или значение «Continue».【F:src/components/funnel/templates/CouponTemplate.tsx†L1-L111】【F:src/lib/funnel/types.ts†L191-L230】 - -## Конструктор (Builder) - -Конструктор помогает собирать JSON-конфигурацию и состоит из трёх основных областей: - -1. **Верхняя панель** (`BuilderTopBar`). Позволяет создать пустой проект, загрузить готовый JSON и экспортировать текущую конфигурацию. Импорт использует `deserializeFunnelDefinition`, добавляющий служебные координаты для канваса. Экспорт сериализует состояние обратно в формат `FunnelDefinition` (`serializeBuilderState`).【F:src/components/admin/builder/BuilderTopBar.tsx†L1-L79】【F:src/lib/admin/builder/utils.ts†L1-L58】 -2. **Канвас** (`BuilderCanvas`). Отображает экраны цепочкой, даёт возможность добавлять новые (`add-screen`), менять порядок drag-and-drop (`reorder-screens`) и выбирать экран для редактирования. Каждый экран показывает тип шаблона, количество опций и ссылку на следующий экран по умолчанию.【F:src/components/admin/builder/BuilderCanvas.tsx†L1-L132】 -3. **Боковая панель** (`BuilderSidebar`). Содержит две вкладки состояния: - - Когда экран не выбран, показываются настройки воронки (ID, заголовок, описание, стартовый экран) и сводка валидации (`validateBuilderState`).【F:src/components/admin/builder/BuilderSidebar.tsx†L1-L188】【F:src/lib/admin/builder/validation.ts†L1-L168】 - - Для выбранного экрана доступны поля заголовков, параметры списка (тип выбора, опции), правила навигации, кастомизация кнопок и инструмент удаления. Все изменения отправляются через `update-screen`, `update-navigation` и вспомогательные обработчики, формируя корректный JSON. - -### Предпросмотр - -Компонент `BuilderPreview` визуализирует выбранный экран, используя те же шаблоны, что и боевой рантайм (`ListTemplate`, `InfoTemplate` и др.). Для симуляции действий используются заглушки — выбор опций, заполнение формы и навигация обновляют локальное состояние предпросмотра, но не меняют структуру воронки. При переключении экрана состояние сбрасывается, что позволяет увидеть дефолтное поведение каждого шаблона.【F:src/components/admin/builder/BuilderPreview.tsx†L1-L123】 - -### Валидация и сериализация - -`validateBuilderState` проверяет уникальность идентификаторов экранов и опций, корректность ссылок в навигации и наличие переходов. Ошибки и предупреждения отображаются в боковой панели. При экспорте координаты канваса удаляются, чтобы JSON соответствовал ожиданиям рантайма. Ответы пользователей рантайм хранит в структуре `Record`, где ключ — `id` экрана, а значение — массив выбранных значений (опций, компонентов даты или сериализованные данные формы).【F:src/lib/admin/builder/validation.ts†L1-L168】【F:src/lib/admin/builder/utils.ts†L1-L86】【F:src/components/funnel/FunnelRuntime.tsx†L1-L215】 - -## Рабочий процесс - -1. Создайте экраны через верхнюю панель или кнопку на канвасе. Каждый новый экран получает уникальный ID (`screen-{n}`). -2. Настройте порядок переходов drag-and-drop и установите `firstScreenId`, если стартовать нужно не с первого элемента. -3. Заполните контент для каждого шаблона, настройте условия в `navigation.rules` и убедитесь, что `defaultNextScreenId` указан для веток без правил. -4. Проверьте сводку валидации — при ошибках экспорт JSON будет возможен, но рантайм может не смочь построить маршрут. -5. Экспортируйте JSON и передайте его рантайму (``). - -Такой подход гарантирует, что конструктор и рантайм используют одну и ту же схему данных, а визуальные шаблоны ведут себя предсказуемо при изменении конфигурации. diff --git a/public/funnels/soulmate.json b/public/funnels/soulmate.json new file mode 100644 index 0000000..d86f1bb --- /dev/null +++ b/public/funnels/soulmate.json @@ -0,0 +1,1581 @@ +{ + "meta": { + "id": "soulmate", + "title": "Soulmate V1", + "description": "Soulmate", + "firstScreenId": "onboarding" + }, + "defaultTexts": { + "nextButton": "Next", + "privacyBanner": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем." + }, + "screens": [ + { + "id": "onboarding", + "template": "soulmate", + "header": { + "showBackButton": false, + "show": false + }, + "title": { + "text": "Soulmate Portrait", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Все начинается с тебя! Выбери свой пол.\nВсего 2 минуты — и Портрет откроет того, кто связан с тобой судьбой.\nПоразительная точность 99%.\nТебя ждёт неожиданное открытие.\nОсталось лишь осмелиться взглянуть.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "Counitue", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": true + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "gender", + "isEndScreen": false + }, + "description": { + "text": "Ваш персональный портрет почти готов.", + "show": true, + "font": "manrope", + "weight": "regular", + "size": "md", + "align": "center", + "color": "default" + }, + "fields": [], + "list": { + "options": [] + }, + "variants": [] + }, + { + "id": "gender", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Какого ты пола?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Все начинается с тебя! Выбери свой пол.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-status", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "female", + "label": "FEMALE", + "emoji": "🩷", + "disabled": false + }, + { + "id": "male", + "label": "MALE", + "emoji": "💙", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-status", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Вы сейчаc?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Это нужно, чтобы портрет и советы были точнее.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "analysis-target", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "in_relationship", + "label": "В отношениях", + "disabled": false + }, + { + "id": "single", + "label": "Свободны", + "disabled": false + }, + { + "id": "after_breakup", + "label": " После расставания", + "disabled": false + }, + { + "id": "its_complicated", + "label": "Всё сложно", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "analysis-target", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Кого анализируем?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-age", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "current_partner", + "label": "Текущего партнёра", + "disabled": false + }, + { + "id": "crush", + "label": "Человека, который нравится", + "disabled": false + }, + { + "id": "ex_partner", + "label": "Бывшего", + "disabled": false + }, + { + "id": "future_date", + "label": "Будущую встречу", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-age", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Возраст текущего партнера", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [ + { + "conditions": [ + { + "screenId": "partner-age", + "conditionType": "options", + "operator": "includesAny", + "optionIds": [ + "under_29" + ], + "values": [] + } + ], + "nextScreenId": "partner-age-detail" + } + ], + "defaultNextScreenId": "partner-ethnicity", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "under_29", + "label": "До 29", + "disabled": false + }, + { + "id": "from_30_to_39", + "label": "30–39", + "disabled": false + }, + { + "id": "from_40_to_49", + "label": "40–49", + "disabled": false + }, + { + "id": "from_50_to_59", + "label": "50–59", + "disabled": false + }, + { + "id": "over_60", + "label": "60+", + "disabled": false + } + ] + }, + "variants": [ + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "current_partner" + ] + } + ] + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "crush" + ] + } + ], + "overrides": { + "title": { + "text": "Возраст человека, который нравится" + } + } + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "ex_partner" + ] + } + ], + "overrides": { + "title": { + "text": "Возраст бывшего" + } + } + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "future_date" + ] + } + ], + "overrides": { + "title": { + "text": "Возраст будущего партнёра" + } + } + } + ] + }, + { + "id": "partner-age-detail", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Уточните чуть точнее", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Чтобы портрет был максимально похож.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-ethnicity", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "from_18_to_21", + "label": "18–21", + "disabled": false + }, + { + "id": "from_22_to_25", + "label": "22–25", + "disabled": false + }, + { + "id": "from_26_to_29", + "label": "26–29", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-ethnicity", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Этническая принадлежность твоей второй половинки?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-eye-color", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "white", + "label": "White", + "disabled": false + }, + { + "id": "hispanic_latino", + "label": "Hispanic / Latino", + "disabled": false + }, + { + "id": "african_african_american", + "label": "African / African-American", + "disabled": false + }, + { + "id": "asian", + "label": "Asian", + "disabled": false + }, + { + "id": "indian_south_asian", + "label": "Indian / South Asian", + "disabled": false + }, + { + "id": "middle_eastern_arab", + "label": "Middle Eastern / Arab", + "disabled": false + }, + { + "id": "native_american_indigenous", + "label": "Native American / Indigenous", + "disabled": false + }, + { + "id": "no_preference", + "label": "No preference", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-eye-color", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Цвет глаз твоей второй половинки?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-hair-length", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "brown", + "label": "Brown", + "disabled": false + }, + { + "id": "blue", + "label": "Голубые", + "disabled": false + }, + { + "id": "hazel", + "label": "Hazel", + "disabled": false + }, + { + "id": "green", + "label": "Green", + "disabled": false + }, + { + "id": "amber", + "label": "Янтарные", + "disabled": false + }, + { + "id": "gray", + "label": "Серые", + "disabled": false + }, + { + "id": "unknown", + "label": "Не знаю", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-hair-length", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Выберите длину волос", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "От неё зависит форма и настроение портрета.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "burnout-support", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "short", + "label": "Короткие", + "disabled": false + }, + { + "id": "medium", + "label": "Средние", + "disabled": false + }, + { + "id": "long", + "label": "Длинные", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "burnout-support", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Когда ты выгораешь, тебе нужно чтобы партнёр", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "burnout-result", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "acknowledged_and_calmed", + "label": "Признал ваше разочарование и успокоил", + "disabled": false + }, + { + "id": "gave_emotional_support", + "label": "Дал эмоциональную опору и безопасное пространство", + "disabled": false + }, + { + "id": "took_over_tasks", + "label": "Перехватил быт/дела, чтобы вы восстановились", + "disabled": false + }, + { + "id": "inspired_with_plan", + "label": "Вдохнул энергию через цель и короткий план действий", + "disabled": false + }, + { + "id": "shifted_to_positive", + "label": "Переключил на позитив: прогулка, кино, смешные истории", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "burnout-result", + "template": "info", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "burnout-result", + "show": false, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "Такой партнёр **умеет слышать и поддерживать**, а вы — **человек с глубокой душой**, который ценит искренность и силу настоящих чувств.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "birthdate", + "isEndScreen": false + }, + "icon": { + "type": "image", + "value": "/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg", + "size": "md" + }, + "fields": [], + "list": { + "options": [] + }, + "variants": [ + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "acknowledged_and_calmed" + ] + } + ] + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "gave_emotional_support" + ] + } + ], + "overrides": { + "subtitle": { + "text": "Такой человек создаёт **чувство надёжности**, а вы обладаете мудростью и внутренней зрелостью, выбирая близость и доверие." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "took_over_tasks" + ] + } + ], + "overrides": { + "subtitle": { + "text": "Такой партнёр готов **подставить плечо** в нужный момент, а вы сильны тем, что умеете **доверять** и **принимать поддержку** — это ваша природная мудрость." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "inspired_with_plan" + ] + } + ], + "overrides": { + "subtitle": { + "text": "Такой человек **заряжает ясностью** и **мотивирует**, а вы выделяетесь **силой воли** и **стремлением к росту** — вы не боитесь идти вперёд." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "shifted_to_positive" + ] + } + ], + "overrides": { + "subtitle": { + "text": "Такой партнёр умеет **возвращать радость**, а вы показываете свою силу в умении **сохранять лёгкость** и **светлый взгляд** на жизнь." + } + } + } + ] + }, + { + "id": "birthdate", + "template": "date", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Когда ты родился?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "В момент вашего рождения заложенны глубинные закономерности.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "nature-archetype", + "isEndScreen": false + }, + "dateInput": { + "monthLabel": "Месяц", + "dayLabel": "День", + "yearLabel": "Год", + "monthPlaceholder": "ММ", + "dayPlaceholder": "ДД", + "yearPlaceholder": "ГГГГ", + "showSelectedDate": true, + "selectedDateFormat": "dd MMMM yyyy", + "selectedDateLabel": "Выбранная дата:", + "zodiac": { + "enabled": true, + "storageKey": "userZodiac" + } + }, + "fields": [], + "list": { + "options": [] + }, + "variants": [] + }, + { + "id": "nature-archetype", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Какой природный образ ближе вашему характеру?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "love-priority", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "flower", + "label": "Цветок — нежность, забота, притягательность", + "emoji": "🌹", + "disabled": false + }, + { + "id": "sea", + "label": "Море — глубина, тайна, эмоции", + "emoji": "🌊", + "disabled": false + }, + { + "id": "sun", + "label": "Солнце — энергия, сила, яркость", + "emoji": "🌞️", + "disabled": false + }, + { + "id": "moon", + "label": "Луна — интуиция, чувствительность", + "emoji": "🌙", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "love-priority", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Когда речь о любви, что для вас важнее: сердце или разум?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "core-need", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "follow_heart", + "label": "Доверяю сердцу", + "emoji": "🧡", + "disabled": false + }, + { + "id": "follow_mind", + "label": "Опираюсь на разум", + "emoji": "🧠", + "disabled": false + }, + { + "id": "balance_heart_mind", + "label": "Сочетание сердца и разума", + "emoji": "🎯", + "disabled": false + }, + { + "id": "depends_on_situation", + "label": "Зависит от ситуации", + "emoji": "⚖️", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "core-need", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "В чём ваша базовая потребность сейчас?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-similarity", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "safety_and_support", + "label": "Безопасность и опора", + "disabled": false + }, + { + "id": "passion_and_spark", + "label": "Страсть и искра", + "disabled": false + }, + { + "id": "calm_and_acceptance", + "label": "Спокойствие и принятие", + "disabled": false + }, + { + "id": "inspiration_and_growth", + "label": "Вдохновение и рост", + "disabled": false + }, + { + "id": "not_important", + "label": "Неважно", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-similarity", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Твоя вторая половинка похожа на тебя?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-role", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "similar", + "label": "Да, есть сходство", + "disabled": false + }, + { + "id": "different", + "label": "Мы совершенно разные", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-role", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Предпочитаемая роль партнёра", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-strength", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "leader", + "label": "Ведущий", + "disabled": false + }, + { + "id": "equal", + "label": "Равный", + "disabled": false + }, + { + "id": "supportive", + "label": "Поддерживающий", + "disabled": false + }, + { + "id": "flexible", + "label": "Гибкая роль", + "disabled": false + }, + { + "id": "dependent", + "label": "Зависимый от меня", + "disabled": false + }, + { + "id": "situational", + "label": "По ситуации", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-strength", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Что для тебя главный источник силы в отношениях?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "love-expression", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "support_and_care", + "label": "Поддержка и забота", + "disabled": false + }, + { + "id": "admiration_and_recognition", + "label": "Восхищение и признание", + "disabled": false + }, + { + "id": "freedom_and_space", + "label": "Свобода и пространство", + "disabled": false + }, + { + "id": "shared_goals_and_plans", + "label": "Общие цели и планы", + "disabled": false + }, + { + "id": "joy_and_lightness", + "label": "Радость и лёгкость", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "love-expression", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Как ты проявляешь любовь?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-future", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "words", + "label": "Словами", + "disabled": false + }, + { + "id": "actions", + "label": "Поступками", + "disabled": false + }, + { + "id": "quality_time", + "label": "Совместным временем", + "disabled": false + }, + { + "id": "care", + "label": "Заботой", + "disabled": false + }, + { + "id": "passion", + "label": "Страстью", + "disabled": false + }, + { + "id": "in_my_own_way", + "label": "По-своему", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-future", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Как ты воспринимаешь будущее твоей пары?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-energy", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "home_and_family", + "label": "Совместный дом и семья", + "disabled": false + }, + { + "id": "travel_and_discovery", + "label": "Путешествия и открытия", + "disabled": false + }, + { + "id": "shared_goals", + "label": "Совместные проекты и цели", + "disabled": false + }, + { + "id": "present_moment", + "label": "Просто быть рядом «здесь и сейчас»", + "disabled": false + }, + { + "id": "unsure", + "label": "Пока сложно сказать", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-energy", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Какую энергию ты хочешь в отношениях?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-metaphor", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "lightness_and_joy", + "label": "Лёгкость и радость", + "disabled": false + }, + { + "id": "strength_and_drive", + "label": "Сила и драйв", + "disabled": false + }, + { + "id": "comfort_and_safety", + "label": "Уют и надёжность", + "disabled": false + }, + { + "id": "depth_and_meaning", + "label": "Глубина и смысл", + "disabled": false + }, + { + "id": "freedom_and_space", + "label": "Свобода и простор", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-metaphor", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Какой образ отношений вам ближе?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Можно выбрать несколько вариантов.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "portrait-generation", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "multi", + "options": [ + { + "id": "bridge", + "label": "Мост — связь сквозь препятствия", + "disabled": false + }, + { + "id": "mountain_path", + "label": "Путь в горах — испытания и смысл", + "disabled": false + }, + { + "id": "dance", + "label": "Танец — баланс и взаимные шаги", + "disabled": false + }, + { + "id": "key_and_lock", + "label": "Ключ и замок — совпадение ценностей", + "disabled": false + }, + { + "id": "harbor", + "label": "Гавань — безопасность и покой", + "disabled": false + }, + { + "id": "lighthouse", + "label": "Маяк — ориентир и поддержка", + "disabled": false + }, + { + "id": "ocean_after_storm", + "label": "Океан после шторма — очищение и новое", + "disabled": false + }, + { + "id": "garden", + "label": "Сад — забота и рост", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "portrait-generation", + "template": "loaders", + "header": { + "showBackButton": false, + "show": false + }, + "title": { + "text": "Создаем портрет твоей второй половинки.", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "Continue", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "email", + "isEndScreen": false + }, + "fields": [], + "list": { + "options": [] + }, + "progressbars": { + "items": [ + { + "processingTitle": "Анализ твоих ответов", + "processingSubtitle": "Processing...", + "completedTitle": "Анализ твоих ответов", + "completedSubtitle": "Complete" + }, + { + "processingTitle": "Portrait of the Soulmate", + "processingSubtitle": "Processing...", + "completedTitle": "Portrait of the Soulmate", + "completedSubtitle": "Complete" + }, + { + "processingTitle": "Portrait of the Soulmate", + "processingSubtitle": "Processing...", + "completedTitle": "Connection Insights", + "completedSubtitle": "Complete" + } + ], + "transitionDuration": 3000 + }, + "variants": [] + }, + { + "id": "email", + "template": "email", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Портрет твоей второй половинки готов! Куда нам его отправить?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "Continue", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": true + }, + "navigation": { + "rules": [], + "isEndScreen": true + }, + "fields": [], + "list": { + "options": [] + }, + "emailInput": { + "label": "Email", + "placeholder": "example@email.com" + }, + "image": { + "src": "/female-portrait.jpg" + }, + "variants": [ + { + "conditions": [ + { + "screenId": "gender", + "operator": "includesAny", + "optionIds": [ + "male" + ] + } + ], + "overrides": { + "image": { + "src": "/male-portrait.jpg" + } + } + } + ] + } + ] +} \ No newline at end of file diff --git a/public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg b/public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg new file mode 100644 index 0000000..5ddd5de --- /dev/null +++ b/public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/scripts/sync-funnels-from-db.mjs b/scripts/sync-funnels-from-db.mjs index 0186744..48360c0 100755 --- a/scripts/sync-funnels-from-db.mjs +++ b/scripts/sync-funnels-from-db.mjs @@ -159,11 +159,26 @@ async function downloadImagesFromDatabase(funnels) { if (image) { const localPath = path.join(imagesDir, filename); - await fs.writeFile(localPath, image.data); + + // Преобразуем MongoDB Binary в Buffer + let buffer; + if (Buffer.isBuffer(image.data)) { + buffer = image.data; + } else if (image.data?.buffer) { + // BSON Binary объект имеет свойство buffer + buffer = Buffer.from(image.data.buffer); + } else if (image.data instanceof Uint8Array) { + buffer = Buffer.from(image.data); + } else { + // Fallback - пробуем напрямую преобразовать + buffer = Buffer.from(image.data); + } + + await fs.writeFile(localPath, buffer); // Создаем маппинг: старый URL → новый локальный путь imageMapping[imageUrl] = `/images/${filename}`; - console.log(`💾 Downloaded ${filename}`); + console.log(`💾 Downloaded ${filename} (${buffer.length} bytes)`); } else { console.warn(`⚠️ Image not found in database: ${filename}`); } diff --git a/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx index 7c35e85..53a62cc 100644 --- a/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx +++ b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx @@ -105,9 +105,7 @@ export default function FunnelBuilderPage() { // Конвертируем состояние билдера обратно в FunnelDefinition const updatedFunnelData: FunnelDefinition = { meta: builderState.meta, - defaultTexts: { - nextButton: 'Counitue' - }, + defaultTexts: builderState.defaultTexts, screens: builderState.screens.map(cleanScreen) }; diff --git a/src/app/api/funnels/[id]/route.ts b/src/app/api/funnels/[id]/route.ts index 977a01c..6e1123d 100644 --- a/src/app/api/funnels/[id]/route.ts +++ b/src/app/api/funnels/[id]/route.ts @@ -1,7 +1,57 @@ import { NextRequest, NextResponse } from 'next/server'; import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; -import type { FunnelDefinition } from '@/lib/funnel/types'; +import type { FunnelDefinition, ScreenDefinition, TypographyVariant } from '@/lib/funnel/types'; + +/** + * Нормализует TypographyVariant - удаляет объект если text пустой + */ +function normalizeTypography(typography: TypographyVariant | undefined): TypographyVariant | undefined { + if (!typography) return undefined; + + // Если text пустой или только пробелы, удаляем весь объект + if (!typography.text || typography.text.trim() === '') { + return undefined; + } + + return typography; +} + +/** + * Нормализует данные воронки перед сохранением в MongoDB + * Удаляет пустые текстовые поля которые не пройдут валидацию + */ +function normalizeFunnelData(funnelData: FunnelDefinition): FunnelDefinition { + return { + ...funnelData, + screens: funnelData.screens.map((screen): ScreenDefinition => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const normalizedScreen: any = { ...screen }; + + // Нормализуем subtitle (опциональное поле) + if ('subtitle' in normalizedScreen) { + const normalized = normalizeTypography(normalizedScreen.subtitle); + if (normalized === undefined) { + delete normalizedScreen.subtitle; + } else { + normalizedScreen.subtitle = normalized; + } + } + + // Нормализуем description (для info и soulmate экранов) + if ('description' in normalizedScreen) { + const normalized = normalizeTypography(normalizedScreen.description); + if (normalized === undefined) { + delete normalizedScreen.description; + } else { + normalizedScreen.description = normalized; + } + } + + return normalizedScreen as ScreenDefinition; + }), + }; +} interface RouteParams { params: Promise<{ @@ -110,8 +160,9 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { if (status !== undefined) funnel.status = status; if (funnelData !== undefined) { - // Save as-is; schema expects `progressbars` for loaders - funnel.funnelData = funnelData as FunnelDefinition; + // Нормализуем данные перед сохранением (удаляем пустые текстовые поля) + const normalizedData = normalizeFunnelData(funnelData as FunnelDefinition); + funnel.funnelData = normalizedData; // Увеличиваем версию только при публикации if (isPublishing) { @@ -133,10 +184,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { const nextSequenceNumber = (lastHistoryEntry?.sequenceNumber || -1) + 1; + // Нормализуем данные для истории + const normalizedDataForHistory = normalizeFunnelData(funnelData as FunnelDefinition); + await FunnelHistoryModel.create({ funnelId: id, sessionId, - funnelSnapshot: funnelData as FunnelDefinition, + funnelSnapshot: normalizedDataForHistory, actionType: status === 'published' ? 'publish' : 'update', sequenceNumber: nextSequenceNumber, description: actionDescription || 'Воронка обновлена', @@ -144,7 +198,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { changeDetails: { action: 'update-funnel', previousValue: previousData, - newValue: funnelData as FunnelDefinition + newValue: normalizedDataForHistory } }); diff --git a/src/app/api/funnels/route.ts b/src/app/api/funnels/route.ts index cf309b7..7f83342 100644 --- a/src/app/api/funnels/route.ts +++ b/src/app/api/funnels/route.ts @@ -1,7 +1,57 @@ import { NextRequest, NextResponse } from 'next/server'; import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; -import type { FunnelDefinition } from '@/lib/funnel/types'; +import type { FunnelDefinition, ScreenDefinition, TypographyVariant } from '@/lib/funnel/types'; + +/** + * Нормализует TypographyVariant - удаляет объект если text пустой + */ +function normalizeTypography(typography: TypographyVariant | undefined): TypographyVariant | undefined { + if (!typography) return undefined; + + // Если text пустой или только пробелы, удаляем весь объект + if (!typography.text || typography.text.trim() === '') { + return undefined; + } + + return typography; +} + +/** + * Нормализует данные воронки перед сохранением в MongoDB + * Удаляет пустые текстовые поля которые не пройдут валидацию + */ +function normalizeFunnelData(funnelData: FunnelDefinition): FunnelDefinition { + return { + ...funnelData, + screens: funnelData.screens.map((screen): ScreenDefinition => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const normalizedScreen: any = { ...screen }; + + // Нормализуем subtitle (опциональное поле) + if ('subtitle' in normalizedScreen) { + const normalized = normalizeTypography(normalizedScreen.subtitle); + if (normalized === undefined) { + delete normalizedScreen.subtitle; + } else { + normalizedScreen.subtitle = normalized; + } + } + + // Нормализуем description (для info и soulmate экранов) + if ('description' in normalizedScreen) { + const normalized = normalizeTypography(normalizedScreen.description); + if (normalized === undefined) { + delete normalizedScreen.description; + } else { + normalizedScreen.description = normalized; + } + } + + return normalizedScreen as ScreenDefinition; + }), + }; +} // GET /api/funnels - получить список всех воронок export async function GET(request: NextRequest) { @@ -127,11 +177,14 @@ export async function POST(request: NextRequest) { ); } + // Нормализуем данные перед сохранением (удаляем пустые текстовые поля) + const normalizedData = normalizeFunnelData(funnelData as FunnelDefinition); + // Создаем воронку const funnel = new FunnelModel({ name, description, - funnelData: funnelData as FunnelDefinition, + funnelData: normalizedData, status, version: 1, usage: { @@ -147,7 +200,7 @@ export async function POST(request: NextRequest) { await FunnelHistoryModel.create({ funnelId: String(savedFunnel._id), sessionId, - funnelSnapshot: funnelData, + funnelSnapshot: normalizedData, actionType: 'create', sequenceNumber: 0, description: 'Воронка создана', @@ -169,6 +222,26 @@ export async function POST(request: NextRequest) { } catch (error) { console.error('POST /api/funnels error:', error); + // Обработка ошибок валидации Mongoose + if (error instanceof Error && error.name === 'ValidationError') { + const validationError = error as Error & { errors: Record }; + const details = []; + + // Собираем все ошибки валидации + for (const field in validationError.errors) { + const fieldError = validationError.errors[field]; + details.push(`${field}: ${fieldError.message}`); + } + + return NextResponse.json( + { + error: 'Ошибка валидации данных воронки', + details: details.join('; ') + }, + { status: 400 } + ); + } + if (error instanceof Error && error.message.includes('duplicate key')) { return NextResponse.json( { error: 'Funnel with this name already exists' }, diff --git a/src/app/api/images/[filename]/route.ts b/src/app/api/images/[filename]/route.ts index 3333d29..ed369fd 100644 --- a/src/app/api/images/[filename]/route.ts +++ b/src/app/api/images/[filename]/route.ts @@ -3,6 +3,12 @@ import connectMongoDB from '@/lib/mongodb'; import { Image, type IImage } from '@/lib/models/Image'; import { IS_FRONTEND_ONLY_BUILD } from '@/lib/runtime/buildVariant'; +// Тип для MongoDB Binary объекта из BSON +interface MongoDBBinary { + buffer?: ArrayBuffer | Buffer; + _bsontype?: string; +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ filename: string }> } @@ -37,7 +43,22 @@ export async function GET( } // Возвращаем изображение с правильными заголовками - const buffer = image.data instanceof Buffer ? image.data : Buffer.from(image.data); + // Преобразуем MongoDB Binary в Buffer + let buffer: Buffer; + const rawData = image.data as unknown; + + if (Buffer.isBuffer(rawData)) { + buffer = rawData; + } else if ((rawData as MongoDBBinary)?.buffer) { + // BSON Binary объект имеет свойство buffer + const binaryData = (rawData as MongoDBBinary).buffer; + buffer = Buffer.isBuffer(binaryData) ? binaryData : Buffer.from(binaryData as ArrayBuffer); + } else if (rawData instanceof Uint8Array) { + buffer = Buffer.from(rawData); + } else { + // Fallback - пробуем напрямую преобразовать + buffer = Buffer.from(rawData as ArrayBuffer); + } // Специальная обработка для SVG файлов let contentType = image.mimetype; diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index 3a7fbcd..61e241a 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -106,18 +106,22 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT }, [storageKey]); const handleTextChange = (text: string) => { - if (text.trim() === "" && allowRemove) { - onChange(undefined); - return; - } - - // Сохраняем существующие настройки или используем минимальные дефолты + // Всегда обновляем текст, даже если пустой + // Это позволяет controlled input работать корректно onChange({ ...value, text, + show: value?.show ?? true, // Если show не задан, по умолчанию true }); }; + const handleTextBlur = () => { + // При потере фокуса удаляем объект если текст пустой + if (allowRemove && (!value?.text || value.text.trim() === "")) { + onChange(undefined); + } + }; + const handleAdvancedChange = (field: keyof TypographyVariant, fieldValue: string) => { onChange({ ...value, @@ -127,11 +131,27 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT }; const handleShowToggle = (show: boolean) => { - onChange({ - ...value, - text: value?.text || "", - show, - }); + if (!show) { + // Скрываем элемент + if (allowRemove) { + // Для опциональных полей - удаляем объект полностью + onChange(undefined); + } else { + // Для обязательных полей - сохраняем с show: false + onChange({ + ...value, + text: value?.text || "", + show: false, + }); + } + } else { + // Показываем элемент + onChange({ + ...value, + text: value?.text || "", + show: true, + }); + } }; return ( @@ -140,7 +160,7 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT