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

This accordion component includes:

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

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

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

This accordion component includes:

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

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

+//
+//
+//
+//
+// ), +// } satisfies Story; +// export const CustomStyling = { +// render: (args) => ( +// +// +// +// Custom Styled Item +// +// +// This accordion item has custom styling with blue colors and enhanced +// spacing. +// +// +// +// +// Another Custom Item +// +// +// Each item can have its own custom styling while maintaining the +// accordion functionality. +// +// +// +// ), +// } satisfies Story; diff --git a/src/entities/session/actions.ts b/src/entities/session/actions.ts new file mode 100644 index 0000000..92d7095 --- /dev/null +++ b/src/entities/session/actions.ts @@ -0,0 +1,34 @@ +import { http } from "@/shared/api/httpClient"; +import { + CreateSessionResponseSchema, + ICreateSessionRequest, + ICreateSessionResponse, + IUpdateSessionRequest, + IUpdateSessionResponse, + UpdateSessionResponseSchema, +} from "./types"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +export const createSession = async ( + payload: ICreateSessionRequest +): Promise => { + return http.post(API_ROUTES.session(), payload, { + tags: ["session", "create"], + schema: CreateSessionResponseSchema, + revalidate: 0, + }); +}; + +export const updateSession = async ( + payload: IUpdateSessionRequest +): Promise => { + return http.patch( + API_ROUTES.session(payload.sessionId), + payload, + { + tags: ["session", "update"], + schema: UpdateSessionResponseSchema, + revalidate: 0, + } + ); +}; diff --git a/src/entities/session/types.ts b/src/entities/session/types.ts new file mode 100644 index 0000000..293147c --- /dev/null +++ b/src/entities/session/types.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { CreateAuthorizeUserSchema } from "../user/types"; + +export const CreateSessionRequestSchema = z.object({ + feature: z.string().optional(), + locale: z.string(), + timezone: z.string(), + source: z.string(), + sign: z.boolean(), + signDate: z.string().optional(), + utm: z.record(z.string(), z.string()).optional(), + domain: z.string(), +}); + +export const UpdateSessionRequestSchema = z.object({ + sessionId: z.string(), + data: z.object({ + feature: z.string().optional(), + profile: CreateAuthorizeUserSchema.optional(), + partner: CreateAuthorizeUserSchema.omit({ + relationship_status: true, + }).optional(), + answers: z.record(z.string(), z.unknown()).optional(), + cookies: z.record(z.string(), z.string()).optional(), + }), +}); + +export const CreateSessionResponseSchema = z.object({ + status: z.string(), + sessionId: z.string(), +}); + +export const UpdateSessionResponseSchema = z.object({ + status: z.string(), + message: z.string(), +}); + +export type ICreateSessionRequest = z.infer; +export type IUpdateSessionRequest = z.infer; +export type ICreateSessionResponse = z.infer< + typeof CreateSessionResponseSchema +>; +export type IUpdateSessionResponse = z.infer< + typeof UpdateSessionResponseSchema +>; diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts new file mode 100644 index 0000000..f60f2b3 --- /dev/null +++ b/src/entities/user/types.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +export const GenderSchema = z.enum(["male", "female", "other"]); + +export const RelationshipStatusSchema = z.enum([ + "single", + "relationship", + "married", + "complicated", + "other", +]); + +export const CreateAuthorizeUserSchema = z.object({ + name: z.string(), + birthdate: z.string().optional(), + gender: GenderSchema, + birthplace: z.object({ + address: z.string().optional(), + coords: z.string().optional(), + }), + relationship_status: RelationshipStatusSchema, +}); diff --git a/src/hooks/session/useSession.ts b/src/hooks/session/useSession.ts new file mode 100644 index 0000000..6c00529 --- /dev/null +++ b/src/hooks/session/useSession.ts @@ -0,0 +1,121 @@ +import { + ICreateSessionResponse, + IUpdateSessionRequest, +} from "@/entities/session/types"; +import { + createSession as createSessionApi, + updateSession as updateSessionApi, +} from "@/entities/session/actions"; +import { getClientTimezone } from "@/shared/utils/locales"; +import { parseQueryParams } from "@/shared/utils/url"; +import { useCallback, useMemo, useState } from "react"; + +// TODO +const language = "en"; + +interface IUseSessionProps { + funnelId: string; +} + +export const useSession = ({ funnelId }: IUseSessionProps) => { + const localStorageKey = `${funnelId}_sessionId`; + const sessionId = + typeof window === "undefined" ? "" : localStorage.getItem(localStorageKey); + + const timezone = getClientTimezone(); + + const [isError, setIsError] = useState(false); + + const createSession = + useCallback(async (): Promise => { + if (typeof window === "undefined") { + return { + sessionId: "", + status: "error", + }; + } + if (sessionId?.length) { + return { + sessionId, + status: "old", + }; + } + try { + const utm = parseQueryParams(); + const sessionParams = { + locale: language, + timezone, + // source: funnelId, + source: "aura.compatibility.v2", + sign: false, + utm, + domain: window.location.hostname, + }; + console.log("Creating session with parameters:", sessionParams); + const sessionFromServer = await createSessionApi(sessionParams); + console.log("Session creation response:", sessionFromServer); + if ( + sessionFromServer?.sessionId?.length && + sessionFromServer?.status === "success" + ) { + localStorage.setItem(localStorageKey, sessionFromServer.sessionId); + return sessionFromServer; + } + console.error( + "Session creation failed - invalid response:", + sessionFromServer + ); + setIsError(true); + return { + status: "error", + sessionId: "", + }; + } catch (error) { + console.error("Session creation failed with error:", error); + setIsError(true); + return { + status: "error", + sessionId: "", + }; + } + }, [localStorageKey, timezone, sessionId]); + // localStorageKey, sessionId, timezone, utm + + const updateSession = useCallback( + async (data: IUpdateSessionRequest["data"]) => { + try { + let _sessionId = sessionId; + if (!_sessionId) { + const session = await createSession(); + _sessionId = session.sessionId; + } + const result = await updateSessionApi({ + sessionId: _sessionId, + data, + }); + return result; + } catch (error) { + console.log(error); + } + }, + [sessionId, createSession] + ); + + const deleteSession = useCallback(async () => { + if (typeof window === "undefined") { + return; + } + localStorage.removeItem(localStorageKey); + }, [localStorageKey]); + + return useMemo( + () => ({ + session: sessionId, + isError, + createSession, + updateSession, + deleteSession, + }), + [sessionId, isError, createSession, deleteSession, updateSession] + ); +}; diff --git a/src/lib/env.ts b/src/lib/env.ts index 135252a..2985b11 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,8 +1,8 @@ -import { z } from 'zod'; +import { z } from "zod"; /** * Environment Variables Schema - * + * * Валидация всех переменных окружения при старте приложения. * Ошибки обнаруживаются на этапе сборки, а не в runtime. */ @@ -10,27 +10,40 @@ const envSchema = z.object({ // MongoDB MONGODB_URI: z .string() - .min(1, 'MONGODB_URI is required') - .default('mongodb://localhost:27017/witlab-funnel'), + .min(1, "MONGODB_URI is required") + .default("mongodb://localhost:27017/witlab-funnel"), // Build variant NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: z - .enum(['frontend', 'full']) + .enum(["frontend", "full"]) .optional() - .default('frontend'), + .default("frontend"), // Optional: Base URL for API calls NEXT_PUBLIC_BASE_URL: z .string() .url() .optional() - .default('http://localhost:3000'), + .default("http://localhost:3000"), // Node environment NODE_ENV: z - .enum(['development', 'production', 'test']) + .enum(["development", "production", "test"]) .optional() - .default('development'), + .default("development"), + + // Optional: API URL for API calls + NEXT_PUBLIC_API_URL: z + .string() + .url() + .optional() + .default("http://localhost:3000"), + + // Optional: Dev logger server enabled + DEV_LOGGER_SERVER_ENABLED: z.string().optional().default("false"), + + // Optional: Auth redirect URL + NEXT_PUBLIC_AUTH_REDIRECT_URL: z.string().optional().default("/"), }); /** @@ -41,17 +54,21 @@ function validateEnv() { try { return envSchema.parse({ MONGODB_URI: process.env.MONGODB_URI, - NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT, + NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: + process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NODE_ENV: process.env.NODE_ENV, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + DEV_LOGGER_SERVER_ENABLED: process.env.DEV_LOGGER_SERVER_ENABLED, + NEXT_PUBLIC_AUTH_REDIRECT_URL: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL, }); } catch (error) { if (error instanceof z.ZodError) { - console.error('❌ Invalid environment variables:'); + console.error("❌ Invalid environment variables:"); error.issues.forEach((err) => { - console.error(` - ${err.path.join('.')}: ${err.message}`); + console.error(` - ${err.path.join(".")}: ${err.message}`); }); - throw new Error('Environment validation failed'); + throw new Error("Environment validation failed"); } throw error; } diff --git a/src/shared/api/httpClient.ts b/src/shared/api/httpClient.ts new file mode 100644 index 0000000..ebbd9a1 --- /dev/null +++ b/src/shared/api/httpClient.ts @@ -0,0 +1,215 @@ +import { z } from "zod"; + +import { devLogger } from "../utils/logger"; +import { env } from "@/lib/env"; + +export class ApiError extends Error { + constructor( + public status: number, + public data: unknown, + message = "API Error" + ) { + super(message); + this.name = "ApiError"; + } +} + +type RequestOpts = Omit & { + tags?: string[]; // next.js cache-tag + query?: Record; // query-string + schema?: z.ZodTypeAny; // runtime validation + revalidate?: number; + skipAuthRedirect?: boolean; +}; + +class HttpClient { + constructor(private baseUrl: string) {} + + private buildUrl( + rootUrl: string, + path: string, + query?: Record + ) { + const url = new URL(path, rootUrl); + if (query) + Object.entries(query).forEach(([k, v]) => + url.searchParams.append(k, String(v)) + ); + return url.toString(); + } + + private async request( + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + rootUrl: string = this.baseUrl, + path: string, + opts: RequestOpts = {}, + body?: unknown, + errorMessage?: string + ): Promise { + const { + tags = [], + schema, + query, + revalidate = 300, + skipAuthRedirect = false, + ...rest + } = opts; + + const fullUrl = this.buildUrl(rootUrl, path, query); + const startTime = Date.now(); + + // Log API request (both client and server with ENV control) + if (typeof window !== "undefined") { + // Client-side logging + devLogger.apiRequest(fullUrl, method, body); + } else { + // Server-side logging (requires ENV variable) + if (typeof devLogger.serverApiRequest === "function") { + devLogger.serverApiRequest(fullUrl, method, body); + } else { + // Fallback server logging + if (env.DEV_LOGGER_SERVER_ENABLED === "true") { + console.group(`\n🚀 [SERVER] API REQUEST: ${method} ${fullUrl}`); + if (body !== undefined) { + console.log("📦 Request Body:", JSON.stringify(body, null, 2)); + } + console.groupEnd(); + } + } + } + + const headers = new Headers(); + let accessToken: string | undefined; + if (typeof window === "undefined") { + const { getServerAccessToken } = await import("../auth/token"); + accessToken = await getServerAccessToken(); + } else { + try { + const { getClientAccessToken } = await import("../auth/token"); + accessToken = getClientAccessToken(); + } catch { + // ignore + } + } + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); + headers.set("Content-Type", "application/json"); + + const res = await fetch(fullUrl, { + method, + body: body ? JSON.stringify(body) : undefined, + headers, + next: { revalidate, tags }, + ...rest, + }); + + const payload = await res.json().catch(() => null); + const duration = Date.now() - startTime; + + if (!res.ok) { + // Log API error response (both client and server) + if (typeof window !== "undefined") { + devLogger.apiResponse(fullUrl, method, res.status, payload, duration); + } else { + if (typeof devLogger.serverApiResponse === "function") { + devLogger.serverApiResponse( + fullUrl, + method, + res.status, + payload, + duration + ); + } else { + // Fallback server logging + if (env.DEV_LOGGER_SERVER_ENABLED === "true") { + const emoji = res.status >= 200 && res.status < 300 ? "✅" : "❌"; + console.group( + `\n${emoji} [SERVER] API ERROR: ${method} ${fullUrl}` + ); + console.log(`📊 Status: ${res.status}`); + console.log(`⏱️ Duration: ${duration}ms`); + if (payload !== undefined) { + console.log("📦 Error Response:", payload); + } + console.groupEnd(); + } + } + } + + if (res.status === 401 && !skipAuthRedirect) { + if (typeof window === "undefined") { + const { redirect } = await import("next/navigation"); + redirect(env.NEXT_PUBLIC_AUTH_REDIRECT_URL || ""); + } else { + const url = env.NEXT_PUBLIC_AUTH_REDIRECT_URL || "/"; + window.location.href = url; + } + } + throw new ApiError(res.status, payload, errorMessage); + } + + const data = payload as T; + const validatedData = schema ? (schema.parse(data) as T) : data; + + // Log successful API response (both client and server) + if (typeof window !== "undefined") { + devLogger.apiResponse( + fullUrl, + method, + res.status, + validatedData, + duration + ); + } else { + if (typeof devLogger.serverApiResponse === "function") { + devLogger.serverApiResponse( + fullUrl, + method, + res.status, + validatedData, + duration + ); + } else { + // Fallback server logging + if (env.DEV_LOGGER_SERVER_ENABLED === "true") { + console.group(`\n✅ [SERVER] API SUCCESS: ${method} ${fullUrl}`); + console.log(`📊 Status: ${res.status}`); + console.log(`⏱️ Duration: ${duration}ms`); + if (validatedData !== undefined) { + const responsePreview = + typeof validatedData === "object" && validatedData !== null + ? Array.isArray(validatedData) + ? `Array[${validatedData.length}]` + : `Object{${Object.keys(validatedData) + .slice(0, 5) + .join(", ")}${ + Object.keys(validatedData).length > 5 ? "..." : "" + }}` + : validatedData; + console.log("📦 Response Preview:", responsePreview); + } + console.groupEnd(); + } + } + } + + return validatedData; + } + + get = (p: string, o?: RequestOpts, u?: string) => + this.request("GET", u, p, o); + post = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("POST", u, p, o, b); + put = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("PUT", u, p, o, b); + patch = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("PATCH", u, p, o, b); + delete = (p: string, o?: RequestOpts, u?: string) => + this.request("DELETE", u, p, o); +} + +const apiUrl = env.NEXT_PUBLIC_API_URL; +if (!apiUrl) { + throw new Error("NEXT_PUBLIC_API_URL environment variable is required"); +} + +export const http = new HttpClient(apiUrl); diff --git a/src/shared/auth/clientToken.ts b/src/shared/auth/clientToken.ts new file mode 100644 index 0000000..a7d2ea1 --- /dev/null +++ b/src/shared/auth/clientToken.ts @@ -0,0 +1,24 @@ +"use client"; + +/** + * Gets the access token from client-side cookies + * @returns The access token or undefined if not found + */ +export function getClientAccessToken(): string | undefined { + if (typeof document === "undefined") { + return undefined; + } + + const cookies = document.cookie.split(";"); + const accessTokenCookie = cookies.find(cookie => + cookie.trim().startsWith("accessToken=") + ); + + if (!accessTokenCookie) { + return undefined; + } + + return decodeURIComponent( + accessTokenCookie.trim().substring("accessToken=".length) + ); +} diff --git a/src/shared/auth/token.ts b/src/shared/auth/token.ts new file mode 100644 index 0000000..10b28e4 --- /dev/null +++ b/src/shared/auth/token.ts @@ -0,0 +1,17 @@ +// Server-side token functions (only for Server Components) +export async function getServerAccessToken() { + const { cookies } = await import("next/headers"); + return (await cookies()).get("accessToken")?.value; +} + +// Client-side token functions +export function getClientAccessToken(): string | undefined { + if (typeof window === "undefined") return undefined; + + const cookies = document.cookie.split(";"); + const accessTokenCookie = cookies.find(cookie => + cookie.trim().startsWith("accessToken=") + ); + + return accessTokenCookie?.split("=")[1]; +} diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts new file mode 100644 index 0000000..ea79d67 --- /dev/null +++ b/src/shared/constants/api-routes.ts @@ -0,0 +1,13 @@ +const ROOT_ROUTE = "/"; +const ROOT_ROUTE_V2 = "/v2/"; + +const createRoute = ( + segments: (string | undefined)[], + rootRoute: string = ROOT_ROUTE +): string => { + return rootRoute + segments.filter(Boolean).join("/"); +}; + +export const API_ROUTES = { + session: (id?: string) => createRoute(["session", id], ROOT_ROUTE_V2), +}; diff --git a/src/shared/utils/locales.ts b/src/shared/utils/locales.ts new file mode 100644 index 0000000..3c44eea --- /dev/null +++ b/src/shared/utils/locales.ts @@ -0,0 +1,2 @@ +export const getClientTimezone = () => + Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts new file mode 100644 index 0000000..f09c7bf --- /dev/null +++ b/src/shared/utils/logger.ts @@ -0,0 +1,399 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +export enum LogType { + API = "API", + SOCKET = "SOCKET", + ERROR = "ERROR", + INFO = "INFO", +} + +export enum LogDirection { + REQUEST = "REQUEST", + RESPONSE = "RESPONSE", + INCOMING = "INCOMING", + OUTGOING = "OUTGOING", +} + +interface LogEntry { + type: LogType; + direction?: LogDirection; + event: string; + data?: unknown; + url?: string; + method?: string; + status?: number; + timestamp: Date; + duration?: number; +} + +class DevLogger { + private enabled = false; + private enabledTypes = new Set(Object.values(LogType)); + private envEnabled = false; + private serverLoggingEnabled = false; + + constructor() { + // Check ENV variables first + if (typeof window !== "undefined") { + this.envEnabled = process.env.NEXT_PUBLIC_DEV_LOGGER_ENABLED !== "false"; + } else { + // Server side - check server env + this.serverLoggingEnabled = + process.env.DEV_LOGGER_SERVER_ENABLED === "true"; + this.envEnabled = process.env.DEV_LOGGER_ENABLED !== "false"; + } + + // Check localStorage for logging preferences (client-side only) + if (typeof window !== "undefined") { + const stored = localStorage.getItem("dev-logger-enabled"); + this.enabled = stored ? JSON.parse(stored) : this.envEnabled; + + const storedTypes = localStorage.getItem("dev-logger-types"); + if (storedTypes) { + this.enabledTypes = new Set(JSON.parse(storedTypes)); + } + } else { + this.enabled = this.envEnabled; + } + } + + private shouldLog(type: LogType): boolean { + // Check ENV first, then user preferences + return this.envEnabled && this.enabled && this.enabledTypes.has(type); + } + + private shouldLogServer(type: LogType): boolean { + // Server logging requires explicit ENV enable + return ( + this.serverLoggingEnabled && + this.envEnabled && + this.enabled && + this.enabledTypes.has(type) + ); + } + + private getLogStyle( + type: LogType, + direction?: LogDirection + ): { emoji: string; color: string; bgColor?: string } { + const styles: Record = { + [LogType.API]: { + [LogDirection.REQUEST]: { + emoji: "🚀", + color: "#3b82f6", + bgColor: "#eff6ff", + }, + [LogDirection.RESPONSE]: { + emoji: "📨", + color: "#10b981", + bgColor: "#f0fdf4", + }, + }, + [LogType.SOCKET]: { + [LogDirection.OUTGOING]: { emoji: "🟢", color: "#16a34a" }, + [LogDirection.INCOMING]: { emoji: "🔵", color: "#2563eb" }, + }, + [LogType.ERROR]: { emoji: "❌", color: "#ef4444" }, + [LogType.INFO]: { emoji: "ℹ️", color: "#6366f1" }, + }; + + const typeStyles = styles[type]; + if ( + direction && + typeof typeStyles === "object" && + direction in typeStyles + ) { + return typeStyles[direction]; + } + return typeof typeStyles === "object" + ? { emoji: "📝", color: "#6b7280" } + : typeStyles; + } + + private formatTime(date: Date): string { + return date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }); + } + + log(entry: Omit) { + if (!this.shouldLog(entry.type)) return; + + const timestamp = new Date(); + const { emoji, color, bgColor } = this.getLogStyle( + entry.type, + entry.direction + ); + const timeStr = this.formatTime(timestamp); + const baseStyle = `color: ${color}; font-weight: bold;`; + const groupStyle = bgColor + ? `${baseStyle} background: ${bgColor}; padding: 2px 6px; border-radius: 3px;` + : baseStyle; + + // Create compact collapsible group + const groupTitle = `${emoji} ${entry.type}${ + entry.direction ? ` ${entry.direction}` : "" + }: ${entry.event}`; + + // Always use groupCollapsed for cleaner output + console.groupCollapsed(`%c${groupTitle} [${timeStr}]`, groupStyle); + + // Compact one-line summary with key info + const summaryParts = []; + if (entry.method) summaryParts.push(`${entry.method}`); + if (entry.status) { + const statusColor = + entry.status >= 200 && entry.status < 300 ? "✅" : "❌"; + summaryParts.push(`${statusColor} ${entry.status}`); + } + if (entry.duration !== undefined) + summaryParts.push(`⏱️ ${entry.duration}ms`); + + if (summaryParts.length > 0) { + console.log( + `%c${summaryParts.join(" • ")}`, + "color: #6b7280; font-size: 11px;" + ); + } + + if (entry.data !== undefined) { + // Show preview for objects/arrays, full value for primitives + if (typeof entry.data === "object" && entry.data !== null) { + const preview = Array.isArray(entry.data) + ? `Array[${entry.data.length}]` + : `Object{${Object.keys(entry.data).slice(0, 3).join(", ")}${ + Object.keys(entry.data).length > 3 ? "..." : "" + }}`; + console.log(`%c📦 Data:`, "color: #6b7280; font-size: 11px;", preview); + console.log(entry.data); + } else { + console.log( + `%c📦 Data:`, + "color: #6b7280; font-size: 11px;", + entry.data + ); + } + } + + console.groupEnd(); + } + + // API logging methods + apiRequest(url: string, method: string, data?: unknown) { + this.log({ + type: LogType.API, + direction: LogDirection.REQUEST, + event: `${method.toUpperCase()} ${url.split("?")[0]}`, + url, + method, + data, + }); + } + + apiResponse( + url: string, + method: string, + status: number, + data?: unknown, + duration?: number + ) { + this.log({ + type: LogType.API, + direction: LogDirection.RESPONSE, + event: `${method.toUpperCase()} ${url.split("?")[0]}`, + url, + method, + status, + data, + duration, + }); + } + + // Socket logging methods + socketOutgoing(event: string, data?: unknown) { + this.log({ + type: LogType.SOCKET, + direction: LogDirection.OUTGOING, + event, + data, + }); + } + + socketIncoming(event: string, data?: unknown) { + this.log({ + type: LogType.SOCKET, + direction: LogDirection.INCOMING, + event, + data, + }); + } + + // Connection state logging + socketConnected() { + console.log( + `%c✅ SOCKET CONNECTED`, + "color: #10b981; font-weight: bold; background: #f0fdf4; padding: 2px 6px; border-radius: 3px;" + ); + } + + socketDisconnected(reason?: string) { + console.log( + `%c❌ SOCKET DISCONNECTED`, + "color: #ef4444; font-weight: bold; background: #fef2f2; padding: 2px 6px; border-radius: 3px;", + reason || "" + ); + } + + socketError(error?: unknown) { + console.log( + `%c⚠️ SOCKET ERROR`, + "color: #f59e0b; font-weight: bold; background: #fffbeb; padding: 2px 6px; border-radius: 3px;", + error || "" + ); + } + + // Server-side logging methods + serverApiRequest(url: string, method: string, body?: unknown) { + if (!this.shouldLogServer(LogType.API)) return; + + console.group(`\n🚀 [SERVER] API REQUEST: ${method} ${url}`); + if (body !== undefined) { + console.log("📦 Request Body:", JSON.stringify(body, null, 2)); + } + console.groupEnd(); + } + + serverApiResponse( + url: string, + method: string, + status: number, + data?: unknown, + duration?: number + ) { + if (!this.shouldLogServer(LogType.API)) return; + + const emoji = status >= 200 && status < 300 ? "✅" : "❌"; + console.group( + `\n${emoji} [SERVER] API ${ + status >= 200 && status < 300 ? "SUCCESS" : "ERROR" + }: ${method} ${url}` + ); + console.log(`📊 Status: ${status}`); + if (duration !== undefined) { + console.log(`⏱️ Duration: ${duration}ms`); + } + if (data !== undefined) { + // Limit response data display to avoid overwhelming logs + const responsePreview = + typeof data === "object" && data !== null + ? Array.isArray(data) + ? `Array[${data.length}]` + : `Object{${Object.keys(data).slice(0, 5).join(", ")}${ + Object.keys(data).length > 5 ? "..." : "" + }}` + : data; + console.log("📦 Response Preview:", responsePreview); + // Full response data (collapsed) + console.groupCollapsed("📄 Full Response Data:"); + console.log(data); + console.groupEnd(); + } + console.groupEnd(); + } + + // Control methods + enable() { + this.enabled = true; + if (typeof window !== "undefined") { + localStorage.setItem("dev-logger-enabled", "true"); + } + console.log( + "%c📝 Dev Logger ENABLED", + "color: #10b981; font-weight: bold;" + ); + } + + disable() { + this.enabled = false; + if (typeof window !== "undefined") { + localStorage.setItem("dev-logger-enabled", "false"); + } + console.log( + "%c📝 Dev Logger DISABLED", + "color: #ef4444; font-weight: bold;" + ); + } + + enableType(type: LogType) { + this.enabledTypes.add(type); + this.saveEnabledTypes(); + console.log( + `%c📝 ${type} logging ENABLED`, + "color: #10b981; font-weight: bold;" + ); + } + + disableType(type: LogType) { + this.enabledTypes.delete(type); + this.saveEnabledTypes(); + console.log( + `%c📝 ${type} logging DISABLED`, + "color: #ef4444; font-weight: bold;" + ); + } + + private saveEnabledTypes() { + if (typeof window !== "undefined") { + localStorage.setItem( + "dev-logger-types", + JSON.stringify(Array.from(this.enabledTypes)) + ); + } + } + + // Helper method to show current settings + status() { + console.group( + "%c🔧 Dev Logger Status", + "color: #6366f1; font-weight: bold;" + ); + console.log("Enabled:", this.enabled); + console.log("Active Types:", Array.from(this.enabledTypes)); + console.groupEnd(); + } +} + +// Create singleton instance +export const devLogger = new DevLogger(); + +// Make it available globally for easy console access +if (typeof window !== "undefined") { + (window as any).devLogger = devLogger; +} + +// Export convenience methods for quick filtering +export const filterAPI = () => { + console.clear(); + devLogger.disableType(LogType.SOCKET); + devLogger.disableType(LogType.ERROR); + devLogger.disableType(LogType.INFO); + devLogger.enableType(LogType.API); +}; + +export const filterSocket = () => { + console.clear(); + devLogger.disableType(LogType.API); + devLogger.disableType(LogType.ERROR); + devLogger.disableType(LogType.INFO); + devLogger.enableType(LogType.SOCKET); +}; + +export const showAll = () => { + console.clear(); + Object.values(LogType).forEach((type) => devLogger.enableType(type)); +}; diff --git a/src/shared/utils/url.ts b/src/shared/utils/url.ts new file mode 100644 index 0000000..bbd9394 --- /dev/null +++ b/src/shared/utils/url.ts @@ -0,0 +1,19 @@ +export const getQueryParam = (paramName: string) => { + const search = window.location.search; + const params = new URLSearchParams(search); + return params.get(paramName); +}; + +export const parseQueryParams = () => { + if (typeof window === "undefined") { + return {}; + } + const params = new URLSearchParams(window.location.search); + const result: Record = {}; + + for (const [key, value] of params.entries()) { + result[key] = value; + } + + return result; +};