add session
This commit is contained in:
gofnnp 2025-10-04 20:56:37 +04:00
parent af901fca45
commit ace03937db
15 changed files with 1158 additions and 153 deletions

View File

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

View File

@ -14,9 +14,13 @@ 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<string>();
let currentScreenId = funnel.meta.firstScreenId || funnel.screens[0]?.id;
@ -28,7 +32,11 @@ function estimatePathLength(funnel: FunnelDefinition, answers: FunnelAnswers): n
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)) {
@ -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,9 +154,10 @@ 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 shouldAutoAdvance =
currentScreen.template === "list" &&
(() => {
const listScreen = currentScreen as ListScreenDefinition;
const selectionType = listScreen.list.selectionType;
@ -128,7 +165,11 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
const bottomActionButton = listScreen.bottomActionButton;
const isButtonExplicitlyDisabled = bottomActionButton?.show === false;
return selectionType === "single" && isButtonExplicitlyDisabled && ids.length > 0;
return (
selectionType === "single" &&
isButtonExplicitlyDisabled &&
ids.length > 0
);
})();
// ПРАВИЛЬНОЕ РЕШЕНИЕ: Автопереход ТОЛЬКО при изменении значения
@ -136,7 +177,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
// восстанавливают состояние и вызывают callbacks без реального изменения
const shouldProceed = hasChanged;
if (!shouldProceed) {
return; // Блокируем программные вызовы useEffect без изменений
}
@ -165,7 +205,8 @@ 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)
const zodiac =
Number.isNaN(month) || Number.isNaN(day)
? null
: getZodiacSign(month, day);
@ -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);
}
};

View File

