Merge pull request #25 from WIT-LAB-LLC/auth

auth
This commit is contained in:
pennyteenycat 2025-10-05 23:45:06 +02:00 committed by GitHub
commit c539dbbfe4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 557 additions and 116 deletions

View File

@ -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,

View File

@ -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<z.infer<typeof formSchema>>({
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 ? <Spinner className="size-6" /> : 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({
<Image
src={screen.image.src}
alt="portrait"
width={164}
height={245}
className="mt-3.5 rounded-[50px] blur-sm"
width={164}
height={245}
className="mt-3.5 rounded-[50px] blur-sm"
/>
)}
<PrivacySecurityBanner
<PrivacySecurityBanner
className="mt-[26px]"
text={{
children: defaultTexts?.privacyBanner || "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
text={{
children:
defaultTexts?.privacyBanner ||
"Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.",
}}
/>
</div>

View File

@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@ -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<HTMLDivElement, BottomActionButtonProps>(
}
);
export { BottomActionButton };
export { BottomActionButton };

View File

@ -0,0 +1,17 @@
"use server";
import { cookies } from "next/headers";
export const setSessionIdToCookie = async (
key: string,
value: string
): Promise<void> => {
const cookieStore = await cookies();
cookieStore.set(key, value, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365,
});
};

View File

@ -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<ICreateAuthorizeResponse> => {
return http.post<ICreateAuthorizeResponse>(
API_ROUTES.authorization(),
payload,
{
tags: ["authorization", "create"],
schema: CreateAuthorizeResponseSchema,
revalidate: 0,
}
);
};

View File

@ -0,0 +1,14 @@
"use server";
import { cookies } from "next/headers";
export const setAuthTokenToCookie = async (token: string): Promise<void> => {
const cookieStore = await cookies();
cookieStore.set("accessToken", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365,
});
};

View File

@ -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<typeof CreateAuthorizeUserSchema>;
export type ICreateAuthorizeRequest = z.infer<
typeof CreateAuthorizeRequestSchema
>;
export type ICreateAuthorizeResponse = z.infer<
typeof CreateAuthorizeResponseSchema
>;

153
src/hooks/auth/useAuth.ts Normal file
View File

@ -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<string | null>(null);
const getAllCookies = useCallback(() => {
const cookies: Record<string, string> = {};
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<ICreateAuthorizeRequest>({
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]
);
};

View File

@ -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<ICreateSessionResponse> => {
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(

View File

@ -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<T extends TypographyAs>(
}
// Проверяем поле 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<T extends TypographyAs>(
align: variant.align ?? defaults?.align,
color: variant.color ?? defaults?.color,
className: variant.className,
enableMarkup: hasTextMarkup(variant.text || ''), // 🎨 АВТОМАТИЧЕСКИ включаем разметку если обнаружена
enableMarkup: hasTextMarkup(variant.text || ""), // 🎨 АВТОМАТИЧЕСКИ включаем разметку если обнаружена
} as TypographyProps<T>;
}
@ -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<LayoutQuestionProps, "children"> {
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
);
}

View File

@ -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<ScreenDefinition["template"], TemplateRenderer> = {
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<ScreenDefinition["template"], TemplateRenderer>
/>
);
},
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<ScreenDefinition["template"], TemplateRenderer>
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<ScreenDefinition["template"], TemplateRenderer>
/>
);
},
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<string, string> = {};
try {
formData = JSON.parse(formDataJson);
} catch {
@ -113,7 +147,14 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
/>
);
},
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<ScreenDefinition["template"], TemplateRenderer>
// Используем только общую кнопку экрана
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<ScreenDefinition["template"], TemplateRenderer>
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<ScreenDefinition["template"], TemplateRenderer>
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<ScreenDefinition["template"], TemplateRenderer>
/>
);
},
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}`);

View File

@ -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 <TemplateLayout {...layoutProps}>{children}</TemplateLayout>;
* ```
*/
@ -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,

View File

@ -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");

View File

@ -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)
);
}

View File

@ -10,4 +10,5 @@ const createRoute = (
export const API_ROUTES = {
session: (id?: string) => createRoute(["session", id], ROOT_ROUTE_V2),
authorization: () => createRoute(["users", "auth"]),
};

View File

@ -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");
}

View File

@ -0,0 +1,31 @@
export function filterNullKeysOfObject<T extends object>(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<typeof object>(
object[key as keyof object]
)
: object[key as keyof T],
});
},
Array.isArray(object) ? [] : {}
) as T;
}