main
done retaining funnel, edits compatibility & palms & home
This commit is contained in:
parent
67f4dfdf3d
commit
12836b372d
@ -77,7 +77,8 @@
|
||||
"title": "Жаль, что вы уходите…",
|
||||
"description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.<br></br><br></br>Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.",
|
||||
"stay_button": "Остаться и уменьшить мой план на 50%",
|
||||
"cancel_button": "Отменить"
|
||||
"cancel_button": "Отменить",
|
||||
"error_message": "Something went wrong. Please try again later."
|
||||
},
|
||||
"Stay50Done": {
|
||||
"title": "Мы ценим твой выбор!",
|
||||
@ -137,7 +138,8 @@
|
||||
}
|
||||
},
|
||||
"get_offer": "Получить бесплатный план",
|
||||
"cancel": "Отменить"
|
||||
"cancel": "Отменить",
|
||||
"error_message": "Something went wrong. Please try again later."
|
||||
},
|
||||
"ChangeMind": {
|
||||
"title": "Что может изменить твое мнение?",
|
||||
@ -151,7 +153,8 @@
|
||||
"StopFor30Days": {
|
||||
"title": "Остановите подписку на тридцать дней. Никаких списаний.",
|
||||
"stop": "Остановить",
|
||||
"cancel": "Отменить"
|
||||
"cancel": "Отменить",
|
||||
"error_message": "Something went wrong. Please try again later."
|
||||
},
|
||||
"CancellationOfSubscription": {
|
||||
"title": "Подписка аннулируется!",
|
||||
@ -162,7 +165,8 @@
|
||||
"new-price": "0"
|
||||
},
|
||||
"offer_button": "Применить",
|
||||
"cancel_button": "Я подтверждаю свои действия"
|
||||
"cancel_button": "Я подтверждаю свои действия",
|
||||
"error_message": "Something went wrong. Please try again later."
|
||||
},
|
||||
"PlanCancelled": {
|
||||
"title": "Стандартный план Отменен!",
|
||||
|
||||
@ -6,6 +6,7 @@ import CompatibilityActionFieldsForm, {
|
||||
} from "@/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm";
|
||||
import { Typography } from "@/components/ui";
|
||||
import { loadCompatibilityActionFields } from "@/entities/compatibilityActionFields/loaders";
|
||||
import { loadCompatibility } from "@/entities/dashboard/loaders";
|
||||
|
||||
import styles from "./page.module.scss";
|
||||
|
||||
@ -15,6 +16,8 @@ export default function Compatibility({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const actions = use(loadCompatibility());
|
||||
const action = actions?.find(action => action._id === id);
|
||||
const t = useTranslations("Compatibility");
|
||||
|
||||
return (
|
||||
@ -26,7 +29,7 @@ export default function Compatibility({
|
||||
weight="semiBold"
|
||||
className={styles.title}
|
||||
>
|
||||
{t("title")}
|
||||
{action?.title}
|
||||
</Typography>
|
||||
<Typography as="p" size="sm" className={styles.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 { Typography } from "@/components/ui";
|
||||
import { loadPalms } from "@/entities/dashboard/loaders";
|
||||
import { startGeneration } from "@/entities/generations/api";
|
||||
|
||||
import styles from "./page.module.scss";
|
||||
|
||||
export default async function PalmistryResult({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const actions = await loadPalms();
|
||||
const action = actions?.find(action => action._id === id) ?? null;
|
||||
const result = await startGeneration({
|
||||
actionType: "palm",
|
||||
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";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button, Typography } from "@/components/ui";
|
||||
import { ROUTES } from "@/shared/constants/client-routes";
|
||||
|
||||
import styles from "./page.module.scss";
|
||||
|
||||
export default function Error() {
|
||||
export default function Error({ reset }: { reset: () => void }) {
|
||||
const t = useTranslations("Subscriptions");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@ -20,12 +17,7 @@ export default function Error() {
|
||||
<Typography as="p" align="center">
|
||||
{t("error")}
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={
|
||||
// () => reset()
|
||||
() => router.push(ROUTES.retainingFunnelCancelSubscription())
|
||||
}
|
||||
>
|
||||
<Button onClick={() => reset()}>
|
||||
<Typography color="white">{t("try_again")}</Typography>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -1,36 +1,4 @@
|
||||
// import { useSchemeColorByElement } from "@/hooks/useSchemeColorByElement";
|
||||
// import { useRef } from "react";
|
||||
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 }) {
|
||||
// const darkTheme = useSelector(selectors.selectDarkTheme);
|
||||
@ -38,12 +6,10 @@ function StepperLayout({ children }: { children: React.ReactNode }) {
|
||||
// useSchemeColorByElement(mainRef.current, "section.page, .page, section", [
|
||||
// location,
|
||||
// ]);
|
||||
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel);
|
||||
const retainingFunnel = ERetainingFunnel.Red;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RetainingStepper stepperRoutes={stepperRoutes[retainingFunnel]} />
|
||||
<RetainingStepper />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
// overflow-x: clip;
|
||||
// padding-inline: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@ -9,7 +9,8 @@ import { getMessages } from "next-intl/server";
|
||||
import clsx from "clsx";
|
||||
|
||||
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";
|
||||
|
||||
@ -47,7 +48,9 @@ export default async function RootLayout({
|
||||
<html lang={locale}>
|
||||
<body className={clsx(inter.variable, styles.body)}>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<StoreProvider>{children}</StoreProvider>
|
||||
<RetainingStoreProvider>
|
||||
<ToastProvider maxVisible={3}>{children}</ToastProvider>
|
||||
</RetainingStoreProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,14 +4,13 @@ import { use, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Skeleton, Toast, Typography } from "@/components/ui";
|
||||
import { Skeleton, Typography } from "@/components/ui";
|
||||
import { ActionFieldsForm } from "@/components/widgets";
|
||||
import { startGeneration } from "@/entities/generations/actions";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { ROUTES } from "@/shared/constants/client-routes";
|
||||
import { ActionField } from "@/types";
|
||||
|
||||
import styles from "./CompatibilityActionFieldsForm.module.scss";
|
||||
|
||||
interface CompatibilityActionFieldsFormProps {
|
||||
fields: Promise<ActionField[]>;
|
||||
actionId: string;
|
||||
@ -24,15 +23,14 @@ export default function CompatibilityActionFieldsForm({
|
||||
const t = useTranslations("Compatibility");
|
||||
const compatibilityActionFields = use(fields);
|
||||
const router = useRouter();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (
|
||||
values: Record<string, string | number | null>
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setFormError(null);
|
||||
|
||||
const response = await startGeneration({
|
||||
actionType: "compatibility",
|
||||
@ -43,16 +41,19 @@ export default function CompatibilityActionFieldsForm({
|
||||
setIsLoading(false);
|
||||
|
||||
if (response?.data?.id) {
|
||||
router.push(ROUTES.compatibilityResult(response.data.id));
|
||||
router.push(ROUTES.compatibilityResult(actionId, response.data.id));
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
setFormError(response.error);
|
||||
addToast({
|
||||
variant: "error",
|
||||
message: t("error"),
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка случая, когда поля не загрузились
|
||||
if (!compatibilityActionFields || compatibilityActionFields.length === 0) {
|
||||
return (
|
||||
<Typography as="p" size="sm" color="danger">
|
||||
@ -62,22 +63,12 @@ export default function CompatibilityActionFieldsForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionFieldsForm
|
||||
fields={compatibilityActionFields}
|
||||
onSubmit={handleSubmit}
|
||||
buttonText={t("button")}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{formError && (
|
||||
<Toast variant="error" classNameContainer={styles.errorToast}>
|
||||
<Typography as="p" size="sm" color="black">
|
||||
{t("error")}
|
||||
</Typography>
|
||||
</Toast>
|
||||
)}
|
||||
</>
|
||||
<ActionFieldsForm
|
||||
fields={compatibilityActionFields}
|
||||
onSubmit={handleSubmit}
|
||||
buttonText={t("button")}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,11 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: calc(100dvh - 56px);
|
||||
}
|
||||
|
||||
.title {
|
||||
line-height: 30px;
|
||||
height: calc(100dvh - 102px);
|
||||
}
|
||||
|
||||
.description {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
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 { useToast } from "@/providers/toast-provider";
|
||||
|
||||
import styles from "./CompatibilityResultPage.module.scss";
|
||||
|
||||
@ -16,6 +18,18 @@ export default function CompatibilityResultPage({
|
||||
}: CompatibilityResultPageProps) {
|
||||
const t = useTranslations("CompatibilityResult");
|
||||
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) {
|
||||
return (
|
||||
@ -25,15 +39,8 @@ export default function CompatibilityResultPage({
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Toast variant="error">{t("error")}</Toast>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
|
||||
{t("title")}
|
||||
</Typography>
|
||||
<Typography as="p" size="lg" align="left" className={styles.description}>
|
||||
{data?.result}
|
||||
</Typography>
|
||||
|
||||
@ -59,5 +59,4 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
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 { IconName } from "@/components/ui/Icon/Icon";
|
||||
import { CompatibilityAction } from "@/entities/dashboard/types";
|
||||
import { Action } from "@/entities/dashboard/types";
|
||||
|
||||
import styles from "./CompatibilityCard.module.scss";
|
||||
|
||||
type CompatibilityCardProps = CompatibilityAction;
|
||||
type CompatibilityCardProps = Action;
|
||||
|
||||
export default function CompatibilityCard({
|
||||
imageUrl,
|
||||
|
||||
@ -2,11 +2,11 @@ import Image from "next/image";
|
||||
|
||||
import { Button, Card, Icon, MetaLabel, Typography } from "@/components/ui";
|
||||
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";
|
||||
|
||||
type MeditationCardProps = Meditation;
|
||||
type MeditationCardProps = Action;
|
||||
|
||||
export default function MeditationCard({
|
||||
imageUrl,
|
||||
|
||||
@ -2,11 +2,11 @@ import Image from "next/image";
|
||||
|
||||
import { Card, MetaLabel, Typography } from "@/components/ui";
|
||||
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";
|
||||
|
||||
type PalmCardProps = PalmAction;
|
||||
type PalmCardProps = Action;
|
||||
|
||||
export default function PalmCard({
|
||||
imageUrl,
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
.sectionContent.sectionContent {
|
||||
overflow-x: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
width: calc(100% + 32px);
|
||||
padding: 32px 16px;
|
||||
padding-right: 0;
|
||||
margin: -32px -16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.skeleton.skeleton {
|
||||
height: 486px;
|
||||
}
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
.sectionContent.sectionContent {
|
||||
overflow-x: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
width: calc(100% + 32px);
|
||||
padding: 16px;
|
||||
padding-right: 0;
|
||||
margin: -16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.skeleton.skeleton {
|
||||
height: 236px;
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
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 { CompatibilityCard } from "../../cards";
|
||||
@ -12,7 +14,7 @@ import styles from "./CompatibilitySection.module.scss";
|
||||
export default function CompatibilitySection({
|
||||
promise,
|
||||
}: {
|
||||
promise: Promise<CompatibilityAction[]>;
|
||||
promise: Promise<Action[]>;
|
||||
}) {
|
||||
const compatibilities = use(promise);
|
||||
const columns = Math.ceil(compatibilities?.length / 2);
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
.sectionContent.sectionContent {
|
||||
overflow-x: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
width: calc(100% + 32px);
|
||||
padding: 16px;
|
||||
padding-right: 0;
|
||||
margin: -16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.skeleton.skeleton {
|
||||
height: 308px;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { use } from "react";
|
||||
|
||||
import { Grid, Section, Skeleton } from "@/components/ui";
|
||||
import { Meditation } from "@/entities/dashboard/types";
|
||||
import { Action } from "@/entities/dashboard/types";
|
||||
|
||||
import { MeditationCard } from "../../cards";
|
||||
|
||||
@ -10,7 +10,7 @@ import styles from "./MeditationSection.module.scss";
|
||||
export default function MeditationSection({
|
||||
promise,
|
||||
}: {
|
||||
promise: Promise<Meditation[]>;
|
||||
promise: Promise<Action[]>;
|
||||
}) {
|
||||
const meditations = use(promise);
|
||||
const columns = meditations?.length;
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
.sectionContent.sectionContent {
|
||||
overflow-x: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
width: calc(100% + 32px);
|
||||
padding: 16px;
|
||||
padding-right: 0;
|
||||
margin: -16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.skeleton.skeleton {
|
||||
height: 227px;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { use } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
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 { PalmCard } from "../../cards";
|
||||
@ -12,7 +12,7 @@ import styles from "./PalmSection.module.scss";
|
||||
export default function PalmSection({
|
||||
promise,
|
||||
}: {
|
||||
promise: Promise<PalmAction[]>;
|
||||
promise: Promise<Action[]>;
|
||||
}) {
|
||||
const palms = use(promise);
|
||||
const columns = palms?.length;
|
||||
|
||||
@ -2,11 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: calc(100dvh - 56px);
|
||||
}
|
||||
|
||||
.title {
|
||||
line-height: 30px;
|
||||
height: calc(100dvh - 102px);
|
||||
}
|
||||
|
||||
.description {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
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 { useToast } from "@/providers/toast-provider";
|
||||
|
||||
import styles from "./PalmistryResultPage.module.scss";
|
||||
|
||||
@ -14,6 +16,18 @@ interface PalmistryResultPageProps {
|
||||
export default function PalmistryResultPage({ id }: PalmistryResultPageProps) {
|
||||
const t = useTranslations("PalmistryResult");
|
||||
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) {
|
||||
return (
|
||||
@ -23,15 +37,8 @@ export default function PalmistryResultPage({ id }: PalmistryResultPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Toast variant="error">{t("error")}</Toast>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
|
||||
{t("title")}
|
||||
</Typography>
|
||||
<Typography as="p" size="lg" align="left" className={styles.description}>
|
||||
{data?.result}
|
||||
</Typography>
|
||||
|
||||
@ -13,14 +13,15 @@ import { useTranslations } from "next-intl";
|
||||
import { Button, Typography } from "@/components/ui";
|
||||
import Modal from "@/components/ui/Modal/Modal";
|
||||
import { UserSubscription } from "@/entities/subscriptions/types";
|
||||
import { useRetainingStore } from "@/providers/retaining-store-provider";
|
||||
import { ROUTES } from "@/shared/constants/client-routes";
|
||||
import { useRetainingActions } from "@/stores/retainingStore";
|
||||
|
||||
import styles from "./CancelSubscriptionModalProvider.module.scss";
|
||||
|
||||
type Ctx = { open: (sub: UserSubscription) => void };
|
||||
|
||||
const Context = createContext<Ctx | null>(null);
|
||||
|
||||
export const useCancelSubscriptionModal = () => {
|
||||
const ctx = useContext(Context);
|
||||
if (!ctx)
|
||||
@ -36,7 +37,8 @@ export default function CancelSubscriptionModalProvider({
|
||||
const router = useRouter();
|
||||
const t = useTranslations("Subscriptions");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { setCancellingSubscription } = useRetainingActions();
|
||||
|
||||
const { setCancellingSubscription } = useRetainingStore(state => state);
|
||||
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const open = useCallback(
|
||||
|
||||
@ -69,11 +69,5 @@ export default function SubscriptionTable({ subscription }: ITableProps) {
|
||||
return data;
|
||||
}, [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} />;
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
// import { useSelector } from "react-redux";
|
||||
// import { selectors } from "@/store";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { Typography } from "@/components/ui";
|
||||
|
||||
@ -3,25 +3,55 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
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";
|
||||
|
||||
export default function RetainingStepper({
|
||||
stepperRoutes,
|
||||
}: {
|
||||
stepperRoutes: string[];
|
||||
}) {
|
||||
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()],
|
||||
};
|
||||
|
||||
export default function RetainingStepper() {
|
||||
const pathname = usePathname();
|
||||
const { funnel } = useRetainingStore(state => state);
|
||||
|
||||
const getCurrentStep = () => {
|
||||
// if ([
|
||||
// ROUTES.retainingFunnelPlanCancelled(),
|
||||
// ROUTES.retainingFunnelSubscriptionStopped(),
|
||||
// ].some(route => location.pathname.includes(route))) {
|
||||
// return stepperRoutes[retainingFunnel].length;
|
||||
// }
|
||||
if (
|
||||
[
|
||||
ROUTES.retainingFunnelPlanCancelled(),
|
||||
ROUTES.retainingFunnelSubscriptionStopped(),
|
||||
].some(route => pathname.includes(route))
|
||||
) {
|
||||
return stepperRoutes[funnel].length;
|
||||
}
|
||||
let index = 0;
|
||||
for (const route of stepperRoutes) {
|
||||
for (const route of stepperRoutes[funnel]) {
|
||||
if (pathname.includes(route)) {
|
||||
return index + 1;
|
||||
}
|
||||
@ -30,10 +60,9 @@ export default function RetainingStepper({
|
||||
return 0;
|
||||
};
|
||||
|
||||
// логика выбора шага по pathname
|
||||
return (
|
||||
<StepperBar
|
||||
length={stepperRoutes.length}
|
||||
length={stepperRoutes[funnel].length}
|
||||
currentStep={getCurrentStep()}
|
||||
// color={darkTheme ? "#B2BCFF" : "#353E75"}
|
||||
color={"#353E75"}
|
||||
|
||||
@ -5,9 +5,13 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Spinner, Typography } from "@/components/ui";
|
||||
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
|
||||
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 { ELottieKeys } from "@/shared/constants/lottie";
|
||||
import { ERetainingFunnel } from "@/stores/retaining-store";
|
||||
|
||||
import { RetainingButton } from "../..";
|
||||
|
||||
@ -19,9 +23,16 @@ export default function Buttons() {
|
||||
useLottie({
|
||||
preloadKey: ELottieKeys.loaderCheckMark,
|
||||
});
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [activeButton, setActiveButton] = useState<"stay" | "cancel">();
|
||||
const [isLoadingButton, setIsLoadingButton] = useState<"stay" | "cancel">();
|
||||
const { cancellingSubscription, setFunnel } = useRetainingStore(
|
||||
state => state
|
||||
);
|
||||
|
||||
const [activeButton, setActiveButton] = useState<"stay" | "cancel" | null>();
|
||||
const [isLoadingButton, setIsLoadingButton] = useState<
|
||||
"stay" | "cancel" | null
|
||||
>();
|
||||
|
||||
const handleCancelButtonClick = () => {
|
||||
if (isLoadingButton) return;
|
||||
@ -37,15 +48,22 @@ export default function Buttons() {
|
||||
setActiveButton("stay");
|
||||
setIsLoadingButton("stay");
|
||||
|
||||
// const response = await api.userSubscriptionAction({
|
||||
// subscriptionId: cancellingSubscriptionId,
|
||||
// action: "discount_50",
|
||||
// token
|
||||
// });
|
||||
// if (response.status === "success") {
|
||||
// dispatch(actions.retainingFunnel.setFunnel(ERetainingFunnel.Stay50));
|
||||
// }
|
||||
router.push(ROUTES.retainingFunnelStay50Done());
|
||||
const response = await performUserSubscriptionAction({
|
||||
subscriptionId: cancellingSubscription?.id || "",
|
||||
action: "discount_50",
|
||||
});
|
||||
|
||||
if (response?.data?.status === "success") {
|
||||
setFunnel(ERetainingFunnel.Stay50);
|
||||
return router.push(ROUTES.retainingFunnelStay50Done());
|
||||
}
|
||||
setIsLoadingButton(null);
|
||||
setActiveButton(null);
|
||||
addToast({
|
||||
variant: "error",
|
||||
message: t("error_message"),
|
||||
duration: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
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 styles from "./Buttons.module.scss";
|
||||
// import { useRouter } from "next/navigation";
|
||||
// import { ROUTES } from "@/shared/constants/client-routes";
|
||||
|
||||
export default function Buttons() {
|
||||
const t = useTranslations("CancellationOfSubscription");
|
||||
// const router = useRouter();
|
||||
const router = useRouter();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const { cancellingSubscription } = useRetainingStore(state => state);
|
||||
|
||||
const [isToastVisible, setIsToastVisible] = useState(false);
|
||||
const [isLoadingOfferButton, setIsLoadingOfferButton] =
|
||||
@ -24,32 +30,44 @@ export default function Buttons() {
|
||||
const handleOfferButtonClick = async () => {
|
||||
if (isLoadingOfferButton || isLoadingCancelButton) return;
|
||||
setIsLoadingOfferButton(true);
|
||||
// const response = await api.userSubscriptionAction({
|
||||
// subscriptionId: cancellingSubscriptionId,
|
||||
// action: "pause_60",
|
||||
// token
|
||||
// });
|
||||
// if (response.status === "success") {
|
||||
// navigate(routes.client.retainingFunnelSubscriptionStopped());
|
||||
// }
|
||||
const response = await performUserSubscriptionAction({
|
||||
subscriptionId: cancellingSubscription?.id || "",
|
||||
action: "pause_60",
|
||||
});
|
||||
|
||||
if (response?.data?.status === "success") {
|
||||
return router.push(ROUTES.retainingFunnelSubscriptionStopped());
|
||||
}
|
||||
setIsLoadingOfferButton(false);
|
||||
addToast({
|
||||
variant: "error",
|
||||
message: t("error_message"),
|
||||
duration: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelClick = async () => {
|
||||
if (isToastVisible || isLoadingOfferButton || isLoadingCancelButton) return;
|
||||
setIsLoadingCancelButton(true);
|
||||
setIsToastVisible(true);
|
||||
// const response = await api.userSubscriptionAction({
|
||||
// subscriptionId: cancellingSubscriptionId,
|
||||
// action: "cancel",
|
||||
// token
|
||||
// });
|
||||
// if (response.status === "success") {
|
||||
// setIsToastVisible(true);
|
||||
// const timer = setTimeout(() => {
|
||||
// router.push(ROUTES.profile());
|
||||
// }, 7000);
|
||||
// return () => clearTimeout(timer);
|
||||
// }
|
||||
|
||||
const response = await performUserSubscriptionAction({
|
||||
subscriptionId: cancellingSubscription?.id || "",
|
||||
action: "cancel",
|
||||
});
|
||||
|
||||
if (response?.data?.status === "success") {
|
||||
setIsToastVisible(true);
|
||||
const timer = setTimeout(() => {
|
||||
router.push(ROUTES.profile());
|
||||
}, 7000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsLoadingCancelButton(false);
|
||||
addToast({
|
||||
variant: "error",
|
||||
message: t("error_message"),
|
||||
duration: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useRetainingStore } from "@/providers/retaining-store-provider";
|
||||
import { ROUTES } from "@/shared/constants/client-routes";
|
||||
import { ERetainingFunnel } from "@/types";
|
||||
|
||||
@ -28,21 +29,20 @@ export default function Buttons({ answers }: ButtonsProps) {
|
||||
const [activeAnswer, setActiveAnswer] = useState<ChangeMindAnswer | null>(
|
||||
null
|
||||
);
|
||||
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel);
|
||||
const retainingFunnel = ERetainingFunnel.Red;
|
||||
const { funnel } = useRetainingStore(state => state);
|
||||
|
||||
const handleNext = (answer: ChangeMindAnswer) => {
|
||||
setActiveAnswer(answer);
|
||||
const timer = setTimeout(() => {
|
||||
if (retainingFunnel === ERetainingFunnel.Red) {
|
||||
if (funnel === ERetainingFunnel.Red) {
|
||||
router.push(ROUTES.retainingFunnelStopFor30Days());
|
||||
}
|
||||
// if (retainingFunnel === ERetainingFunnel.Green) {
|
||||
// router.push(ROUTES.retainingFunnelSecondChance());
|
||||
// }
|
||||
// if (retainingFunnel === ERetainingFunnel.Purple) {
|
||||
// router.push(ROUTES.retainingFunnelSecondChance());
|
||||
// }
|
||||
if (funnel === ERetainingFunnel.Green) {
|
||||
router.push(ROUTES.retainingFunnelSecondChance());
|
||||
}
|
||||
if (funnel === ERetainingFunnel.Purple) {
|
||||
router.push(ROUTES.retainingFunnelSecondChance());
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
pointer-events: none;
|
||||
margin-top: 211px;
|
||||
overflow-x: clip;
|
||||
z-index: 9999;
|
||||
z-index: 8888;
|
||||
|
||||
& > .blur {
|
||||
padding-top: 40px;
|
||||
|
||||
@ -9,7 +9,10 @@ import { Offer, RetainingButton } from "@/components/domains/retaining";
|
||||
import { EyeSvg } from "@/components/domains/retaining/images";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { BlurComponent } from "@/components/widgets";
|
||||
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
|
||||
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 { retainingImages } from "@/shared/constants/images/retaining";
|
||||
import { ELottieKeys } from "@/shared/constants/lottie";
|
||||
@ -26,16 +29,13 @@ export default function SecondChancePage() {
|
||||
useLottie({
|
||||
preloadKey: ELottieKeys.confetti,
|
||||
});
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [activeOffer, setActiveOffer] = useState<"pause_30" | "free_chat_30">(
|
||||
"pause_30"
|
||||
);
|
||||
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false);
|
||||
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel);
|
||||
// const token = useSelector(selectors.selectToken);
|
||||
// const cancellingSubscriptionId = useSelector(selectors.selectCancellingSubscriptionId);
|
||||
|
||||
const retainingFunnel = ERetainingFunnel.Red;
|
||||
const { funnel, cancellingSubscription } = useRetainingStore(state => state);
|
||||
|
||||
const handleOfferClick = (offer: "pause_30" | "free_chat_30") => {
|
||||
if (isLoadingButton) return;
|
||||
@ -46,26 +46,33 @@ export default function SecondChancePage() {
|
||||
if (isLoadingButton) return;
|
||||
setIsLoadingButton(true);
|
||||
|
||||
// const response = await api.userSubscriptionAction({
|
||||
// subscriptionId: cancellingSubscriptionId,
|
||||
// action: activeOffer,
|
||||
// token
|
||||
// });
|
||||
// if (response.status === "success") {
|
||||
// navigate(routes.client.retainingFunnelPlanCancelled());
|
||||
// }
|
||||
const response = await performUserSubscriptionAction({
|
||||
subscriptionId: cancellingSubscription?.id || "",
|
||||
action: activeOffer,
|
||||
});
|
||||
|
||||
if (response?.data?.status === "success") {
|
||||
return router.push(ROUTES.retainingFunnelPlanCancelled());
|
||||
}
|
||||
setIsLoadingButton(false);
|
||||
addToast({
|
||||
variant: "error",
|
||||
message: t("error_message"),
|
||||
duration: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelClick = () => {
|
||||
if (isLoadingButton) return;
|
||||
if (retainingFunnel === ERetainingFunnel.Red) {
|
||||
if (funnel === ERetainingFunnel.Red) {
|
||||
router.push(ROUTES.retainingFunnelChangeMind());
|
||||
}
|
||||
// if (retainingFunnel === ERetainingFunnel.Green) {
|
||||
// return navigate(routes.client.retainingFunnelCancellationOfSubscription());
|
||||
// }
|
||||
// if (retainingFunnel === ERetainingFunnel.Purple) {
|
||||
// return navigate(routes.client.retainingFunnelStopFor30Days());
|
||||
if (funnel === ERetainingFunnel.Green) {
|
||||
return router.push(ROUTES.retainingFunnelCancellationOfSubscription());
|
||||
}
|
||||
if (funnel === ERetainingFunnel.Purple) {
|
||||
return router.push(ROUTES.retainingFunnelStopFor30Days());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -88,7 +95,6 @@ export default function SecondChancePage() {
|
||||
onClick={() => handleOfferClick("free_chat_30")}
|
||||
active={activeOffer === "free_chat_30"}
|
||||
image={
|
||||
// <img className={styles.offerImageVIP} src={images("vip_member.png")} alt="vip member" />
|
||||
<Image
|
||||
src={retainingImages("vip_member.png")}
|
||||
alt="vip member"
|
||||
|
||||
@ -6,46 +6,56 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
import { RetainingButton } from "@/components/domains/retaining";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
|
||||
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 { ELottieKeys } from "@/shared/constants/lottie";
|
||||
import { ERetainingFunnel } from "@/types";
|
||||
|
||||
import styles from "./Buttons.module.scss";
|
||||
|
||||
export default function Buttons() {
|
||||
const t = useTranslations("StopFor30Days");
|
||||
const router = useRouter();
|
||||
// const api = useApi();
|
||||
// const token = useSelector(selectors.selectToken);
|
||||
// const cancellingSubscriptionId = useSelector(selectors.selectCancellingSubscriptionId);
|
||||
useLottie({
|
||||
preloadKey: ELottieKeys.loaderCheckMark2,
|
||||
});
|
||||
useLottie({
|
||||
preloadKey: ELottieKeys.confetti,
|
||||
});
|
||||
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel);
|
||||
// const retainingFunnel = ERetainingFunnel.Red;
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false);
|
||||
|
||||
const { funnel, cancellingSubscription } = useRetainingStore(state => state);
|
||||
|
||||
const handleStopClick = async () => {
|
||||
if (isLoadingButton) return;
|
||||
setIsLoadingButton(true);
|
||||
// const response = await api.userSubscriptionAction({
|
||||
// subscriptionId: cancellingSubscriptionId,
|
||||
// action: "pause_30",
|
||||
// token
|
||||
// });
|
||||
// if (response.status === "success") {
|
||||
// navigate(routes.client.retainingFunnelSubscriptionStopped());
|
||||
// }
|
||||
|
||||
const response = await performUserSubscriptionAction({
|
||||
subscriptionId: cancellingSubscription?.id || "",
|
||||
action: "pause_30",
|
||||
});
|
||||
|
||||
if (response?.data?.status === "success") {
|
||||
return router.push(ROUTES.retainingFunnelSubscriptionStopped());
|
||||
}
|
||||
setIsLoadingButton(false);
|
||||
addToast({
|
||||
variant: "error",
|
||||
message: t("error_message"),
|
||||
duration: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelClick = () => {
|
||||
if (isLoadingButton) return;
|
||||
// if (retainingFunnel === ERetainingFunnel.Green) {
|
||||
// return navigate(routes.client.retainingFunnelChangeMind());
|
||||
// }
|
||||
if (funnel === ERetainingFunnel.Green) {
|
||||
return router.push(ROUTES.retainingFunnelChangeMind());
|
||||
}
|
||||
router.push(ROUTES.retainingFunnelCancellationOfSubscription());
|
||||
};
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useRetainingStore } from "@/providers/retaining-store-provider";
|
||||
import { ROUTES } from "@/shared/constants/client-routes";
|
||||
import { ERetainingFunnel } from "@/types";
|
||||
|
||||
@ -23,13 +24,15 @@ interface ButtonsProps {
|
||||
export default function Buttons({ answers }: ButtonsProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { setFunnel } = useRetainingStore(state => state);
|
||||
|
||||
const [activeAnswer, setActiveAnswer] = useState<WhatReasonAnswer | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleNext = (answer: WhatReasonAnswer) => {
|
||||
setActiveAnswer(answer);
|
||||
// dispatch(actions.retainingFunnel.setFunnel(answer.funnel));
|
||||
setFunnel(answer.funnel);
|
||||
const timer = setTimeout(() => {
|
||||
if (answer.funnel === ERetainingFunnel.Red) {
|
||||
router.push(ROUTES.retainingFunnelSecondChance());
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
font-size: 16px;
|
||||
background-color: #f1f1f1;
|
||||
outline: none;
|
||||
appearance: none; // Removes default arrow
|
||||
appearance: none;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
}
|
||||
|
||||
.inputError {
|
||||
border-color: #ef4444 !important; // red-500
|
||||
border-color: #ef4444 !important;
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
@ -1,55 +1,122 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toast {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
gap: 6px;
|
||||
grid-template-columns: 24px 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
animation: appearance 0.8s
|
||||
linear(
|
||||
0 0%,
|
||||
0 1.8%,
|
||||
0.01 3.6%,
|
||||
0.08 10.03%,
|
||||
0.15 14.25%,
|
||||
0.2 14.34%,
|
||||
0.31 14.14%,
|
||||
0.41 17.21%,
|
||||
0.49 19.04%,
|
||||
0.58 20.56%,
|
||||
0.66 22.07%,
|
||||
0.76 23.87%,
|
||||
0.84 26.07%,
|
||||
0.93 28.04%,
|
||||
1.03 31.14%,
|
||||
1.09 37.31%,
|
||||
1.09 44.28%,
|
||||
1.02 49.41%,
|
||||
0.96 55%,
|
||||
0.98 64%,
|
||||
0.99 74.4%,
|
||||
1 86.4%,
|
||||
1 100%
|
||||
);
|
||||
animation-fill-mode: forwards;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
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 {
|
||||
background-color: #ffdcdc;
|
||||
background-color: rgba(255, 220, 220, 0.95);
|
||||
border-color: rgba(225, 43, 43, 0.2);
|
||||
color: #8b0000;
|
||||
}
|
||||
|
||||
.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 {
|
||||
0% {
|
||||
transform: translateY(100%);
|
||||
transform: translateY(100%) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
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";
|
||||
|
||||
export type ToastVariant = "error" | "success" | "warning" | "info";
|
||||
|
||||
interface IToastProps {
|
||||
variant: "error" | "success";
|
||||
variant: ToastVariant;
|
||||
children: React.ReactNode;
|
||||
classNameContainer?: 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({
|
||||
variant,
|
||||
children,
|
||||
classNameContainer = "",
|
||||
classNameToast = "",
|
||||
onClose,
|
||||
}: IToastProps) {
|
||||
return (
|
||||
<div className={`${styles.container} ${classNameContainer}`}>
|
||||
<div className={`${styles.toast} ${styles[variant]} ${classNameToast}`}>
|
||||
{variant === "error" && <ErrorIcon />}
|
||||
{variant === "success" && <div />}
|
||||
{children}
|
||||
{getIcon(variant)}
|
||||
<div className={styles.content}>{children}</div>
|
||||
{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>
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px; // Увеличим отступ между полями
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.fieldWrapper {
|
||||
@ -18,9 +18,9 @@
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
margin-top: 8px; // Небольшой отступ перед кнопкой
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,14 +15,9 @@ const validate = (fields: ActionField[], values: FormValues): FormErrors => {
|
||||
|
||||
for (const field of fields) {
|
||||
const value = values[field.key];
|
||||
// Для примера добавим правило, что все поля обязательны
|
||||
if (value === null || value === "" || value === undefined) {
|
||||
errors[field.key] = "Это поле обязательно для заполнения";
|
||||
}
|
||||
// Можно добавлять более сложные правила
|
||||
// if (field.key === 'your_name' && value && value.length < 2) {
|
||||
// errors[field.key] = 'Имя должно содержать минимум 2 символа';
|
||||
// }
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title,
|
||||
.text {
|
||||
.title.title,
|
||||
.text.text {
|
||||
color: #2a74dd;
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
.content.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-inline: 8px;
|
||||
@ -27,10 +27,10 @@
|
||||
transition: height 2s ease;
|
||||
}
|
||||
|
||||
.seeAllButton {
|
||||
padding: 0 !important;
|
||||
background: none !important;
|
||||
border-radius: 0 !important;
|
||||
width: fit-content !important;
|
||||
.seeAllButton.seeAllButton {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@ -13,14 +13,13 @@
|
||||
|
||||
.inputsWrapper {
|
||||
display: grid;
|
||||
// Делаем селектор AM/PM немного уже
|
||||
grid-template-columns: 1fr 1fr 0.8fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 12px;
|
||||
color: #ef4444; // red-500
|
||||
color: #ef4444;
|
||||
margin: 0;
|
||||
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 Table } from "./Table/Table";
|
||||
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(),
|
||||
externalId: z.string(),
|
||||
clientSource: z.string(),
|
||||
createdAt: z.string(), // ISO-строка даты
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type Assistant = z.infer<typeof AssistantSchema>;
|
||||
@ -26,58 +26,33 @@ export const FieldSchema = z.object({
|
||||
actionId: z.string(),
|
||||
key: z.string(),
|
||||
title: z.string(),
|
||||
inputType: z.string(), // text | date | time …
|
||||
model: z.string().optional(), // присутствует не всегда
|
||||
inputType: z.string(),
|
||||
model: z.string().optional(),
|
||||
property: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type Field = z.infer<typeof FieldSchema>;
|
||||
|
||||
/* ---------- CompatibilityAction ---------- */
|
||||
export const CompatibilityActionSchema = z.object({
|
||||
/* ---------- Action ---------- */
|
||||
export const ActionSchema = z.object({
|
||||
_id: z.string(),
|
||||
title: z.string(),
|
||||
minutes: z.number(),
|
||||
type: z.string(),
|
||||
imageUrl: z.string().url(),
|
||||
prompt: z.string(),
|
||||
fields: z.array(FieldSchema),
|
||||
prompt: z.string().optional(),
|
||||
fields: z.array(FieldSchema).optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type CompatibilityAction = z.infer<typeof CompatibilityActionSchema>;
|
||||
|
||||
/* ---------- 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>;
|
||||
export type Action = z.infer<typeof ActionSchema>;
|
||||
|
||||
/* ---------- Итоговый ответ /dashboard ---------- */
|
||||
export const DashboardSchema = z.object({
|
||||
assistants: z.array(AssistantSchema),
|
||||
compatibilityActions: z.array(CompatibilityActionSchema),
|
||||
palmActions: z.array(PalmActionSchema),
|
||||
meditations: z.array(MeditationSchema),
|
||||
compatibilityActions: z.array(ActionSchema),
|
||||
palmActions: z.array(ActionSchema),
|
||||
meditations: z.array(ActionSchema),
|
||||
});
|
||||
export type DashboardData = z.infer<typeof DashboardSchema>;
|
||||
|
||||
@ -41,7 +41,7 @@ export async function fetchGenerationStatus(
|
||||
API_ROUTES.statusGeneration(id),
|
||||
{
|
||||
schema: GenerationResponseSchema,
|
||||
revalidate: 0, // Всегда запрашиваем свежие данные
|
||||
revalidate: 0,
|
||||
}
|
||||
);
|
||||
return { data: response, error: null };
|
||||
|
||||
@ -5,9 +5,7 @@ import { z } from "zod";
|
||||
export const StartGenerationRequestSchema = z.object({
|
||||
actionType: z.enum(["compatibility", "palm"]),
|
||||
actionId: z.string(),
|
||||
variables: z
|
||||
.record(z.string().or(z.number()).or(z.null())) // Record<string, string>
|
||||
.optional(),
|
||||
variables: z.record(z.string().or(z.number()).or(z.null())).optional(),
|
||||
});
|
||||
|
||||
export type StartGenerationRequest = z.infer<
|
||||
@ -18,9 +16,9 @@ export type StartGenerationRequest = z.infer<
|
||||
|
||||
export const GenerationResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.string(), // e.g., "queued", "processing", "completed", "failed"
|
||||
status: 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>;
|
||||
|
||||
@ -8,7 +8,7 @@ export const CheckoutRequestSchema = z.object({
|
||||
export type CheckoutRequest = z.infer<typeof CheckoutRequestSchema>;
|
||||
|
||||
export const CheckoutResponseSchema = z.object({
|
||||
status: z.string(), // "paid" | "pending" | …
|
||||
status: z.string(),
|
||||
invoiceId: z.string(),
|
||||
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 { API_ROUTES } from "@/shared/constants/api-routes";
|
||||
|
||||
import { SubscriptionsData, SubscriptionsSchema } from "./types";
|
||||
import {
|
||||
SubscriptionsData,
|
||||
SubscriptionsSchema,
|
||||
UserSubscriptionActionPayload,
|
||||
UserSubscriptionActionResponse,
|
||||
UserSubscriptionActionResponseSchema,
|
||||
} from "./types";
|
||||
|
||||
export const getSubscriptions = async () => {
|
||||
return http.get<SubscriptionsData>(API_ROUTES.subscriptions(), {
|
||||
@ -10,3 +16,19 @@ export const getSubscriptions = async () => {
|
||||
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 const SubscriptionsSchema = z.object({
|
||||
status: z.string(), // "success" | string
|
||||
status: z.string(),
|
||||
data: z.array(UserSubscriptionSchema),
|
||||
});
|
||||
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();
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
birthplace: z.object({
|
||||
address: z.string(),
|
||||
}).optional(),
|
||||
birthplace: z
|
||||
.object({
|
||||
address: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
name: z.string(),
|
||||
birthdate: z.string(),
|
||||
gender: z.string(),
|
||||
|
||||
@ -27,7 +27,7 @@ export function useGenerationPolling(id: string, interval = 3000) {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
setIsLoading(false);
|
||||
return; // Останавливаем опрос при ошибке
|
||||
return;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
export async function getServerAccessToken() {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { UserSubscriptionActionEnum } from "@/entities/subscriptions/types";
|
||||
|
||||
const ROOT_ROUTE = "/";
|
||||
const ROOT_ROUTE_V2 = "/v2/";
|
||||
const ROOT_ROUTE_V3 = "/v3/";
|
||||
@ -18,4 +20,12 @@ export const API_ROUTES = {
|
||||
createRoute(["dashboard", "compatibility-actions", id, "fields"]),
|
||||
startGeneration: () => createRoute(["generations", "start"]),
|
||||
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: (id: string) => createRoute(["compatibility", id]),
|
||||
compatibilityResult: (id: string) =>
|
||||
createRoute(["compatibility", "result", id]),
|
||||
compatibilityResult: (id: string, resultId: string) =>
|
||||
createRoute(["compatibility", id, "result", resultId]),
|
||||
|
||||
// Palmistry
|
||||
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(),
|
||||
key: z.string(),
|
||||
title: z.string(),
|
||||
inputType: z.string(), // text | date | time …
|
||||
model: z.string().optional(), // присутствует не всегда
|
||||
inputType: z.string(),
|
||||
model: z.string().optional(),
|
||||
property: z.string().optional(),
|
||||
createdAt: z.string(), // ISO-строка даты
|
||||
createdAt: 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>;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user