@ -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<typeof Accordion> = {
@ -12,7 +17,7 @@ const meta: Meta<typeof Accordion> = {
args: {
type: "single",
collapsible: true,
},
} satisfies React.ComponentProps<typeof Accordion>,
argTypes: {
type: {
control: { type: "select" },
@ -22,123 +27,139 @@ const meta: Meta<typeof Accordion> = {
control: { type: "boolean" },
},
},
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that matches the other
components&apos; aesthetic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It&apos;s animated by default, but you can disable it if you
prefer.
</AccordionContent>
</AccordionItem>
</Accordion>
),
};
export default meta;
type Story = StoryObj<typeof meta>;
// type Story = StoryObj<typeof meta>;
export const Default = {
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that matches the other components&apos; aesthetic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It&apos;s animated by default, but you can disable it if you prefer.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
// args: {},
};
export const Multiple = {
args: {
type: "multiple",
},
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that matches the other components&apos; aesthetic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It&apos;s animated by default, but you can disable it if you prefer.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
// export const Multiple = {
// args: {
// type: "multiple",
// },
// render: (args) => (
// <Accordion {...args} className="w-[400px]">
// <AccordionItem value="item-1">
// <AccordionTrigger>Is it accessible?</AccordionTrigger>
// <AccordionContent>
// Yes. It adheres to the WAI-ARIA design pattern.
// </AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-2">
// <AccordionTrigger>Is it styled?</AccordionTrigger>
// <AccordionContent>
// Yes. It comes with default styles that matches the other
// components&apos; aesthetic.
// </AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-3">
// <AccordionTrigger>Is it animated?</AccordionTrigger>
// <AccordionContent>
// Yes. It&apos;s animated by default, but you can disable it if you
// prefer.
// </AccordionContent>
// </AccordionItem>
// </Accordion>
// ),
// } satisfies Story;
export const SingleItem = {
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>What is this component?</AccordionTrigger>
<AccordionContent>
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.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
// export const SingleItem = {
// render: (args) => (
// <Accordion {...args} className="w-[400px]">
// <AccordionItem value="item-1">
// <AccordionTrigger>What is this component?</AccordionTrigger>
// <AccordionContent>
// 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.
// </AccordionContent>
// </AccordionItem>
// </Accordion>
// ),
// } satisfies Story;
export const LongContent = {
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>What are the features?</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<p>This accordion component includes:</p>
<ul className="list-disc list-inside space-y-1 ml-4">
<li>Accessibility support with WAI-ARIA patterns</li>
<li>Smooth animations for opening and closing</li>
<li>Keyboard navigation support</li>
<li>Customizable styling with Tailwind CSS</li>
<li>Single or multiple item selection modes</li>
<li>Collapsible functionality</li>
</ul>
<p className="mt-2">
The component is built using Radix UI primitives, ensuring excellent accessibility and user experience across different devices and assistive technologies.
</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
export const CustomStyling = {
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1" className="border-2 border-blue-200 rounded-lg mb-2">
<AccordionTrigger className="text-blue-600 font-semibold hover:text-blue-800">
Custom Styled Item
</AccordionTrigger>
<AccordionContent className="text-blue-700 bg-blue-50 p-4 rounded-b-lg">
This accordion item has custom styling with blue colors and enhanced spacing.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2" className="border-2 border-green-200 rounded-lg">
<AccordionTrigger className="text-green-600 font-semibold hover:text-green-800">
Another Custom Item
</AccordionTrigger>
<AccordionContent className="text-green-700 bg-green-50 p-4 rounded-b-lg">
Each item can have its own custom styling while maintaining the accordion functionality.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
// export const LongContent = {
// render: (args) => (
// <Accordion {...args} className="w-[400px]">
// <AccordionItem value="item-1">
// <AccordionTrigger>What are the features?</AccordionTrigger>
// <AccordionContent>
// <div className="space-y-2">
// <p>This accordion component includes:</p>
// <ul className="list-disc list-inside space-y-1 ml-4">
// <li>Accessibility support with WAI-ARIA patterns</li>
// <li>Smooth animations for opening and closing</li>
// <li>Keyboard navigation support</li>
// <li>Customizable styling with Tailwind CSS</li>
// <li>Single or multiple item selection modes</li>
// <li>Collapsible functionality</li>
// </ul>
// <p className="mt-2">
// The component is built using Radix UI primitives, ensuring
// excellent accessibility and user experience across different
// devices and assistive technologies.
// </p>
// </div>
// </AccordionContent>
// </AccordionItem>
// </Accordion>
// ),
// } satisfies Story;
// export const CustomStyling = {
// render: (args) => (
// <Accordion {...args} className="w-[400px]">
// <AccordionItem
// value="item-1"
// className="border-2 border-blue-200 rounded-lg mb-2"
// >
// <AccordionTrigger className="text-blue-600 font-semibold hover:text-blue-800">
// Custom Styled Item
// </AccordionTrigger>
// <AccordionContent className="text-blue-700 bg-blue-50 p-4 rounded-b-lg">
// This accordion item has custom styling with blue colors and enhanced
// spacing.
// </AccordionContent>
// </AccordionItem>
// <AccordionItem
// value="item-2"
// className="border-2 border-green-200 rounded-lg"
// >
// <AccordionTrigger className="text-green-600 font-semibold hover:text-green-800">
// Another Custom Item
// </AccordionTrigger>
// <AccordionContent className="text-green-700 bg-green-50 p-4 rounded-b-lg">
// Each item can have its own custom styling while maintaining the
// accordion functionality.
// </AccordionContent>
// </AccordionItem>
// </Accordion>
// ),
// } satisfies Story;

View File

@ -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<ICreateSessionResponse> => {
return http.post<ICreateSessionResponse>(API_ROUTES.session(), payload, {
tags: ["session", "create"],
schema: CreateSessionResponseSchema,
revalidate: 0,
});
};
export const updateSession = async (
payload: IUpdateSessionRequest
): Promise<IUpdateSessionResponse> => {
return http.patch<IUpdateSessionResponse>(
API_ROUTES.session(payload.sessionId),
payload,
{
tags: ["session", "update"],
schema: UpdateSessionResponseSchema,
revalidate: 0,
}
);
};

View File

@ -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<typeof CreateSessionRequestSchema>;
export type IUpdateSessionRequest = z.infer<typeof UpdateSessionRequestSchema>;
export type ICreateSessionResponse = z.infer<
typeof CreateSessionResponseSchema
>;
export type IUpdateSessionResponse = z.infer<
typeof UpdateSessionResponseSchema
>;

View File

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

View File

@ -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<ICreateSessionResponse> => {
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]
);
};

View File

@ -1,4 +1,4 @@
import { z } from 'zod';
import { z } from "zod";
/**
* Environment Variables Schema
@ -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;
}

View File

@ -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<RequestInit, "method" | "body"> & {
tags?: string[]; // next.js cache-tag
query?: Record<string, unknown>; // 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<string, unknown>
) {
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<T>(
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
rootUrl: string = this.baseUrl,
path: string,
opts: RequestOpts = {},
body?: unknown,
errorMessage?: string
): Promise<T> {
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 = <T>(p: string, o?: RequestOpts, u?: string) =>
this.request<T>("GET", u, p, o);
post = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
this.request<T>("POST", u, p, o, b);
put = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
this.request<T>("PUT", u, p, o, b);
patch = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
this.request<T>("PATCH", u, p, o, b);
delete = <T>(p: string, o?: RequestOpts, u?: string) =>
this.request<T>("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);

View File

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

17
src/shared/auth/token.ts Normal file
View File

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

View File

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

View File

@ -0,0 +1,2 @@
export const getClientTimezone = () =>
Intl.DateTimeFormat().resolvedOptions().timeZone;

399
src/shared/utils/logger.ts Normal file
View File

@ -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<LogType>(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, any> = {
[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<LogEntry, "timestamp">) {
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));
};

19
src/shared/utils/url.ts Normal file
View File

@ -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<string, string> = {};
for (const [key, value] of params.entries()) {
result[key] = value;
}
return result;
};