session
add session
This commit is contained in:
parent
af901fca45
commit
ace03937db
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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<string>();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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' aesthetic.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It'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' aesthetic.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It'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' aesthetic.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It'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' aesthetic.
|
||||
// </AccordionContent>
|
||||
// </AccordionItem>
|
||||
// <AccordionItem value="item-3">
|
||||
// <AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
// <AccordionContent>
|
||||
// Yes. It'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;
|
||||
|
||||
34
src/entities/session/actions.ts
Normal file
34
src/entities/session/actions.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
};
|
||||
45
src/entities/session/types.ts
Normal file
45
src/entities/session/types.ts
Normal 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
|
||||
>;
|
||||
22
src/entities/user/types.ts
Normal file
22
src/entities/user/types.ts
Normal 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,
|
||||
});
|
||||
121
src/hooks/session/useSession.ts
Normal file
121
src/hooks/session/useSession.ts
Normal 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]
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
215
src/shared/api/httpClient.ts
Normal file
215
src/shared/api/httpClient.ts
Normal 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);
|
||||
24
src/shared/auth/clientToken.ts
Normal file
24
src/shared/auth/clientToken.ts
Normal 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
17
src/shared/auth/token.ts
Normal 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];
|
||||
}
|
||||
13
src/shared/constants/api-routes.ts
Normal file
13
src/shared/constants/api-routes.ts
Normal 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),
|
||||
};
|
||||
2
src/shared/utils/locales.ts
Normal file
2
src/shared/utils/locales.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const getClientTimezone = () =>
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
399
src/shared/utils/logger.ts
Normal file
399
src/shared/utils/logger.ts
Normal 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
19
src/shared/utils/url.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user