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

View File

@ -77,7 +77,8 @@
"title": "Жаль, что вы уходите…",
"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": "Стандартный план Отменен!",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,29 @@
import PalmistryResultPage from "@/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage";
import { 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} />
</>
);
}

View File

@ -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>

View File

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

View File

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

View File

@ -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>

View File

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

View File

@ -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 {

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

@ -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>

View File

@ -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(

View File

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

View File

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

View File

@ -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"}

View File

@ -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 (

View File

@ -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 (

View File

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

View File

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

View File

@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ export const AssistantSchema = z.object({
photoUrl: z.string().url(),
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>;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,13 @@
import { http } from "@/shared/api/httpClient";
import { 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,
}
);
};

View File

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

View File

@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]),

View File

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

View File

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

View File

@ -47,12 +47,12 @@ export const ActionFieldSchema = z.object({
actionId: z.string(),
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>;