commit
c539dbbfe4
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
16
src/components/ui/spinner.tsx
Normal file
16
src/components/ui/spinner.tsx
Normal 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 }
|
||||
@ -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 };
|
||||
|
||||
17
src/entities/session/serverActions.ts
Normal file
17
src/entities/session/serverActions.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
21
src/entities/user/actions.ts
Normal file
21
src/entities/user/actions.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
};
|
||||
14
src/entities/user/serverActions.ts
Normal file
14
src/entities/user/serverActions.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@ -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
153
src/hooks/auth/useAuth.ts
Normal 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]
|
||||
);
|
||||
};
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -10,4 +10,5 @@ const createRoute = (
|
||||
|
||||
export const API_ROUTES = {
|
||||
session: (id?: string) => createRoute(["session", id], ROOT_ROUTE_V2),
|
||||
authorization: () => createRoute(["users", "auth"]),
|
||||
};
|
||||
|
||||
12
src/shared/session/sessionId.ts
Normal file
12
src/shared/session/sessionId.ts
Normal 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");
|
||||
}
|
||||
31
src/shared/utils/filter-object/index.ts
Normal file
31
src/shared/utils/filter-object/index.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user