done retaining funnel, edits compatibility & palms & home
This commit is contained in:
gofnnp 2025-06-24 11:45:45 +04:00
parent 67f4dfdf3d
commit 12836b372d
67 changed files with 1153 additions and 442 deletions

View File

@ -77,7 +77,8 @@
"title": "Жаль, что вы уходите…", "title": "Жаль, что вы уходите…",
"description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.<br></br><br></br>Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.", "description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.<br></br><br></br>Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.",
"stay_button": "Остаться и уменьшить мой план на 50%", "stay_button": "Остаться и уменьшить мой план на 50%",
"cancel_button": "Отменить" "cancel_button": "Отменить",
"error_message": "Something went wrong. Please try again later."
}, },
"Stay50Done": { "Stay50Done": {
"title": "Мы ценим твой выбор!", "title": "Мы ценим твой выбор!",
@ -137,7 +138,8 @@
} }
}, },
"get_offer": "Получить бесплатный план", "get_offer": "Получить бесплатный план",
"cancel": "Отменить" "cancel": "Отменить",
"error_message": "Something went wrong. Please try again later."
}, },
"ChangeMind": { "ChangeMind": {
"title": "Что может изменить твое мнение?", "title": "Что может изменить твое мнение?",
@ -151,7 +153,8 @@
"StopFor30Days": { "StopFor30Days": {
"title": "Остановите подписку на тридцать дней. Никаких списаний.", "title": "Остановите подписку на тридцать дней. Никаких списаний.",
"stop": "Остановить", "stop": "Остановить",
"cancel": "Отменить" "cancel": "Отменить",
"error_message": "Something went wrong. Please try again later."
}, },
"CancellationOfSubscription": { "CancellationOfSubscription": {
"title": "Подписка аннулируется!", "title": "Подписка аннулируется!",
@ -162,7 +165,8 @@
"new-price": "0" "new-price": "0"
}, },
"offer_button": "Применить", "offer_button": "Применить",
"cancel_button": "Я подтверждаю свои действия" "cancel_button": "Я подтверждаю свои действия",
"error_message": "Something went wrong. Please try again later."
}, },
"PlanCancelled": { "PlanCancelled": {
"title": "Стандартный план Отменен!", "title": "Стандартный план Отменен!",

View File

@ -6,6 +6,7 @@ import CompatibilityActionFieldsForm, {
} from "@/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm"; } from "@/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm";
import { Typography } from "@/components/ui"; import { Typography } from "@/components/ui";
import { loadCompatibilityActionFields } from "@/entities/compatibilityActionFields/loaders"; import { loadCompatibilityActionFields } from "@/entities/compatibilityActionFields/loaders";
import { loadCompatibility } from "@/entities/dashboard/loaders";
import styles from "./page.module.scss"; import styles from "./page.module.scss";
@ -15,6 +16,8 @@ export default function Compatibility({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = use(params); const { id } = use(params);
const actions = use(loadCompatibility());
const action = actions?.find(action => action._id === id);
const t = useTranslations("Compatibility"); const t = useTranslations("Compatibility");
return ( return (
@ -26,7 +29,7 @@ export default function Compatibility({
weight="semiBold" weight="semiBold"
className={styles.title} className={styles.title}
> >
{t("title")} {action?.title}
</Typography> </Typography>
<Typography as="p" size="sm" className={styles.description}> <Typography as="p" size="sm" className={styles.description}>
{t("description")} {t("description")}

View File

@ -0,0 +1,3 @@
.title {
line-height: 30px;
}

View File

@ -0,0 +1,26 @@
import { use } from "react";
import CompatibilityResultPage from "@/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage";
import { Typography } from "@/components/ui";
import { loadCompatibility } from "@/entities/dashboard/loaders";
import styles from "./page.module.scss";
export default function CompatibilityResult({
params,
}: {
params: Promise<{ id: string; resultId: string }>;
}) {
const { id, resultId } = use(params);
const actions = use(loadCompatibility());
const action = actions?.find(action => action._id === id);
return (
<>
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
{action?.title}
</Typography>
<CompatibilityResultPage id={resultId} />
</>
);
}

View File

@ -1,13 +0,0 @@
import { use } from "react";
import CompatibilityResultPage from "@/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage";
export default function CompatibilityResult({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <CompatibilityResultPage id={id} />;
}

View File

@ -0,0 +1,3 @@
.title {
line-height: 30px;
}

View File

@ -1,16 +1,29 @@
import PalmistryResultPage from "@/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage"; import PalmistryResultPage from "@/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage";
import { Typography } from "@/components/ui";
import { loadPalms } from "@/entities/dashboard/loaders";
import { startGeneration } from "@/entities/generations/api"; import { startGeneration } from "@/entities/generations/api";
import styles from "./page.module.scss";
export default async function PalmistryResult({ export default async function PalmistryResult({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
const actions = await loadPalms();
const action = actions?.find(action => action._id === id) ?? null;
const result = await startGeneration({ const result = await startGeneration({
actionType: "palm", actionType: "palm",
actionId: id, actionId: id,
}); });
return <PalmistryResultPage id={result?.id} />; return (
<>
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
{action?.title}
</Typography>
<PalmistryResultPage id={result?.id} />
</>
);
} }

View File

@ -1,16 +1,13 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button, Typography } from "@/components/ui"; import { Button, Typography } from "@/components/ui";
import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./page.module.scss"; import styles from "./page.module.scss";
export default function Error() { export default function Error({ reset }: { reset: () => void }) {
const t = useTranslations("Subscriptions"); const t = useTranslations("Subscriptions");
const router = useRouter();
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -20,12 +17,7 @@ export default function Error() {
<Typography as="p" align="center"> <Typography as="p" align="center">
{t("error")} {t("error")}
</Typography> </Typography>
<Button <Button onClick={() => reset()}>
onClick={
// () => reset()
() => router.push(ROUTES.retainingFunnelCancelSubscription())
}
>
<Typography color="white">{t("try_again")}</Typography> <Typography color="white">{t("try_again")}</Typography>
</Button> </Button>
</div> </div>

View File

@ -1,36 +1,4 @@
// import { useSchemeColorByElement } from "@/hooks/useSchemeColorByElement";
// import { useRef } from "react";
import { RetainingStepper } from "@/components/domains/retaining"; import { RetainingStepper } from "@/components/domains/retaining";
import { ROUTES } from "@/shared/constants/client-routes";
import { ERetainingFunnel } from "@/types";
const stepperRoutes: Record<ERetainingFunnel, string[]> = {
[ERetainingFunnel.Red]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Green]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Purple]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Stay50]: [ROUTES.retainingFunnelStay50Done()],
};
function StepperLayout({ children }: { children: React.ReactNode }) { function StepperLayout({ children }: { children: React.ReactNode }) {
// const darkTheme = useSelector(selectors.selectDarkTheme); // const darkTheme = useSelector(selectors.selectDarkTheme);
@ -38,12 +6,10 @@ function StepperLayout({ children }: { children: React.ReactNode }) {
// useSchemeColorByElement(mainRef.current, "section.page, .page, section", [ // useSchemeColorByElement(mainRef.current, "section.page, .page, section", [
// location, // location,
// ]); // ]);
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel);
const retainingFunnel = ERetainingFunnel.Red;
return ( return (
<> <>
<RetainingStepper stepperRoutes={stepperRoutes[retainingFunnel]} /> <RetainingStepper />
{children} {children}
</> </>
); );

View File

@ -3,8 +3,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
// overflow-x: clip;
// padding-inline: 2px;
} }
.title { .title {

View File

@ -9,7 +9,8 @@ import { getMessages } from "next-intl/server";
import clsx from "clsx"; import clsx from "clsx";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { StoreProvider } from "@/providers/StoreProvider"; import { RetainingStoreProvider } from "@/providers/retaining-store-provider";
import { ToastProvider } from "@/providers/toast-provider";
import styles from "./layout.module.scss"; import styles from "./layout.module.scss";
@ -47,7 +48,9 @@ export default async function RootLayout({
<html lang={locale}> <html lang={locale}>
<body className={clsx(inter.variable, styles.body)}> <body className={clsx(inter.variable, styles.body)}>
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<StoreProvider>{children}</StoreProvider> <RetainingStoreProvider>
<ToastProvider maxVisible={3}>{children}</ToastProvider>
</RetainingStoreProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@ -4,14 +4,13 @@ import { use, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Skeleton, Toast, Typography } from "@/components/ui"; import { Skeleton, Typography } from "@/components/ui";
import { ActionFieldsForm } from "@/components/widgets"; import { ActionFieldsForm } from "@/components/widgets";
import { startGeneration } from "@/entities/generations/actions"; import { startGeneration } from "@/entities/generations/actions";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { ActionField } from "@/types"; import { ActionField } from "@/types";
import styles from "./CompatibilityActionFieldsForm.module.scss";
interface CompatibilityActionFieldsFormProps { interface CompatibilityActionFieldsFormProps {
fields: Promise<ActionField[]>; fields: Promise<ActionField[]>;
actionId: string; actionId: string;
@ -24,15 +23,14 @@ export default function CompatibilityActionFieldsForm({
const t = useTranslations("Compatibility"); const t = useTranslations("Compatibility");
const compatibilityActionFields = use(fields); const compatibilityActionFields = use(fields);
const router = useRouter(); const router = useRouter();
const { addToast } = useToast();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = async ( const handleSubmit = async (
values: Record<string, string | number | null> values: Record<string, string | number | null>
) => { ) => {
setIsLoading(true); setIsLoading(true);
setFormError(null);
const response = await startGeneration({ const response = await startGeneration({
actionType: "compatibility", actionType: "compatibility",
@ -43,16 +41,19 @@ export default function CompatibilityActionFieldsForm({
setIsLoading(false); setIsLoading(false);
if (response?.data?.id) { if (response?.data?.id) {
router.push(ROUTES.compatibilityResult(response.data.id)); router.push(ROUTES.compatibilityResult(actionId, response.data.id));
} }
if (response.error) { if (response.error) {
setFormError(response.error); addToast({
variant: "error",
message: t("error"),
duration: 5000,
});
return; return;
} }
}; };
// Обработка случая, когда поля не загрузились
if (!compatibilityActionFields || compatibilityActionFields.length === 0) { if (!compatibilityActionFields || compatibilityActionFields.length === 0) {
return ( return (
<Typography as="p" size="sm" color="danger"> <Typography as="p" size="sm" color="danger">
@ -62,22 +63,12 @@ export default function CompatibilityActionFieldsForm({
} }
return ( return (
<> <ActionFieldsForm
<ActionFieldsForm fields={compatibilityActionFields}
fields={compatibilityActionFields} onSubmit={handleSubmit}
onSubmit={handleSubmit} buttonText={t("button")}
buttonText={t("button")} isLoading={isLoading}
isLoading={isLoading} />
/>
{formError && (
<Toast variant="error" classNameContainer={styles.errorToast}>
<Typography as="p" size="sm" color="black">
{t("error")}
</Typography>
</Toast>
)}
</>
); );
} }

View File

@ -2,11 +2,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: calc(100dvh - 56px); height: calc(100dvh - 102px);
}
.title {
line-height: 30px;
} }
.description { .description {

View File

@ -1,9 +1,11 @@
"use client"; "use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Spinner, Toast, Typography } from "@/components/ui"; import { Spinner, Typography } from "@/components/ui";
import { useGenerationPolling } from "@/hooks/generation/useGenerationPolling"; import { useGenerationPolling } from "@/hooks/generation/useGenerationPolling";
import { useToast } from "@/providers/toast-provider";
import styles from "./CompatibilityResultPage.module.scss"; import styles from "./CompatibilityResultPage.module.scss";
@ -16,6 +18,18 @@ export default function CompatibilityResultPage({
}: CompatibilityResultPageProps) { }: CompatibilityResultPageProps) {
const t = useTranslations("CompatibilityResult"); const t = useTranslations("CompatibilityResult");
const { data, error, isLoading } = useGenerationPolling(id); const { data, error, isLoading } = useGenerationPolling(id);
const { addToast } = useToast();
useEffect(() => {
if (error) {
addToast({
variant: "error",
message: t("error"),
duration: 5000,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
if (isLoading) { if (isLoading) {
return ( return (
@ -25,15 +39,8 @@ export default function CompatibilityResultPage({
); );
} }
if (error) {
return <Toast variant="error">{t("error")}</Toast>;
}
return ( return (
<> <>
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" size="lg" align="left" className={styles.description}> <Typography as="p" size="lg" align="left" className={styles.description}>
{data?.result} {data?.result}
</Typography> </Typography>

View File

@ -59,5 +59,4 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
background: linear-gradient(0deg, #174280 0%, rgba(0, 0, 0, 0) 70.95%); background: linear-gradient(0deg, #174280 0%, rgba(0, 0, 0, 0) 70.95%);
// border: 1px solid rgba(229, 231, 235, 1);
} }

View File

@ -2,11 +2,11 @@ import Image from "next/image";
import { Card, MetaLabel, Typography } from "@/components/ui"; import { Card, MetaLabel, Typography } from "@/components/ui";
import { IconName } from "@/components/ui/Icon/Icon"; import { IconName } from "@/components/ui/Icon/Icon";
import { CompatibilityAction } from "@/entities/dashboard/types"; import { Action } from "@/entities/dashboard/types";
import styles from "./CompatibilityCard.module.scss"; import styles from "./CompatibilityCard.module.scss";
type CompatibilityCardProps = CompatibilityAction; type CompatibilityCardProps = Action;
export default function CompatibilityCard({ export default function CompatibilityCard({
imageUrl, imageUrl,

View File

@ -2,11 +2,11 @@ import Image from "next/image";
import { Button, Card, Icon, MetaLabel, Typography } from "@/components/ui"; import { Button, Card, Icon, MetaLabel, Typography } from "@/components/ui";
import { IconName } from "@/components/ui/Icon/Icon"; import { IconName } from "@/components/ui/Icon/Icon";
import { Meditation } from "@/entities/dashboard/types"; import { Action } from "@/entities/dashboard/types";
import styles from "./MeditationCard.module.scss"; import styles from "./MeditationCard.module.scss";
type MeditationCardProps = Meditation; type MeditationCardProps = Action;
export default function MeditationCard({ export default function MeditationCard({
imageUrl, imageUrl,

View File

@ -2,11 +2,11 @@ import Image from "next/image";
import { Card, MetaLabel, Typography } from "@/components/ui"; import { Card, MetaLabel, Typography } from "@/components/ui";
import { IconName } from "@/components/ui/Icon/Icon"; import { IconName } from "@/components/ui/Icon/Icon";
import { PalmAction } from "@/entities/dashboard/types"; import { Action } from "@/entities/dashboard/types";
import styles from "./PalmCard.module.scss"; import styles from "./PalmCard.module.scss";
type PalmCardProps = PalmAction; type PalmCardProps = Action;
export default function PalmCard({ export default function PalmCard({
imageUrl, imageUrl,

View File

@ -1,10 +1,16 @@
.sectionContent.sectionContent { .sectionContent.sectionContent {
overflow-x: scroll; overflow-x: scroll;
-webkit-overflow-scrolling: touch;
width: calc(100% + 32px); width: calc(100% + 32px);
padding: 32px 16px; padding: 32px 16px;
padding-right: 0;
margin: -32px -16px; margin: -32px -16px;
} }
.grid {
padding-right: 16px;
}
.skeleton.skeleton { .skeleton.skeleton {
height: 486px; height: 486px;
} }

View File

@ -1,10 +1,16 @@
.sectionContent.sectionContent { .sectionContent.sectionContent {
overflow-x: scroll; overflow-x: scroll;
-webkit-overflow-scrolling: touch;
width: calc(100% + 32px); width: calc(100% + 32px);
padding: 16px; padding: 16px;
padding-right: 0;
margin: -16px; margin: -16px;
} }
.grid {
padding-right: 16px;
}
.skeleton.skeleton { .skeleton.skeleton {
height: 236px; height: 236px;
} }

View File

@ -1,8 +1,10 @@
"use client";
import { use } from "react"; import { use } from "react";
import Link from "next/link"; import Link from "next/link";
import { Grid, Section, Skeleton } from "@/components/ui"; import { Grid, Section, Skeleton } from "@/components/ui";
import { CompatibilityAction } from "@/entities/dashboard/types"; import { Action } from "@/entities/dashboard/types";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { CompatibilityCard } from "../../cards"; import { CompatibilityCard } from "../../cards";
@ -12,7 +14,7 @@ import styles from "./CompatibilitySection.module.scss";
export default function CompatibilitySection({ export default function CompatibilitySection({
promise, promise,
}: { }: {
promise: Promise<CompatibilityAction[]>; promise: Promise<Action[]>;
}) { }) {
const compatibilities = use(promise); const compatibilities = use(promise);
const columns = Math.ceil(compatibilities?.length / 2); const columns = Math.ceil(compatibilities?.length / 2);

View File

@ -1,10 +1,16 @@
.sectionContent.sectionContent { .sectionContent.sectionContent {
overflow-x: scroll; overflow-x: scroll;
-webkit-overflow-scrolling: touch;
width: calc(100% + 32px); width: calc(100% + 32px);
padding: 16px; padding: 16px;
padding-right: 0;
margin: -16px; margin: -16px;
} }
.grid {
padding-right: 16px;
}
.skeleton.skeleton { .skeleton.skeleton {
height: 308px; height: 308px;
} }

View File

@ -1,7 +1,7 @@
import { use } from "react"; import { use } from "react";
import { Grid, Section, Skeleton } from "@/components/ui"; import { Grid, Section, Skeleton } from "@/components/ui";
import { Meditation } from "@/entities/dashboard/types"; import { Action } from "@/entities/dashboard/types";
import { MeditationCard } from "../../cards"; import { MeditationCard } from "../../cards";
@ -10,7 +10,7 @@ import styles from "./MeditationSection.module.scss";
export default function MeditationSection({ export default function MeditationSection({
promise, promise,
}: { }: {
promise: Promise<Meditation[]>; promise: Promise<Action[]>;
}) { }) {
const meditations = use(promise); const meditations = use(promise);
const columns = meditations?.length; const columns = meditations?.length;

View File

@ -1,10 +1,16 @@
.sectionContent.sectionContent { .sectionContent.sectionContent {
overflow-x: scroll; overflow-x: scroll;
-webkit-overflow-scrolling: touch;
width: calc(100% + 32px); width: calc(100% + 32px);
padding: 16px; padding: 16px;
padding-right: 0;
margin: -16px; margin: -16px;
} }
.grid {
padding-right: 16px;
}
.skeleton.skeleton { .skeleton.skeleton {
height: 227px; height: 227px;
} }

View File

@ -2,7 +2,7 @@ import { use } from "react";
import Link from "next/link"; import Link from "next/link";
import { Grid, Section, Skeleton } from "@/components/ui"; import { Grid, Section, Skeleton } from "@/components/ui";
import { PalmAction } from "@/entities/dashboard/types"; import { Action } from "@/entities/dashboard/types";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { PalmCard } from "../../cards"; import { PalmCard } from "../../cards";
@ -12,7 +12,7 @@ import styles from "./PalmSection.module.scss";
export default function PalmSection({ export default function PalmSection({
promise, promise,
}: { }: {
promise: Promise<PalmAction[]>; promise: Promise<Action[]>;
}) { }) {
const palms = use(promise); const palms = use(promise);
const columns = palms?.length; const columns = palms?.length;

View File

@ -2,11 +2,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: calc(100dvh - 56px); height: calc(100dvh - 102px);
}
.title {
line-height: 30px;
} }
.description { .description {

View File

@ -1,9 +1,11 @@
"use client"; "use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Spinner, Toast, Typography } from "@/components/ui"; import { Spinner, Typography } from "@/components/ui";
import { useGenerationPolling } from "@/hooks/generation/useGenerationPolling"; import { useGenerationPolling } from "@/hooks/generation/useGenerationPolling";
import { useToast } from "@/providers/toast-provider";
import styles from "./PalmistryResultPage.module.scss"; import styles from "./PalmistryResultPage.module.scss";
@ -14,6 +16,18 @@ interface PalmistryResultPageProps {
export default function PalmistryResultPage({ id }: PalmistryResultPageProps) { export default function PalmistryResultPage({ id }: PalmistryResultPageProps) {
const t = useTranslations("PalmistryResult"); const t = useTranslations("PalmistryResult");
const { data, error, isLoading } = useGenerationPolling(id); const { data, error, isLoading } = useGenerationPolling(id);
const { addToast } = useToast();
useEffect(() => {
if (error) {
addToast({
variant: "error",
message: t("error"),
duration: 5000,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
if (isLoading) { if (isLoading) {
return ( return (
@ -23,15 +37,8 @@ export default function PalmistryResultPage({ id }: PalmistryResultPageProps) {
); );
} }
if (error) {
return <Toast variant="error">{t("error")}</Toast>;
}
return ( return (
<> <>
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" size="lg" align="left" className={styles.description}> <Typography as="p" size="lg" align="left" className={styles.description}>
{data?.result} {data?.result}
</Typography> </Typography>

View File

@ -13,14 +13,15 @@ import { useTranslations } from "next-intl";
import { Button, Typography } from "@/components/ui"; import { Button, Typography } from "@/components/ui";
import Modal from "@/components/ui/Modal/Modal"; import Modal from "@/components/ui/Modal/Modal";
import { UserSubscription } from "@/entities/subscriptions/types"; import { UserSubscription } from "@/entities/subscriptions/types";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { useRetainingActions } from "@/stores/retainingStore";
import styles from "./CancelSubscriptionModalProvider.module.scss"; import styles from "./CancelSubscriptionModalProvider.module.scss";
type Ctx = { open: (sub: UserSubscription) => void }; type Ctx = { open: (sub: UserSubscription) => void };
const Context = createContext<Ctx | null>(null); const Context = createContext<Ctx | null>(null);
export const useCancelSubscriptionModal = () => { export const useCancelSubscriptionModal = () => {
const ctx = useContext(Context); const ctx = useContext(Context);
if (!ctx) if (!ctx)
@ -36,7 +37,8 @@ export default function CancelSubscriptionModalProvider({
const router = useRouter(); const router = useRouter();
const t = useTranslations("Subscriptions"); const t = useTranslations("Subscriptions");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { setCancellingSubscription } = useRetainingActions();
const { setCancellingSubscription } = useRetainingStore(state => state);
const close = useCallback(() => setIsOpen(false), []); const close = useCallback(() => setIsOpen(false), []);
const open = useCallback( const open = useCallback(

View File

@ -69,11 +69,5 @@ export default function SubscriptionTable({ subscription }: ITableProps) {
return data; return data;
}, [subscription, t, open]); }, [subscription, t, open]);
// const tableData: ReactNode[][] = [
// [t("table.subscription_status"), t(`table.subscription_status_value.${subscription.subscriptionStatus}`, {
// date: formatDate(subscription.cancellationDate) || ""
// })],
// ]
return <Table data={tableData} />; return <Table data={tableData} />;
} }

View File

@ -1,5 +1,3 @@
// import { useSelector } from "react-redux";
// import { selectors } from "@/store";
import clsx from "clsx"; import clsx from "clsx";
import { Typography } from "@/components/ui"; import { Typography } from "@/components/ui";

View File

@ -3,25 +3,55 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { StepperBar } from "@/components/layout"; import { StepperBar } from "@/components/layout";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { ERetainingFunnel } from "@/types";
import styles from "./RetainingStepper.module.scss"; import styles from "./RetainingStepper.module.scss";
export default function RetainingStepper({ const stepperRoutes: Record<ERetainingFunnel, string[]> = {
stepperRoutes, [ERetainingFunnel.Red]: [
}: { ROUTES.retainingFunnelAppreciateChoice(),
stepperRoutes: string[]; ROUTES.retainingFunnelWhatReason(),
}) { ROUTES.retainingFunnelSecondChance(),
ROUTES.retainingFunnelChangeMind(),
ROUTES.retainingFunnelStopFor30Days(),
ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Green]: [
ROUTES.retainingFunnelAppreciateChoice(),
ROUTES.retainingFunnelWhatReason(),
ROUTES.retainingFunnelStopFor30Days(),
ROUTES.retainingFunnelChangeMind(),
ROUTES.retainingFunnelSecondChance(),
ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Purple]: [
ROUTES.retainingFunnelAppreciateChoice(),
ROUTES.retainingFunnelWhatReason(),
ROUTES.retainingFunnelChangeMind(),
ROUTES.retainingFunnelSecondChance(),
ROUTES.retainingFunnelStopFor30Days(),
ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Stay50]: [ROUTES.retainingFunnelStay50Done()],
};
export default function RetainingStepper() {
const pathname = usePathname(); const pathname = usePathname();
const { funnel } = useRetainingStore(state => state);
const getCurrentStep = () => { const getCurrentStep = () => {
// if ([ if (
// ROUTES.retainingFunnelPlanCancelled(), [
// ROUTES.retainingFunnelSubscriptionStopped(), ROUTES.retainingFunnelPlanCancelled(),
// ].some(route => location.pathname.includes(route))) { ROUTES.retainingFunnelSubscriptionStopped(),
// return stepperRoutes[retainingFunnel].length; ].some(route => pathname.includes(route))
// } ) {
return stepperRoutes[funnel].length;
}
let index = 0; let index = 0;
for (const route of stepperRoutes) { for (const route of stepperRoutes[funnel]) {
if (pathname.includes(route)) { if (pathname.includes(route)) {
return index + 1; return index + 1;
} }
@ -30,10 +60,9 @@ export default function RetainingStepper({
return 0; return 0;
}; };
// логика выбора шага по pathname
return ( return (
<StepperBar <StepperBar
length={stepperRoutes.length} length={stepperRoutes[funnel].length}
currentStep={getCurrentStep()} currentStep={getCurrentStep()}
// color={darkTheme ? "#B2BCFF" : "#353E75"} // color={darkTheme ? "#B2BCFF" : "#353E75"}
color={"#353E75"} color={"#353E75"}

View File

@ -5,9 +5,13 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Spinner, Typography } from "@/components/ui"; import { Spinner, Typography } from "@/components/ui";
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
import { useLottie } from "@/hooks/lottie/useLottie"; import { useLottie } from "@/hooks/lottie/useLottie";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { ELottieKeys } from "@/shared/constants/lottie"; import { ELottieKeys } from "@/shared/constants/lottie";
import { ERetainingFunnel } from "@/stores/retaining-store";
import { RetainingButton } from "../.."; import { RetainingButton } from "../..";
@ -19,9 +23,16 @@ export default function Buttons() {
useLottie({ useLottie({
preloadKey: ELottieKeys.loaderCheckMark, preloadKey: ELottieKeys.loaderCheckMark,
}); });
const { addToast } = useToast();
const [activeButton, setActiveButton] = useState<"stay" | "cancel">(); const { cancellingSubscription, setFunnel } = useRetainingStore(
const [isLoadingButton, setIsLoadingButton] = useState<"stay" | "cancel">(); state => state
);
const [activeButton, setActiveButton] = useState<"stay" | "cancel" | null>();
const [isLoadingButton, setIsLoadingButton] = useState<
"stay" | "cancel" | null
>();
const handleCancelButtonClick = () => { const handleCancelButtonClick = () => {
if (isLoadingButton) return; if (isLoadingButton) return;
@ -37,15 +48,22 @@ export default function Buttons() {
setActiveButton("stay"); setActiveButton("stay");
setIsLoadingButton("stay"); setIsLoadingButton("stay");
// const response = await api.userSubscriptionAction({ const response = await performUserSubscriptionAction({
// subscriptionId: cancellingSubscriptionId, subscriptionId: cancellingSubscription?.id || "",
// action: "discount_50", action: "discount_50",
// token });
// });
// if (response.status === "success") { if (response?.data?.status === "success") {
// dispatch(actions.retainingFunnel.setFunnel(ERetainingFunnel.Stay50)); setFunnel(ERetainingFunnel.Stay50);
// } return router.push(ROUTES.retainingFunnelStay50Done());
router.push(ROUTES.retainingFunnelStay50Done()); }
setIsLoadingButton(null);
setActiveButton(null);
addToast({
variant: "error",
message: t("error_message"),
duration: 5000,
});
}; };
return ( return (

View File

@ -1,19 +1,25 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Spinner, Toast } from "@/components/ui"; import { Spinner, Toast } from "@/components/ui";
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { RetainingButton } from "../.."; import { RetainingButton } from "../..";
import styles from "./Buttons.module.scss"; import styles from "./Buttons.module.scss";
// import { useRouter } from "next/navigation";
// import { ROUTES } from "@/shared/constants/client-routes";
export default function Buttons() { export default function Buttons() {
const t = useTranslations("CancellationOfSubscription"); const t = useTranslations("CancellationOfSubscription");
// const router = useRouter(); const router = useRouter();
const { addToast } = useToast();
const { cancellingSubscription } = useRetainingStore(state => state);
const [isToastVisible, setIsToastVisible] = useState(false); const [isToastVisible, setIsToastVisible] = useState(false);
const [isLoadingOfferButton, setIsLoadingOfferButton] = const [isLoadingOfferButton, setIsLoadingOfferButton] =
@ -24,32 +30,44 @@ export default function Buttons() {
const handleOfferButtonClick = async () => { const handleOfferButtonClick = async () => {
if (isLoadingOfferButton || isLoadingCancelButton) return; if (isLoadingOfferButton || isLoadingCancelButton) return;
setIsLoadingOfferButton(true); setIsLoadingOfferButton(true);
// const response = await api.userSubscriptionAction({ const response = await performUserSubscriptionAction({
// subscriptionId: cancellingSubscriptionId, subscriptionId: cancellingSubscription?.id || "",
// action: "pause_60", action: "pause_60",
// token });
// });
// if (response.status === "success") { if (response?.data?.status === "success") {
// navigate(routes.client.retainingFunnelSubscriptionStopped()); return router.push(ROUTES.retainingFunnelSubscriptionStopped());
// } }
setIsLoadingOfferButton(false);
addToast({
variant: "error",
message: t("error_message"),
duration: 5000,
});
}; };
const handleCancelClick = async () => { const handleCancelClick = async () => {
if (isToastVisible || isLoadingOfferButton || isLoadingCancelButton) return; if (isToastVisible || isLoadingOfferButton || isLoadingCancelButton) return;
setIsLoadingCancelButton(true); setIsLoadingCancelButton(true);
setIsToastVisible(true);
// const response = await api.userSubscriptionAction({ const response = await performUserSubscriptionAction({
// subscriptionId: cancellingSubscriptionId, subscriptionId: cancellingSubscription?.id || "",
// action: "cancel", action: "cancel",
// token });
// });
// if (response.status === "success") { if (response?.data?.status === "success") {
// setIsToastVisible(true); setIsToastVisible(true);
// const timer = setTimeout(() => { const timer = setTimeout(() => {
// router.push(ROUTES.profile()); router.push(ROUTES.profile());
// }, 7000); }, 7000);
// return () => clearTimeout(timer); return () => clearTimeout(timer);
// } }
setIsLoadingCancelButton(false);
addToast({
variant: "error",
message: t("error_message"),
duration: 5000,
});
}; };
return ( return (

View File

@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { ERetainingFunnel } from "@/types"; import { ERetainingFunnel } from "@/types";
@ -28,21 +29,20 @@ export default function Buttons({ answers }: ButtonsProps) {
const [activeAnswer, setActiveAnswer] = useState<ChangeMindAnswer | null>( const [activeAnswer, setActiveAnswer] = useState<ChangeMindAnswer | null>(
null null
); );
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel); const { funnel } = useRetainingStore(state => state);
const retainingFunnel = ERetainingFunnel.Red;
const handleNext = (answer: ChangeMindAnswer) => { const handleNext = (answer: ChangeMindAnswer) => {
setActiveAnswer(answer); setActiveAnswer(answer);
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (retainingFunnel === ERetainingFunnel.Red) { if (funnel === ERetainingFunnel.Red) {
router.push(ROUTES.retainingFunnelStopFor30Days()); router.push(ROUTES.retainingFunnelStopFor30Days());
} }
// if (retainingFunnel === ERetainingFunnel.Green) { if (funnel === ERetainingFunnel.Green) {
// router.push(ROUTES.retainingFunnelSecondChance()); router.push(ROUTES.retainingFunnelSecondChance());
// } }
// if (retainingFunnel === ERetainingFunnel.Purple) { if (funnel === ERetainingFunnel.Purple) {
// router.push(ROUTES.retainingFunnelSecondChance()); router.push(ROUTES.retainingFunnelSecondChance());
// } }
}, 1000); }, 1000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}; };

View File

@ -11,7 +11,7 @@
pointer-events: none; pointer-events: none;
margin-top: 211px; margin-top: 211px;
overflow-x: clip; overflow-x: clip;
z-index: 9999; z-index: 8888;
& > .blur { & > .blur {
padding-top: 40px; padding-top: 40px;

View File

@ -9,7 +9,10 @@ import { Offer, RetainingButton } from "@/components/domains/retaining";
import { EyeSvg } from "@/components/domains/retaining/images"; import { EyeSvg } from "@/components/domains/retaining/images";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { BlurComponent } from "@/components/widgets"; import { BlurComponent } from "@/components/widgets";
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
import { useLottie } from "@/hooks/lottie/useLottie"; import { useLottie } from "@/hooks/lottie/useLottie";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { retainingImages } from "@/shared/constants/images/retaining"; import { retainingImages } from "@/shared/constants/images/retaining";
import { ELottieKeys } from "@/shared/constants/lottie"; import { ELottieKeys } from "@/shared/constants/lottie";
@ -26,16 +29,13 @@ export default function SecondChancePage() {
useLottie({ useLottie({
preloadKey: ELottieKeys.confetti, preloadKey: ELottieKeys.confetti,
}); });
const { addToast } = useToast();
const [activeOffer, setActiveOffer] = useState<"pause_30" | "free_chat_30">( const [activeOffer, setActiveOffer] = useState<"pause_30" | "free_chat_30">(
"pause_30" "pause_30"
); );
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false); const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false);
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel); const { funnel, cancellingSubscription } = useRetainingStore(state => state);
// const token = useSelector(selectors.selectToken);
// const cancellingSubscriptionId = useSelector(selectors.selectCancellingSubscriptionId);
const retainingFunnel = ERetainingFunnel.Red;
const handleOfferClick = (offer: "pause_30" | "free_chat_30") => { const handleOfferClick = (offer: "pause_30" | "free_chat_30") => {
if (isLoadingButton) return; if (isLoadingButton) return;
@ -46,26 +46,33 @@ export default function SecondChancePage() {
if (isLoadingButton) return; if (isLoadingButton) return;
setIsLoadingButton(true); setIsLoadingButton(true);
// const response = await api.userSubscriptionAction({ const response = await performUserSubscriptionAction({
// subscriptionId: cancellingSubscriptionId, subscriptionId: cancellingSubscription?.id || "",
// action: activeOffer, action: activeOffer,
// token });
// });
// if (response.status === "success") { if (response?.data?.status === "success") {
// navigate(routes.client.retainingFunnelPlanCancelled()); return router.push(ROUTES.retainingFunnelPlanCancelled());
// } }
setIsLoadingButton(false);
addToast({
variant: "error",
message: t("error_message"),
duration: 5000,
});
}; };
const handleCancelClick = () => { const handleCancelClick = () => {
if (isLoadingButton) return; if (isLoadingButton) return;
if (retainingFunnel === ERetainingFunnel.Red) { if (funnel === ERetainingFunnel.Red) {
router.push(ROUTES.retainingFunnelChangeMind()); router.push(ROUTES.retainingFunnelChangeMind());
} }
// if (retainingFunnel === ERetainingFunnel.Green) { if (funnel === ERetainingFunnel.Green) {
// return navigate(routes.client.retainingFunnelCancellationOfSubscription()); return router.push(ROUTES.retainingFunnelCancellationOfSubscription());
// } }
// if (retainingFunnel === ERetainingFunnel.Purple) { if (funnel === ERetainingFunnel.Purple) {
// return navigate(routes.client.retainingFunnelStopFor30Days()); return router.push(ROUTES.retainingFunnelStopFor30Days());
}
}; };
return ( return (
@ -88,7 +95,6 @@ export default function SecondChancePage() {
onClick={() => handleOfferClick("free_chat_30")} onClick={() => handleOfferClick("free_chat_30")}
active={activeOffer === "free_chat_30"} active={activeOffer === "free_chat_30"}
image={ image={
// <img className={styles.offerImageVIP} src={images("vip_member.png")} alt="vip member" />
<Image <Image
src={retainingImages("vip_member.png")} src={retainingImages("vip_member.png")}
alt="vip member" alt="vip member"

View File

@ -6,46 +6,56 @@ import { useTranslations } from "next-intl";
import { RetainingButton } from "@/components/domains/retaining"; import { RetainingButton } from "@/components/domains/retaining";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
import { useLottie } from "@/hooks/lottie/useLottie"; import { useLottie } from "@/hooks/lottie/useLottie";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { ELottieKeys } from "@/shared/constants/lottie"; import { ELottieKeys } from "@/shared/constants/lottie";
import { ERetainingFunnel } from "@/types";
import styles from "./Buttons.module.scss"; import styles from "./Buttons.module.scss";
export default function Buttons() { export default function Buttons() {
const t = useTranslations("StopFor30Days"); const t = useTranslations("StopFor30Days");
const router = useRouter(); const router = useRouter();
// const api = useApi();
// const token = useSelector(selectors.selectToken);
// const cancellingSubscriptionId = useSelector(selectors.selectCancellingSubscriptionId);
useLottie({ useLottie({
preloadKey: ELottieKeys.loaderCheckMark2, preloadKey: ELottieKeys.loaderCheckMark2,
}); });
useLottie({ useLottie({
preloadKey: ELottieKeys.confetti, preloadKey: ELottieKeys.confetti,
}); });
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel); const { addToast } = useToast();
// const retainingFunnel = ERetainingFunnel.Red;
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false); const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false);
const { funnel, cancellingSubscription } = useRetainingStore(state => state);
const handleStopClick = async () => { const handleStopClick = async () => {
if (isLoadingButton) return; if (isLoadingButton) return;
setIsLoadingButton(true); setIsLoadingButton(true);
// const response = await api.userSubscriptionAction({
// subscriptionId: cancellingSubscriptionId, const response = await performUserSubscriptionAction({
// action: "pause_30", subscriptionId: cancellingSubscription?.id || "",
// token action: "pause_30",
// }); });
// if (response.status === "success") {
// navigate(routes.client.retainingFunnelSubscriptionStopped()); if (response?.data?.status === "success") {
// } return router.push(ROUTES.retainingFunnelSubscriptionStopped());
}
setIsLoadingButton(false);
addToast({
variant: "error",
message: t("error_message"),
duration: 5000,
});
}; };
const handleCancelClick = () => { const handleCancelClick = () => {
if (isLoadingButton) return; if (isLoadingButton) return;
// if (retainingFunnel === ERetainingFunnel.Green) { if (funnel === ERetainingFunnel.Green) {
// return navigate(routes.client.retainingFunnelChangeMind()); return router.push(ROUTES.retainingFunnelChangeMind());
// } }
router.push(ROUTES.retainingFunnelCancellationOfSubscription()); router.push(ROUTES.retainingFunnelCancellationOfSubscription());
}; };

View File

@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { ERetainingFunnel } from "@/types"; import { ERetainingFunnel } from "@/types";
@ -23,13 +24,15 @@ interface ButtonsProps {
export default function Buttons({ answers }: ButtonsProps) { export default function Buttons({ answers }: ButtonsProps) {
const router = useRouter(); const router = useRouter();
const { setFunnel } = useRetainingStore(state => state);
const [activeAnswer, setActiveAnswer] = useState<WhatReasonAnswer | null>( const [activeAnswer, setActiveAnswer] = useState<WhatReasonAnswer | null>(
null null
); );
const handleNext = (answer: WhatReasonAnswer) => { const handleNext = (answer: WhatReasonAnswer) => {
setActiveAnswer(answer); setActiveAnswer(answer);
// dispatch(actions.retainingFunnel.setFunnel(answer.funnel)); setFunnel(answer.funnel);
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (answer.funnel === ERetainingFunnel.Red) { if (answer.funnel === ERetainingFunnel.Red) {
router.push(ROUTES.retainingFunnelSecondChance()); router.push(ROUTES.retainingFunnelSecondChance());

View File

@ -19,7 +19,7 @@
font-size: 16px; font-size: 16px;
background-color: #f1f1f1; background-color: #f1f1f1;
outline: none; outline: none;
appearance: none; // Removes default arrow appearance: none;
transition: transition:
border-color 0.2s, border-color 0.2s,
box-shadow 0.2s; box-shadow 0.2s;

View File

@ -58,7 +58,7 @@
} }
.inputError { .inputError {
border-color: #ef4444 !important; // red-500 border-color: #ef4444 !important;
&:focus { &:focus {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
} }

View File

@ -1,55 +1,122 @@
.container {
width: 100%;
}
.toast { .toast {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: 24px 1fr; grid-template-columns: 24px 1fr auto;
gap: 6px; gap: 12px;
align-items: center; align-items: center;
padding: 16px; padding: 16px;
border-radius: 12px; border-radius: 12px;
font-size: 14px; font-size: 14px;
color: #000; color: #000;
animation: appearance 0.8s box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
linear( backdrop-filter: blur(10px);
0 0%, border: 1px solid rgba(255, 255, 255, 0.1);
0 1.8%, transition: all 0.2s ease;
0.01 3.6%,
0.08 10.03%, &:hover {
0.15 14.25%, transform: translateY(-1px);
0.2 14.34%, box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
0.31 14.14%, }
0.41 17.21%, }
0.49 19.04%,
0.58 20.56%, .content {
0.66 22.07%, display: flex;
0.76 23.87%, align-items: center;
0.84 26.07%, min-height: 24px;
0.93 28.04%, }
1.03 31.14%,
1.09 37.31%, .closeButton {
1.09 44.28%, background: none;
1.02 49.41%, border: none;
0.96 55%, padding: 4px;
0.98 64%, border-radius: 4px;
0.99 74.4%, cursor: pointer;
1 86.4%, color: inherit;
1 100% opacity: 0.6;
); transition: all 0.2s ease;
animation-fill-mode: forwards; display: flex;
align-items: center;
justify-content: center;
&:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
&:focus {
outline: 2px solid currentColor;
outline-offset: 2px;
}
} }
.toast.error { .toast.error {
background-color: #ffdcdc; background-color: rgba(255, 220, 220, 0.95);
border-color: rgba(225, 43, 43, 0.2);
color: #8b0000;
} }
.toast.success { .toast.success {
background-color: #d9ffd9; background-color: rgba(217, 255, 217, 0.95);
border-color: rgba(34, 197, 94, 0.2);
color: #166534;
}
.toast.warning {
background-color: rgba(255, 251, 235, 0.95);
border-color: rgba(245, 158, 11, 0.2);
color: #92400e;
}
.toast.info {
background-color: rgba(219, 234, 254, 0.95);
border-color: rgba(59, 130, 246, 0.2);
color: #1e40af;
} }
@keyframes appearance { @keyframes appearance {
0% { 0% {
transform: translateY(100%); transform: translateY(100%) scale(0.8);
opacity: 0;
} }
100% { 100% {
transform: translateY(0); transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes disappearance {
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateY(100%) scale(0.8);
opacity: 0;
}
}
.toast {
animation: appearance 0.3s ease-out;
animation-fill-mode: both;
}
.toast.removing {
animation: disappearance 0.3s ease-in;
animation-fill-mode: both;
}
@media (max-width: 480px) {
.toast {
padding: 12px;
gap: 8px;
font-size: 13px;
}
.closeButton {
padding: 2px;
} }
} }

View File

@ -4,25 +4,114 @@ import ErrorIcon from "./ErrorIcon/ErrorIcon";
import styles from "./Toast.module.scss"; import styles from "./Toast.module.scss";
export type ToastVariant = "error" | "success" | "warning" | "info";
interface IToastProps { interface IToastProps {
variant: "error" | "success"; variant: ToastVariant;
children: React.ReactNode; children: React.ReactNode;
classNameContainer?: string; classNameContainer?: string;
classNameToast?: string; classNameToast?: string;
onClose?: () => void;
} }
const getIcon = (variant: ToastVariant) => {
switch (variant) {
case "error":
return <ErrorIcon />;
case "success":
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle
cx="12"
cy="12"
r="11"
strokeWidth={1.5}
stroke="currentColor"
/>
<path
d="M8 12L11 15L16 9"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
case "warning":
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L2 20H22L12 2Z"
stroke="currentColor"
strokeWidth={1.5}
fill="none"
/>
<path
d="M12 9V13"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
/>
<circle cx="12" cy="17" r="1" fill="currentColor" />
</svg>
);
case "info":
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle
cx="12"
cy="12"
r="11"
strokeWidth={1.5}
stroke="currentColor"
/>
<path
d="M12 8V12"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
/>
<path
d="M12 16H12.01"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
/>
</svg>
);
}
};
function Toast({ function Toast({
variant, variant,
children, children,
classNameContainer = "", classNameContainer = "",
classNameToast = "", classNameToast = "",
onClose,
}: IToastProps) { }: IToastProps) {
return ( return (
<div className={`${styles.container} ${classNameContainer}`}> <div className={`${styles.container} ${classNameContainer}`}>
<div className={`${styles.toast} ${styles[variant]} ${classNameToast}`}> <div className={`${styles.toast} ${styles[variant]} ${classNameToast}`}>
{variant === "error" && <ErrorIcon />} {getIcon(variant)}
{variant === "success" && <div />} <div className={styles.content}>{children}</div>
{children} {onClose && (
<button
onClick={onClose}
className={styles.closeButton}
aria-label="Закрыть уведомление"
type="button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12 4L4 12M4 4L12 12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,7 +1,7 @@
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; // Увеличим отступ между полями gap: 24px;
} }
.fieldWrapper { .fieldWrapper {
@ -18,7 +18,7 @@
} }
.buttonWrapper { .buttonWrapper {
margin-top: 8px; // Небольшой отступ перед кнопкой margin-top: 8px;
} }
.button { .button {

View File

@ -15,14 +15,9 @@ const validate = (fields: ActionField[], values: FormValues): FormErrors => {
for (const field of fields) { for (const field of fields) {
const value = values[field.key]; const value = values[field.key];
// Для примера добавим правило, что все поля обязательны
if (value === null || value === "" || value === undefined) { if (value === null || value === "" || value === undefined) {
errors[field.key] = "Это поле обязательно для заполнения"; errors[field.key] = "Это поле обязательно для заполнения";
} }
// Можно добавлять более сложные правила
// if (field.key === 'your_name' && value && value.length < 2) {
// errors[field.key] = 'Имя должно содержать минимум 2 символа';
// }
} }
return errors; return errors;

View File

@ -7,8 +7,8 @@
margin: 0 auto; margin: 0 auto;
} }
.title, .title.title,
.text { .text.text {
color: #2a74dd; color: #2a74dd;
} }
@ -19,7 +19,7 @@
gap: 16px; gap: 16px;
} }
.content { .content.content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-inline: 8px; padding-inline: 8px;
@ -27,10 +27,10 @@
transition: height 2s ease; transition: height 2s ease;
} }
.seeAllButton { .seeAllButton.seeAllButton {
padding: 0 !important; padding: 0;
background: none !important; background: none;
border-radius: 0 !important; border-radius: 0;
width: fit-content !important; width: fit-content;
margin-left: auto; margin-left: auto;
} }

View File

@ -13,14 +13,13 @@
.inputsWrapper { .inputsWrapper {
display: grid; display: grid;
// Делаем селектор AM/PM немного уже
grid-template-columns: 1fr 1fr 0.8fr; grid-template-columns: 1fr 1fr 0.8fr;
gap: 12px; gap: 12px;
} }
.errorText { .errorText {
font-size: 12px; font-size: 12px;
color: #ef4444; // red-500 color: #ef4444;
margin: 0; margin: 0;
min-height: 1.2em; min-height: 1.2em;
} }

View File

@ -0,0 +1,68 @@
.container {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9995;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
width: calc(100vw - 32px);
pointer-events: none;
}
.toastWrapper {
pointer-events: auto;
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
animation-delay: calc(var(--toast-index) * 0.1s);
&:not(:first-child) {
margin-top: calc(var(--toast-index) * -8px);
transform: scale(calc(1 - var(--toast-index) * 0.05));
opacity: calc(1 - var(--toast-index) * 0.2);
}
}
// .queueIndicator {
// background: rgba(0, 0, 0, 0.7);
// color: white;
// padding: 8px 16px;
// border-radius: 20px;
// font-size: 12px;
// text-align: center;
// pointer-events: auto;
// animation: fadeIn 0.3s ease-out;
// backdrop-filter: blur(10px);
// }
@keyframes slideIn {
from {
transform: translateY(100%) scale(0.8);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 480px) {
.container {
bottom: 16px;
width: calc(100vw - 24px);
max-width: none;
}
}

View File

@ -0,0 +1,103 @@
"use client";
import React, { memo, useEffect, useRef } from "react";
import { Toast } from "@/components/ui";
import { useToast } from "@/providers/toast-provider";
import styles from "./ToastContainer.module.scss";
const ToastContainer = memo(() => {
const { state, removeToast, setToastVisible, updateToastTimer } = useToast();
const { toasts, maxVisible } = state;
const timersRef = useRef<Map<string, number>>(new Map());
const visibleToasts = toasts.slice(0, maxVisible);
useEffect(() => {
timersRef.current.forEach((timerId, toastId) => {
const isStillVisible = visibleToasts.some(toast => toast.id === toastId);
if (!isStillVisible) {
clearTimeout(timerId);
timersRef.current.delete(toastId);
}
});
visibleToasts.forEach(toast => {
if (!toast.isVisible) {
const startTime = Date.now();
setToastVisible(toast.id, true, startTime);
if (toast.duration && toast.duration > 0) {
const timerId = window.setTimeout(() => {
removeToast(toast.id);
timersRef.current.delete(toast.id);
}, toast.duration);
updateToastTimer(toast.id, timerId);
timersRef.current.set(toast.id, timerId);
}
}
});
toasts.slice(maxVisible).forEach(toast => {
if (toast.isVisible) {
setToastVisible(toast.id, false);
}
});
}, [
visibleToasts,
toasts,
maxVisible,
setToastVisible,
removeToast,
updateToastTimer,
]);
useEffect(() => {
const timers = timersRef.current;
return () => {
timers.forEach(timerId => {
clearTimeout(timerId);
});
timers.clear();
};
}, []);
return (
<div className={styles.container}>
{visibleToasts.map((toast, index) => (
<div
key={toast.id}
className={styles.toastWrapper}
style={
{
"--toast-index": index,
"--total-toasts": visibleToasts.length,
} as React.CSSProperties
}
>
<Toast
variant={toast.variant}
classNameContainer={toast.classNameContainer}
classNameToast={toast.classNameToast}
onClose={() => removeToast(toast.id)}
>
{toast.message}
</Toast>
</div>
))}
{/* {toasts.length > maxVisible && (
<div className={styles.queueIndicator}>
+{toasts.length - maxVisible} в очереди
</div>
)} */}
</div>
);
});
ToastContainer.displayName = "ToastContainer";
export default ToastContainer;

View File

@ -6,3 +6,4 @@ export { default as Horoscope } from "./Horoscope/Horoscope";
export { default as LottieAnimation } from "./LottieAnimation/LottieAnimation"; export { default as LottieAnimation } from "./LottieAnimation/LottieAnimation";
export { default as Table } from "./Table/Table"; export { default as Table } from "./Table/Table";
export { default as TimePicker } from "./TimePicker/TimePicker"; export { default as TimePicker } from "./TimePicker/TimePicker";
export { default as ToastContainer } from "./ToastContainer/ToastContainer";

View File

@ -15,7 +15,7 @@ export const AssistantSchema = z.object({
photoUrl: z.string().url(), photoUrl: z.string().url(),
externalId: z.string(), externalId: z.string(),
clientSource: z.string(), clientSource: z.string(),
createdAt: z.string(), // ISO-строка даты createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
}); });
export type Assistant = z.infer<typeof AssistantSchema>; export type Assistant = z.infer<typeof AssistantSchema>;
@ -26,58 +26,33 @@ export const FieldSchema = z.object({
actionId: z.string(), actionId: z.string(),
key: z.string(), key: z.string(),
title: z.string(), title: z.string(),
inputType: z.string(), // text | date | time … inputType: z.string(),
model: z.string().optional(), // присутствует не всегда model: z.string().optional(),
property: z.string().optional(), property: z.string().optional(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
}); });
export type Field = z.infer<typeof FieldSchema>; export type Field = z.infer<typeof FieldSchema>;
/* ---------- CompatibilityAction ---------- */ /* ---------- Action ---------- */
export const CompatibilityActionSchema = z.object({ export const ActionSchema = z.object({
_id: z.string(), _id: z.string(),
title: z.string(), title: z.string(),
minutes: z.number(), minutes: z.number(),
type: z.string(), type: z.string(),
imageUrl: z.string().url(), imageUrl: z.string().url(),
prompt: z.string(), prompt: z.string().optional(),
fields: z.array(FieldSchema), fields: z.array(FieldSchema).optional(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
}); });
export type CompatibilityAction = z.infer<typeof CompatibilityActionSchema>; export type Action = z.infer<typeof ActionSchema>;
/* ---------- PalmAction ---------- */
export const PalmActionSchema = z.object({
_id: z.string(),
title: z.string(),
minutes: z.number(),
type: z.string(),
imageUrl: z.string().url(),
prompt: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type PalmAction = z.infer<typeof PalmActionSchema>;
/* ---------- Meditation ---------- */
export const MeditationSchema = z.object({
_id: z.string(),
title: z.string(),
minutes: z.number(),
type: z.string(),
imageUrl: z.string().url(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Meditation = z.infer<typeof MeditationSchema>;
/* ---------- Итоговый ответ /dashboard ---------- */ /* ---------- Итоговый ответ /dashboard ---------- */
export const DashboardSchema = z.object({ export const DashboardSchema = z.object({
assistants: z.array(AssistantSchema), assistants: z.array(AssistantSchema),
compatibilityActions: z.array(CompatibilityActionSchema), compatibilityActions: z.array(ActionSchema),
palmActions: z.array(PalmActionSchema), palmActions: z.array(ActionSchema),
meditations: z.array(MeditationSchema), meditations: z.array(ActionSchema),
}); });
export type DashboardData = z.infer<typeof DashboardSchema>; export type DashboardData = z.infer<typeof DashboardSchema>;

View File

@ -41,7 +41,7 @@ export async function fetchGenerationStatus(
API_ROUTES.statusGeneration(id), API_ROUTES.statusGeneration(id),
{ {
schema: GenerationResponseSchema, schema: GenerationResponseSchema,
revalidate: 0, // Всегда запрашиваем свежие данные revalidate: 0,
} }
); );
return { data: response, error: null }; return { data: response, error: null };

View File

@ -5,9 +5,7 @@ import { z } from "zod";
export const StartGenerationRequestSchema = z.object({ export const StartGenerationRequestSchema = z.object({
actionType: z.enum(["compatibility", "palm"]), actionType: z.enum(["compatibility", "palm"]),
actionId: z.string(), actionId: z.string(),
variables: z variables: z.record(z.string().or(z.number()).or(z.null())).optional(),
.record(z.string().or(z.number()).or(z.null())) // Record<string, string>
.optional(),
}); });
export type StartGenerationRequest = z.infer< export type StartGenerationRequest = z.infer<
@ -18,9 +16,9 @@ export type StartGenerationRequest = z.infer<
export const GenerationResponseSchema = z.object({ export const GenerationResponseSchema = z.object({
id: z.string(), id: z.string(),
status: z.string(), // e.g., "queued", "processing", "completed", "failed" status: z.string(),
locale: z.string(), locale: z.string(),
result: z.string().nullable(), // The result can be of any type when not null result: z.string().nullable(),
}); });
export type GenerationResponse = z.infer<typeof GenerationResponseSchema>; export type GenerationResponse = z.infer<typeof GenerationResponseSchema>;

View File

@ -8,7 +8,7 @@ export const CheckoutRequestSchema = z.object({
export type CheckoutRequest = z.infer<typeof CheckoutRequestSchema>; export type CheckoutRequest = z.infer<typeof CheckoutRequestSchema>;
export const CheckoutResponseSchema = z.object({ export const CheckoutResponseSchema = z.object({
status: z.string(), // "paid" | "pending" | … status: z.string(),
invoiceId: z.string(), invoiceId: z.string(),
paymentUrl: z.string().url(), paymentUrl: z.string().url(),
}); });

View File

@ -0,0 +1,34 @@
"use server";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import { ActionResponse } from "@/types";
import {
UserSubscriptionActionPayload,
UserSubscriptionActionResponse,
UserSubscriptionActionResponseSchema,
} from "./types";
export async function performUserSubscriptionAction(
payload: UserSubscriptionActionPayload
): Promise<ActionResponse<UserSubscriptionActionResponse>> {
try {
const response = await http.post<UserSubscriptionActionResponse>(
API_ROUTES.userSubscriptionAction(payload.subscriptionId, payload.action),
payload,
{
schema: UserSubscriptionActionResponseSchema,
revalidate: 0,
}
);
return { data: response, error: null };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to perform user subscription action:", error);
const errorMessage =
error instanceof Error ? error.message : "Произошла неизвестная ошибка.";
return { data: null, error: errorMessage };
}
}

View File

@ -1,7 +1,13 @@
import { http } from "@/shared/api/httpClient"; import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes"; import { API_ROUTES } from "@/shared/constants/api-routes";
import { SubscriptionsData, SubscriptionsSchema } from "./types"; import {
SubscriptionsData,
SubscriptionsSchema,
UserSubscriptionActionPayload,
UserSubscriptionActionResponse,
UserSubscriptionActionResponseSchema,
} from "./types";
export const getSubscriptions = async () => { export const getSubscriptions = async () => {
return http.get<SubscriptionsData>(API_ROUTES.subscriptions(), { return http.get<SubscriptionsData>(API_ROUTES.subscriptions(), {
@ -10,3 +16,19 @@ export const getSubscriptions = async () => {
revalidate: 0, revalidate: 0,
}); });
}; };
export const performUserSubscriptionAction = async (
payload: UserSubscriptionActionPayload
) => {
const { subscriptionId, action } = payload;
return http.post<UserSubscriptionActionResponse>(
API_ROUTES.userSubscriptionAction(subscriptionId, action),
payload,
{
tags: ["user-subscription-action"],
schema: UserSubscriptionActionResponseSchema,
revalidate: 0,
}
);
};

View File

@ -26,7 +26,34 @@ export const UserSubscriptionSchema = z.object({
export type UserSubscription = z.infer<typeof UserSubscriptionSchema>; export type UserSubscription = z.infer<typeof UserSubscriptionSchema>;
export const SubscriptionsSchema = z.object({ export const SubscriptionsSchema = z.object({
status: z.string(), // "success" | string status: z.string(),
data: z.array(UserSubscriptionSchema), data: z.array(UserSubscriptionSchema),
}); });
export type SubscriptionsData = z.infer<typeof SubscriptionsSchema>; export type SubscriptionsData = z.infer<typeof SubscriptionsSchema>;
export const UserSubscriptionActionEnumSchema = z.enum([
"cancel",
"discount_50",
"pause_30",
"pause_60",
"free_chat_30",
]);
export type UserSubscriptionActionEnum = z.infer<
typeof UserSubscriptionActionEnumSchema
>;
export const UserSubscriptionActionPayloadSchema = z.object({
subscriptionId: z.string(),
action: UserSubscriptionActionEnumSchema,
});
export type UserSubscriptionActionPayload = z.infer<
typeof UserSubscriptionActionPayloadSchema
>;
export const UserSubscriptionActionResponseSchema = z.object({
status: z.string(),
data: z.array(UserSubscriptionSchema),
});
export type UserSubscriptionActionResponse = z.infer<
typeof UserSubscriptionActionResponseSchema
>;

View File

@ -11,9 +11,11 @@ const IpLookupSchema = z
.optional(); .optional();
const ProfileSchema = z.object({ const ProfileSchema = z.object({
birthplace: z.object({ birthplace: z
address: z.string(), .object({
}).optional(), address: z.string(),
})
.optional(),
name: z.string(), name: z.string(),
birthdate: z.string(), birthdate: z.string(),
gender: z.string(), gender: z.string(),

View File

@ -27,7 +27,7 @@ export function useGenerationPolling(id: string, interval = 3000) {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
setIsLoading(false); setIsLoading(false);
return; // Останавливаем опрос при ошибке return;
} }
const status = response.data?.status; const status = response.data?.status;

View File

@ -1,22 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRetainingStore } from "@/stores/retainingStore";
export function StoreProvider({ children }: { children: React.ReactNode }) {
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
// Гидратируем store
useRetainingStore.persist.rehydrate();
setIsHydrated(true);
}, []);
// Показываем children только после гидратации
if (!isHydrated) {
return null; // или loading spinner
}
return <>{children}</>;
}

View File

@ -0,0 +1,48 @@
"use client";
import { createContext, type ReactNode, useContext, useRef } from "react";
import { useStore } from "zustand";
import {
createRetainingStore,
type RetainingStore,
} from "@/stores/retaining-store";
export type RetiningStoreApi = ReturnType<typeof createRetainingStore>;
export const RetainingStoreContext = createContext<
RetiningStoreApi | undefined
>(undefined);
export interface RetainingStoreProviderProps {
children: ReactNode;
}
export const RetainingStoreProvider = ({
children,
}: RetainingStoreProviderProps) => {
const storeRef = useRef<RetiningStoreApi | null>(null);
if (storeRef.current === null) {
storeRef.current = createRetainingStore();
}
return (
<RetainingStoreContext.Provider value={storeRef.current}>
{children}
</RetainingStoreContext.Provider>
);
};
export const useRetainingStore = <T,>(
selector: (store: RetainingStore) => T
): T => {
const retainingStoreContext = useContext(RetainingStoreContext);
if (!retainingStoreContext) {
throw new Error(
`useRetainingStore must be used within RetainingStoreProvider`
);
}
return useStore(retainingStoreContext, selector);
};

View File

@ -0,0 +1,212 @@
"use client";
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useReducer,
} from "react";
import { ToastContainer } from "@/components/widgets";
export type ToastVariant = "error" | "success" | "warning" | "info";
export interface Toast {
id: string;
variant: ToastVariant;
message: string | React.ReactNode;
duration?: number;
classNameContainer?: string;
classNameToast?: string;
priority?: number;
isVisible?: boolean;
timerId?: number;
startTime?: number;
}
interface ToastState {
toasts: Toast[];
maxVisible: number;
}
type ToastAction =
| { type: "ADD_TOAST"; payload: Toast }
| { type: "REMOVE_TOAST"; payload: { id: string } }
| { type: "CLEAR_TOASTS" }
| { type: "SET_MAX_VISIBLE"; payload: { max: number } }
| {
type: "SET_TOAST_VISIBLE";
payload: { id: string; isVisible: boolean; startTime?: number };
}
| { type: "UPDATE_TOAST_TIMER"; payload: { id: string; timerId: number } };
const initialState: ToastState = {
toasts: [],
maxVisible: 3,
};
function toastReducer(state: ToastState, action: ToastAction): ToastState {
switch (action.type) {
case "ADD_TOAST":
const newToasts = [...state.toasts, action.payload].sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0)
);
return { ...state, toasts: newToasts };
case "REMOVE_TOAST":
return {
...state,
toasts: state.toasts.filter(toast => toast.id !== action.payload.id),
};
case "CLEAR_TOASTS":
return { ...state, toasts: [] };
case "SET_MAX_VISIBLE":
return { ...state, maxVisible: action.payload.max };
case "SET_TOAST_VISIBLE":
return {
...state,
toasts: state.toasts.map(toast =>
toast.id === action.payload.id
? {
...toast,
isVisible: action.payload.isVisible,
startTime: action.payload.startTime,
}
: toast
),
};
case "UPDATE_TOAST_TIMER":
return {
...state,
toasts: state.toasts.map(toast =>
toast.id === action.payload.id
? { ...toast, timerId: action.payload.timerId }
: toast
),
};
default:
return state;
}
}
interface ToastContextType {
state: ToastState;
addToast: (toast: Omit<Toast, "id" | "isVisible">) => string;
removeToast: (id: string) => void;
clearToasts: () => void;
setMaxVisible: (max: number) => void;
setToastVisible: (id: string, isVisible: boolean, startTime?: number) => void;
updateToastTimer: (id: string, timerId: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
interface ToastProviderProps {
children: ReactNode;
maxVisible?: number;
}
export const ToastProvider = ({
children,
maxVisible = 3,
}: ToastProviderProps) => {
const [state, dispatch] = useReducer(toastReducer, {
...initialState,
maxVisible,
});
const addToast = useCallback((toast: Omit<Toast, "id" | "isVisible">) => {
const id = Math.random().toString(36).substr(2, 9);
const newToast = {
...toast,
id,
priority: toast.priority ?? 0,
isVisible: false,
};
dispatch({ type: "ADD_TOAST", payload: newToast });
return id;
}, []);
const removeToast = useCallback(
(id: string) => {
const toast = state.toasts.find(t => t.id === id);
if (toast?.timerId) {
clearTimeout(toast.timerId);
}
dispatch({ type: "REMOVE_TOAST", payload: { id } });
},
[state.toasts]
);
const clearToasts = useCallback(() => {
state.toasts.forEach(toast => {
if (toast.timerId) {
clearTimeout(toast.timerId);
}
});
dispatch({ type: "CLEAR_TOASTS" });
}, [state.toasts]);
const setMaxVisible = useCallback((max: number) => {
dispatch({ type: "SET_MAX_VISIBLE", payload: { max } });
}, []);
const setToastVisible = useCallback(
(id: string, isVisible: boolean, startTime?: number) => {
dispatch({
type: "SET_TOAST_VISIBLE",
payload: { id, isVisible, startTime },
});
},
[]
);
const updateToastTimer = useCallback((id: string, timerId: number) => {
dispatch({ type: "UPDATE_TOAST_TIMER", payload: { id, timerId } });
}, []);
const contextValue = React.useMemo(
() => ({
state,
addToast,
removeToast,
clearToasts,
setMaxVisible,
setToastVisible,
updateToastTimer,
}),
[
state,
addToast,
removeToast,
clearToasts,
setMaxVisible,
setToastVisible,
updateToastTimer,
]
);
return (
<ToastContext.Provider value={contextValue}>
{children}
<ToastContainer />
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within ToastProvider");
}
return context;
};

View File

@ -1,4 +1,3 @@
// src/shared/auth/token.ts
import { cookies } from "next/headers"; import { cookies } from "next/headers";
export async function getServerAccessToken() { export async function getServerAccessToken() {

View File

@ -1,3 +1,5 @@
import { UserSubscriptionActionEnum } from "@/entities/subscriptions/types";
const ROOT_ROUTE = "/"; const ROOT_ROUTE = "/";
const ROOT_ROUTE_V2 = "/v2/"; const ROOT_ROUTE_V2 = "/v2/";
const ROOT_ROUTE_V3 = "/v3/"; const ROOT_ROUTE_V3 = "/v3/";
@ -18,4 +20,12 @@ export const API_ROUTES = {
createRoute(["dashboard", "compatibility-actions", id, "fields"]), createRoute(["dashboard", "compatibility-actions", id, "fields"]),
startGeneration: () => createRoute(["generations", "start"]), startGeneration: () => createRoute(["generations", "start"]),
statusGeneration: (id: string) => createRoute(["generations", "status", id]), statusGeneration: (id: string) => createRoute(["generations", "status", id]),
userSubscriptionAction: (
subscriptionId: string,
action: UserSubscriptionActionEnum
) =>
createRoute(
["payment", "subscriptions", subscriptionId, action],
ROOT_ROUTE_V3
),
}; };

View File

@ -12,8 +12,8 @@ export const ROUTES = {
// Compatibility // Compatibility
compatibility: (id: string) => createRoute(["compatibility", id]), compatibility: (id: string) => createRoute(["compatibility", id]),
compatibilityResult: (id: string) => compatibilityResult: (id: string, resultId: string) =>
createRoute(["compatibility", "result", id]), createRoute(["compatibility", id, "result", resultId]),
// Palmistry // Palmistry
palmistryResult: (id: string) => createRoute(["palmistry", "result", id]), palmistryResult: (id: string) => createRoute(["palmistry", "result", id]),

View File

@ -0,0 +1,56 @@
"use client";
import { createStore } from "zustand";
import { persist } from "zustand/middleware";
import { UserSubscription } from "@/entities/subscriptions/types";
export enum ERetainingFunnel {
Red = "red",
Green = "green",
Purple = "purple",
Stay50 = "stay50",
}
interface RetainingState {
funnel: ERetainingFunnel;
cancellingSubscription: UserSubscription | null;
}
export type RetainingActions = {
setFunnel: (funnel: ERetainingFunnel) => void;
setCancellingSubscription: (cancellingSubscription: UserSubscription) => void;
setRetainingData: (data: {
funnel: ERetainingFunnel;
cancellingSubscription: UserSubscription;
}) => void;
clearRetainingData: () => void;
};
export type RetainingStore = RetainingState & RetainingActions;
const initialState: RetainingState = {
funnel: ERetainingFunnel.Red,
cancellingSubscription: null,
};
export const createRetainingStore = (
initState: RetainingState = initialState
) => {
return createStore<RetainingStore>()(
persist(
set => ({
...initState,
setFunnel: (funnel: ERetainingFunnel) => set({ funnel }),
setCancellingSubscription: (cancellingSubscription: UserSubscription) =>
set({ cancellingSubscription }),
setRetainingData: (data: {
funnel: ERetainingFunnel;
cancellingSubscription: UserSubscription;
}) => set(data),
clearRetainingData: () => set(initialState),
}),
{ name: "retaining-storage" }
)
);
};

View File

@ -1,70 +0,0 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { UserSubscription } from "@/entities/subscriptions/types";
export enum ERetainingFunnel {
Red = "red",
Green = "green",
Purple = "purple",
Stay50 = "stay50",
}
interface RetainingState {
funnel: ERetainingFunnel;
cancellingSubscription: UserSubscription | null;
setFunnel: (funnel: ERetainingFunnel) => void;
setCancellingSubscription: (cancellingSubscription: UserSubscription) => void;
setRetainingData: (data: {
funnel: ERetainingFunnel;
cancellingSubscription: UserSubscription;
}) => void;
clearRetainingData: () => void;
}
const initialState = {
funnel: ERetainingFunnel.Red,
cancellingSubscription: null,
};
export const useRetainingStore = create<RetainingState>()(
persist(
set => ({
...initialState,
setFunnel: (funnel: ERetainingFunnel) => set({ funnel }),
setCancellingSubscription: (cancellingSubscription: UserSubscription) =>
set({ cancellingSubscription }),
setRetainingData: (data: {
funnel: ERetainingFunnel;
cancellingSubscription: UserSubscription;
}) => set(data),
clearRetainingData: () => set(initialState),
}),
{
name: "retaining-storage",
// partialize: (state) => ({
// funnel: state.funnel,
// cancellingSubscription: state.cancellingSubscription,
// }),
}
)
);
export const useRetainingFunnel = () =>
useRetainingStore(state => state.funnel);
export const useCancellingSubscriptionId = () =>
useRetainingStore(state => state.cancellingSubscription);
export const useRetainingActions = () =>
useRetainingStore(state => ({
setFunnel: state.setFunnel,
setCancellingSubscription: state.setCancellingSubscription,
setRetainingData: state.setRetainingData,
clearRetainingData: state.clearRetainingData,
}));

View File

@ -47,12 +47,12 @@ export const ActionFieldSchema = z.object({
actionId: z.string(), actionId: z.string(),
key: z.string(), key: z.string(),
title: z.string(), title: z.string(),
inputType: z.string(), // text | date | time … inputType: z.string(),
model: z.string().optional(), // присутствует не всегда model: z.string().optional(),
property: z.string().optional(), property: z.string().optional(),
createdAt: z.string(), // ISO-строка даты createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
value: z.union([z.string(), z.number(), z.null()]).optional(), // может быть строкой, числом или null value: z.union([z.string(), z.number(), z.null()]).optional(),
}); });
export type ActionField = z.infer<typeof ActionFieldSchema>; export type ActionField = z.infer<typeof ActionFieldSchema>;