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 b669901..fe52558 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; } @@ -59,7 +68,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; } @@ -71,7 +80,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, @@ -90,14 +100,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); } @@ -113,7 +123,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; } @@ -129,6 +142,7 @@ export function shouldShowHeader(header?: HeaderDefinition) { } interface BuildActionButtonOptions { + children?: React.ReactNode; defaultText?: string; disabled?: boolean; onClick: () => void; @@ -139,9 +153,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, @@ -156,10 +172,10 @@ export function buildBottomActionButtonProps( if (buttonDef?.show === false) { return undefined; } - + // В остальных случаях показать кнопку с градиентом const actionButtonProps = buildActionButtonProps(options, buttonDef); - + return { actionButtonProps, showGradientBlur: true, // Градиент всегда включен (как требовалось) @@ -178,13 +194,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); @@ -192,25 +213,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, }; } @@ -225,11 +255,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; +}