main
done retaining funnel, edits compatibility & palms & home
This commit is contained in:
parent
67f4dfdf3d
commit
12836b372d
@ -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": "Стандартный план Отменен!",
|
||||||
|
|||||||
@ -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")}
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
.title {
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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} />;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
.title {
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/components/widgets/ToastContainer/ToastContainer.tsx
Normal file
103
src/components/widgets/ToastContainer/ToastContainer.tsx
Normal 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;
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
34
src/entities/subscriptions/actions.ts
Normal file
34
src/entities/subscriptions/actions.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
>;
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}</>;
|
|
||||||
}
|
|
||||||
48
src/providers/retaining-store-provider.tsx
Normal file
48
src/providers/retaining-store-provider.tsx
Normal 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);
|
||||||
|
};
|
||||||
212
src/providers/toast-provider.tsx
Normal file
212
src/providers/toast-provider.tsx
Normal 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;
|
||||||
|
};
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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]),
|
||||||
|
|||||||
56
src/stores/retaining-store.ts
Normal file
56
src/stores/retaining-store.ts
Normal 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" }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
|
||||||
}));
|
|
||||||
@ -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>;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user