diff --git a/messages/en.json b/messages/en.json index e8b7d79..20403d9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -77,7 +77,8 @@ "title": "Жаль, что вы уходите…", "description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.



Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.", "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": "Стандартный план Отменен!", diff --git a/src/app/[locale]/(core)/compatibility/[id]/page.tsx b/src/app/[locale]/(core)/compatibility/[id]/page.tsx index d1f859b..ca9727a 100644 --- a/src/app/[locale]/(core)/compatibility/[id]/page.tsx +++ b/src/app/[locale]/(core)/compatibility/[id]/page.tsx @@ -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} {t("description")} diff --git a/src/app/[locale]/(core)/compatibility/[id]/result/[resultId]/page.module.scss b/src/app/[locale]/(core)/compatibility/[id]/result/[resultId]/page.module.scss new file mode 100644 index 0000000..bcba085 --- /dev/null +++ b/src/app/[locale]/(core)/compatibility/[id]/result/[resultId]/page.module.scss @@ -0,0 +1,3 @@ +.title { + line-height: 30px; +} diff --git a/src/app/[locale]/(core)/compatibility/[id]/result/[resultId]/page.tsx b/src/app/[locale]/(core)/compatibility/[id]/result/[resultId]/page.tsx new file mode 100644 index 0000000..3391227 --- /dev/null +++ b/src/app/[locale]/(core)/compatibility/[id]/result/[resultId]/page.tsx @@ -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 ( + <> + + {action?.title} + + + + ); +} diff --git a/src/app/[locale]/(core)/compatibility/result/[id]/page.tsx b/src/app/[locale]/(core)/compatibility/result/[id]/page.tsx deleted file mode 100644 index 4a5582a..0000000 --- a/src/app/[locale]/(core)/compatibility/result/[id]/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/[locale]/(core)/palmistry/result/[id]/page.module.scss b/src/app/[locale]/(core)/palmistry/result/[id]/page.module.scss new file mode 100644 index 0000000..bcba085 --- /dev/null +++ b/src/app/[locale]/(core)/palmistry/result/[id]/page.module.scss @@ -0,0 +1,3 @@ +.title { + line-height: 30px; +} diff --git a/src/app/[locale]/(core)/palmistry/result/[id]/page.tsx b/src/app/[locale]/(core)/palmistry/result/[id]/page.tsx index 0ba6d44..21455fc 100644 --- a/src/app/[locale]/(core)/palmistry/result/[id]/page.tsx +++ b/src/app/[locale]/(core)/palmistry/result/[id]/page.tsx @@ -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 ; + return ( + <> + + {action?.title} + + + + ); } diff --git a/src/app/[locale]/(core)/profile/subscriptions/error.tsx b/src/app/[locale]/(core)/profile/subscriptions/error.tsx index f74f58e..18b8782 100644 --- a/src/app/[locale]/(core)/profile/subscriptions/error.tsx +++ b/src/app/[locale]/(core)/profile/subscriptions/error.tsx @@ -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 (
@@ -20,12 +17,7 @@ export default function Error() { {t("error")} -
diff --git a/src/app/[locale]/(core)/retaining/layout.tsx b/src/app/[locale]/(core)/retaining/layout.tsx index 8dc0c9b..f6b0846 100644 --- a/src/app/[locale]/(core)/retaining/layout.tsx +++ b/src/app/[locale]/(core)/retaining/layout.tsx @@ -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.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 ( <> - + {children} ); diff --git a/src/app/[locale]/(core)/retaining/second-chance/page.module.scss b/src/app/[locale]/(core)/retaining/second-chance/page.module.scss index 4bdc80d..a4c47d0 100644 --- a/src/app/[locale]/(core)/retaining/second-chance/page.module.scss +++ b/src/app/[locale]/(core)/retaining/second-chance/page.module.scss @@ -3,8 +3,6 @@ display: flex; flex-direction: column; align-items: center; - // overflow-x: clip; - // padding-inline: 2px; } .title { diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index ef925c5..a6fe15e 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -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({ - {children} + + {children} + diff --git a/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.tsx b/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.tsx index 5fa4bd3..4f2c20b 100644 --- a/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.tsx +++ b/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.tsx @@ -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; 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(null); const handleSubmit = async ( values: Record ) => { 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 ( @@ -62,22 +63,12 @@ export default function CompatibilityActionFieldsForm({ } return ( - <> - - - {formError && ( - - - {t("error")} - - - )} - + ); } diff --git a/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.module.scss b/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.module.scss index b95f85d..189c05c 100644 --- a/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.module.scss +++ b/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.module.scss @@ -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 { diff --git a/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.tsx b/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.tsx index 4f881bd..21e5532 100644 --- a/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.tsx +++ b/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.tsx @@ -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 {t("error")}; - } - return ( <> - - {t("title")} - {data?.result} diff --git a/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.module.scss b/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.module.scss index 4fc3c05..95ec55a 100644 --- a/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.module.scss +++ b/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.module.scss @@ -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); } diff --git a/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.tsx b/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.tsx index a8103d9..7930c67 100644 --- a/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.tsx +++ b/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.tsx @@ -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, diff --git a/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.tsx b/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.tsx index c02a48e..203589d 100644 --- a/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.tsx +++ b/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.tsx @@ -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, diff --git a/src/components/domains/dashboard/cards/PalmCard/PalmCard.tsx b/src/components/domains/dashboard/cards/PalmCard/PalmCard.tsx index af8cdd9..f216ccf 100644 --- a/src/components/domains/dashboard/cards/PalmCard/PalmCard.tsx +++ b/src/components/domains/dashboard/cards/PalmCard/PalmCard.tsx @@ -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, diff --git a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.module.scss b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.module.scss index cceb1e1..6858c69 100644 --- a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.module.scss +++ b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.module.scss @@ -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; } diff --git a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.module.scss b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.module.scss index 70d3dae..2dd837e 100644 --- a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.module.scss +++ b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.module.scss @@ -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; } diff --git a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx index 97fb780..cf98a10 100644 --- a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx +++ b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx @@ -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; + promise: Promise; }) { const compatibilities = use(promise); const columns = Math.ceil(compatibilities?.length / 2); diff --git a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.module.scss b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.module.scss index 8192ebd..2bd07b5 100644 --- a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.module.scss +++ b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.module.scss @@ -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; } diff --git a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx index a2e70b5..bc5af5e 100644 --- a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx +++ b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx @@ -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; + promise: Promise; }) { const meditations = use(promise); const columns = meditations?.length; diff --git a/src/components/domains/dashboard/sections/PalmSection/PalmSection.module.scss b/src/components/domains/dashboard/sections/PalmSection/PalmSection.module.scss index 63276e6..2f6da40 100644 --- a/src/components/domains/dashboard/sections/PalmSection/PalmSection.module.scss +++ b/src/components/domains/dashboard/sections/PalmSection/PalmSection.module.scss @@ -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; } diff --git a/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx b/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx index 6f76b95..bffcc40 100644 --- a/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx +++ b/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx @@ -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; + promise: Promise; }) { const palms = use(promise); const columns = palms?.length; diff --git a/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.module.scss b/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.module.scss index b95f85d..189c05c 100644 --- a/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.module.scss +++ b/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.module.scss @@ -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 { diff --git a/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.tsx b/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.tsx index 36d67b1..808e3eb 100644 --- a/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.tsx +++ b/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.tsx @@ -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 {t("error")}; - } - return ( <> - - {t("title")} - {data?.result} diff --git a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx index c7f997f..b61ed3e 100644 --- a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx +++ b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx @@ -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(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( diff --git a/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx b/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx index 6107118..0af39ab 100644 --- a/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx +++ b/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx @@ -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 ; } diff --git a/src/components/domains/retaining/Offer/Offer.tsx b/src/components/domains/retaining/Offer/Offer.tsx index 762ffa8..6ff0965 100644 --- a/src/components/domains/retaining/Offer/Offer.tsx +++ b/src/components/domains/retaining/Offer/Offer.tsx @@ -1,5 +1,3 @@ -// import { useSelector } from "react-redux"; -// import { selectors } from "@/store"; import clsx from "clsx"; import { Typography } from "@/components/ui"; diff --git a/src/components/domains/retaining/RetainingStepper/RetainingStepper.tsx b/src/components/domains/retaining/RetainingStepper/RetainingStepper.tsx index 5380e7d..8922b48 100644 --- a/src/components/domains/retaining/RetainingStepper/RetainingStepper.tsx +++ b/src/components/domains/retaining/RetainingStepper/RetainingStepper.tsx @@ -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.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 ( (); - 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 ( diff --git a/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx b/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx index d436340..5e7d425 100644 --- a/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx @@ -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 ( diff --git a/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx b/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx index ff78668..70373fd 100644 --- a/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx @@ -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( 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); }; diff --git a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.module.scss b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.module.scss index fe1c2ac..4ce8e2c 100644 --- a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.module.scss +++ b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.module.scss @@ -11,7 +11,7 @@ pointer-events: none; margin-top: 211px; overflow-x: clip; - z-index: 9999; + z-index: 8888; & > .blur { padding-top: 40px; diff --git a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx index 9587c5f..a9bd934 100644 --- a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx +++ b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx @@ -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(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={ - // vip member (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()); }; diff --git a/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx b/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx index b51b92b..82b1da7 100644 --- a/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx @@ -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( 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()); diff --git a/src/components/ui/SelectInput/SelectInput.module.scss b/src/components/ui/SelectInput/SelectInput.module.scss index cc0bbab..632eb8e 100644 --- a/src/components/ui/SelectInput/SelectInput.module.scss +++ b/src/components/ui/SelectInput/SelectInput.module.scss @@ -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; diff --git a/src/components/ui/TextInput/TextInput.module.scss b/src/components/ui/TextInput/TextInput.module.scss index 48ba6b5..d0c1007 100644 --- a/src/components/ui/TextInput/TextInput.module.scss +++ b/src/components/ui/TextInput/TextInput.module.scss @@ -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); } diff --git a/src/components/ui/Toast/Toast.module.scss b/src/components/ui/Toast/Toast.module.scss index 5b2bb53..738313a 100644 --- a/src/components/ui/Toast/Toast.module.scss +++ b/src/components/ui/Toast/Toast.module.scss @@ -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; } } diff --git a/src/components/ui/Toast/Toast.tsx b/src/components/ui/Toast/Toast.tsx index b56b409..1d452a7 100644 --- a/src/components/ui/Toast/Toast.tsx +++ b/src/components/ui/Toast/Toast.tsx @@ -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 ; + case "success": + return ( + + + + + ); + case "warning": + return ( + + + + + + ); + case "info": + return ( + + + + + + ); + } +}; + function Toast({ variant, children, classNameContainer = "", classNameToast = "", + onClose, }: IToastProps) { return (
- {variant === "error" && } - {variant === "success" &&
} - {children} + {getIcon(variant)} +
{children}
+ {onClose && ( + + )}
); diff --git a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.module.scss b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.module.scss index d4da26f..f280653 100644 --- a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.module.scss +++ b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.module.scss @@ -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; -} \ No newline at end of file +} diff --git a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx index 11c6fb2..7c21e4c 100644 --- a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx +++ b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx @@ -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; diff --git a/src/components/widgets/Horoscope/Horoscope.module.scss b/src/components/widgets/Horoscope/Horoscope.module.scss index 7901476..28010ff 100644 --- a/src/components/widgets/Horoscope/Horoscope.module.scss +++ b/src/components/widgets/Horoscope/Horoscope.module.scss @@ -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; } diff --git a/src/components/widgets/TimePicker/TimePicker.module.scss b/src/components/widgets/TimePicker/TimePicker.module.scss index 1c53604..9d7aff0 100644 --- a/src/components/widgets/TimePicker/TimePicker.module.scss +++ b/src/components/widgets/TimePicker/TimePicker.module.scss @@ -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; } diff --git a/src/components/widgets/ToastContainer/ToastContainer.module.scss b/src/components/widgets/ToastContainer/ToastContainer.module.scss new file mode 100644 index 0000000..5484083 --- /dev/null +++ b/src/components/widgets/ToastContainer/ToastContainer.module.scss @@ -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; + } +} diff --git a/src/components/widgets/ToastContainer/ToastContainer.tsx b/src/components/widgets/ToastContainer/ToastContainer.tsx new file mode 100644 index 0000000..0072ce5 --- /dev/null +++ b/src/components/widgets/ToastContainer/ToastContainer.tsx @@ -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>(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 ( +
+ {visibleToasts.map((toast, index) => ( +
+ removeToast(toast.id)} + > + {toast.message} + +
+ ))} + + {/* {toasts.length > maxVisible && ( +
+ +{toasts.length - maxVisible} в очереди +
+ )} */} +
+ ); +}); + +ToastContainer.displayName = "ToastContainer"; + +export default ToastContainer; diff --git a/src/components/widgets/index.ts b/src/components/widgets/index.ts index e810d34..6c37df5 100644 --- a/src/components/widgets/index.ts +++ b/src/components/widgets/index.ts @@ -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"; diff --git a/src/entities/dashboard/types.ts b/src/entities/dashboard/types.ts index 0a8c9a2..a917b5e 100644 --- a/src/entities/dashboard/types.ts +++ b/src/entities/dashboard/types.ts @@ -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; @@ -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; -/* ---------- 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; - -/* ---------- 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; - -/* ---------- 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; +export type Action = z.infer; /* ---------- Итоговый ответ /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; diff --git a/src/entities/generations/actions.ts b/src/entities/generations/actions.ts index 199142b..d73d821 100644 --- a/src/entities/generations/actions.ts +++ b/src/entities/generations/actions.ts @@ -41,7 +41,7 @@ export async function fetchGenerationStatus( API_ROUTES.statusGeneration(id), { schema: GenerationResponseSchema, - revalidate: 0, // Всегда запрашиваем свежие данные + revalidate: 0, } ); return { data: response, error: null }; diff --git a/src/entities/generations/types.ts b/src/entities/generations/types.ts index 0a45d41..501211b 100644 --- a/src/entities/generations/types.ts +++ b/src/entities/generations/types.ts @@ -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 - .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; diff --git a/src/entities/payment/types.ts b/src/entities/payment/types.ts index eeb0758..81a0a49 100644 --- a/src/entities/payment/types.ts +++ b/src/entities/payment/types.ts @@ -8,7 +8,7 @@ export const CheckoutRequestSchema = z.object({ export type CheckoutRequest = z.infer; export const CheckoutResponseSchema = z.object({ - status: z.string(), // "paid" | "pending" | … + status: z.string(), invoiceId: z.string(), paymentUrl: z.string().url(), }); diff --git a/src/entities/subscriptions/actions.ts b/src/entities/subscriptions/actions.ts new file mode 100644 index 0000000..54c60ef --- /dev/null +++ b/src/entities/subscriptions/actions.ts @@ -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> { + try { + const response = await http.post( + 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 }; + } +} diff --git a/src/entities/subscriptions/api.ts b/src/entities/subscriptions/api.ts index 3a7e811..79f42a7 100644 --- a/src/entities/subscriptions/api.ts +++ b/src/entities/subscriptions/api.ts @@ -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(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( + API_ROUTES.userSubscriptionAction(subscriptionId, action), + payload, + { + tags: ["user-subscription-action"], + schema: UserSubscriptionActionResponseSchema, + revalidate: 0, + } + ); +}; diff --git a/src/entities/subscriptions/types.ts b/src/entities/subscriptions/types.ts index 6dc490e..e429e64 100644 --- a/src/entities/subscriptions/types.ts +++ b/src/entities/subscriptions/types.ts @@ -26,7 +26,34 @@ export const UserSubscriptionSchema = z.object({ export type UserSubscription = z.infer; export const SubscriptionsSchema = z.object({ - status: z.string(), // "success" | string + status: z.string(), data: z.array(UserSubscriptionSchema), }); export type SubscriptionsData = z.infer; + +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 +>; diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts index 300df0e..d9a0628 100644 --- a/src/entities/user/types.ts +++ b/src/entities/user/types.ts @@ -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(), diff --git a/src/hooks/generation/useGenerationPolling.ts b/src/hooks/generation/useGenerationPolling.ts index 9027c88..c35aae4 100644 --- a/src/hooks/generation/useGenerationPolling.ts +++ b/src/hooks/generation/useGenerationPolling.ts @@ -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; diff --git a/src/providers/StoreProvider.tsx b/src/providers/StoreProvider.tsx deleted file mode 100644 index d9a999d..0000000 --- a/src/providers/StoreProvider.tsx +++ /dev/null @@ -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}; -} diff --git a/src/providers/retaining-store-provider.tsx b/src/providers/retaining-store-provider.tsx new file mode 100644 index 0000000..952f54b --- /dev/null +++ b/src/providers/retaining-store-provider.tsx @@ -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; + +export const RetainingStoreContext = createContext< + RetiningStoreApi | undefined +>(undefined); + +export interface RetainingStoreProviderProps { + children: ReactNode; +} + +export const RetainingStoreProvider = ({ + children, +}: RetainingStoreProviderProps) => { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = createRetainingStore(); + } + + return ( + + {children} + + ); +}; + +export const useRetainingStore = ( + selector: (store: RetainingStore) => T +): T => { + const retainingStoreContext = useContext(RetainingStoreContext); + + if (!retainingStoreContext) { + throw new Error( + `useRetainingStore must be used within RetainingStoreProvider` + ); + } + + return useStore(retainingStoreContext, selector); +}; diff --git a/src/providers/toast-provider.tsx b/src/providers/toast-provider.tsx new file mode 100644 index 0000000..4f46a37 --- /dev/null +++ b/src/providers/toast-provider.tsx @@ -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) => 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(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) => { + 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 ( + + {children} + + + ); +}; + +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within ToastProvider"); + } + return context; +}; diff --git a/src/shared/auth/token.ts b/src/shared/auth/token.ts index b3d800d..7450379 100644 --- a/src/shared/auth/token.ts +++ b/src/shared/auth/token.ts @@ -1,4 +1,3 @@ -// src/shared/auth/token.ts import { cookies } from "next/headers"; export async function getServerAccessToken() { diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts index 1011097..4dd374e 100644 --- a/src/shared/constants/api-routes.ts +++ b/src/shared/constants/api-routes.ts @@ -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 + ), }; diff --git a/src/shared/constants/client-routes.ts b/src/shared/constants/client-routes.ts index ae24ce2..3f105a2 100644 --- a/src/shared/constants/client-routes.ts +++ b/src/shared/constants/client-routes.ts @@ -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]), diff --git a/src/stores/retaining-store.ts b/src/stores/retaining-store.ts new file mode 100644 index 0000000..2f3dd4d --- /dev/null +++ b/src/stores/retaining-store.ts @@ -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()( + 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" } + ) + ); +}; diff --git a/src/stores/retainingStore.ts b/src/stores/retainingStore.ts deleted file mode 100644 index 82b3343..0000000 --- a/src/stores/retainingStore.ts +++ /dev/null @@ -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()( - 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, - })); diff --git a/src/types/index.ts b/src/types/index.ts index 7bb7713..ec343fa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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;