From 4e58236d8f0000f25ca6f9ad47f1ca5fde5703d6 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Fri, 12 Sep 2025 21:45:46 +0200 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D1=80=D0=BD=D1=83=D0=BB=20=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=BD=D0=BA=D1=83=20=D0=BE=D1=82=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en.json | 11 ++- .../free-chat-activated/page.module.scss | 27 +++++ .../retaining/free-chat-activated/page.tsx | 23 +++++ .../GlobalNewMessagesBanner.tsx | 5 +- .../CancelSubscriptionModalProvider.tsx | 58 +++++------ .../cancel-subscription/Buttons/Buttons.tsx | 3 +- .../Buttons/Buttons.tsx | 20 ++-- .../retaining/change-mind/Buttons/Buttons.tsx | 10 +- .../Button/Button.module.scss | 5 + .../free-chat-activated/Button/Button.tsx | 29 ++++++ .../retaining/free-chat-activated/index.ts | 1 + src/components/domains/retaining/index.ts | 1 + .../SecondChancePage/SecondChancePage.tsx | 11 ++- .../stop-for-30-days/Buttons/Buttons.tsx | 5 +- .../retaining/what-reason/Buttons/Buttons.tsx | 29 +++++- .../layout/NavigationBar/NavigationBar.tsx | 5 + src/entities/subscriptions/helpers.ts | 36 +++++++ src/entities/subscriptions/types.ts | 20 +++- src/hooks/retaining/useRetainingTracker.ts | 79 +++++++++++++++ src/shared/constants/client-routes.ts | 2 + src/stores/retaining-store.ts | 99 ++++++++++++++++++- 21 files changed, 414 insertions(+), 65 deletions(-) create mode 100644 src/app/[locale]/(core)/retaining/free-chat-activated/page.module.scss create mode 100644 src/app/[locale]/(core)/retaining/free-chat-activated/page.tsx create mode 100644 src/components/domains/retaining/free-chat-activated/Button/Button.module.scss create mode 100644 src/components/domains/retaining/free-chat-activated/Button/Button.tsx create mode 100644 src/components/domains/retaining/free-chat-activated/index.ts create mode 100644 src/entities/subscriptions/helpers.ts create mode 100644 src/hooks/retaining/useRetainingTracker.ts diff --git a/messages/en.json b/messages/en.json index a03f631..4325b53 100644 --- a/messages/en.json +++ b/messages/en.json @@ -181,8 +181,15 @@ "button": "Done" }, "SubscriptionStopped": { - "title": "Subscription stopped successfully!", - "icon": "πŸŽ‰" + "title": "Billing paused successfully!", + "icon": "⏸️", + "description": "Your subscription remains active, but billing has been paused. You can still use all features." + }, + "FreeChatActivated": { + "title": "Free credits added!", + "icon": "🎁", + "description": "You've received 1200 free credits for 30 minutes of chat!", + "button": "Start chatting" }, "DatePicker": { "year": "YYYY", diff --git a/src/app/[locale]/(core)/retaining/free-chat-activated/page.module.scss b/src/app/[locale]/(core)/retaining/free-chat-activated/page.module.scss new file mode 100644 index 0000000..28fe25a --- /dev/null +++ b/src/app/[locale]/(core)/retaining/free-chat-activated/page.module.scss @@ -0,0 +1,27 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 24px; + padding: 40px 20px; + min-height: 60vh; +} + +.title { + font-size: 24px; + line-height: 1.3; +} + +.icon { + font-size: 60px; + margin: 20px 0; +} + +.description { + font-size: 16px; + line-height: 1.4; + color: var(--text-secondary); + max-width: 300px; +} diff --git a/src/app/[locale]/(core)/retaining/free-chat-activated/page.tsx b/src/app/[locale]/(core)/retaining/free-chat-activated/page.tsx new file mode 100644 index 0000000..a5eab30 --- /dev/null +++ b/src/app/[locale]/(core)/retaining/free-chat-activated/page.tsx @@ -0,0 +1,23 @@ +import { getTranslations } from "next-intl/server"; + +import { FreeChatActivatedButton } from "@/components/domains/retaining/free-chat-activated"; +import { Typography } from "@/components/ui"; + +import styles from "./page.module.scss"; + +export default async function FreeChatActivated() { + const t = await getTranslations("FreeChatActivated"); + + return ( +
+ + {t("title")} + + {t("icon")} + + + {t("description")} + +
+ ); +} diff --git a/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx b/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx index c1df217..f4880ab 100644 --- a/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx +++ b/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx @@ -15,13 +15,14 @@ import styles from "./GlobalNewMessagesBanner.module.scss"; export default function GlobalNewMessagesBanner() { const { unreadChats } = useChats(); - // Exclude banner on chat-related and settings (profile) pages + // Exclude banner on chat-related, settings (profile), and retention funnel pages const pathname = usePathname(); const locale = useLocale(); const pathnameWithoutLocale = stripLocale(pathname, locale); const isExcluded = pathnameWithoutLocale.startsWith(ROUTES.chat()) || - pathnameWithoutLocale.startsWith(ROUTES.profile()); + pathnameWithoutLocale.startsWith(ROUTES.profile()) || + pathnameWithoutLocale.startsWith("/retaining"); const hasHydrated = useAppUiStore(state => state._hasHydrated); const { isVisibleAll } = useAppUiStore(state => state.home.newMessages); diff --git a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx index d4446d8..b7bb934 100644 --- a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx +++ b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx @@ -7,14 +7,15 @@ import { useContext, useState, } from "react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button, Typography } from "@/components/ui"; import Modal from "@/components/ui/Modal/Modal"; -import { performUserSubscriptionAction } from "@/entities/subscriptions/actions"; import { UserSubscription } from "@/entities/subscriptions/types"; import { useRetainingStore } from "@/providers/retaining-store-provider"; -import { useToast } from "@/providers/toast-provider"; +import { ROUTES } from "@/shared/constants/client-routes"; +import { ERetainingFunnel } from "@/stores/retaining-store"; import styles from "./CancelSubscriptionModalProvider.module.scss"; @@ -35,14 +36,16 @@ export default function CancelSubscriptionModalProvider({ children: ReactNode; }) { const t = useTranslations("Subscriptions"); - const { addToast } = useToast(); + const router = useRouter(); const [isOpen, setIsOpen] = useState(false); - const [isLoadingCancelButton, setIsLoadingCancelButton] = - useState(false); - const { setCancellingSubscription, cancellingSubscription } = - useRetainingStore(state => state); + const { + setCancellingSubscription, + cancellingSubscription, + setRetainingData, + startJourney, + } = useRetainingStore(state => state); const close = useCallback(() => setIsOpen(false), []); const open = useCallback( @@ -54,30 +57,21 @@ export default function CancelSubscriptionModalProvider({ ); const handleCancel = useCallback(async () => { - // router.push(ROUTES.retainingFunnelCancelSubscription()); + if (!cancellingSubscription) return; - if (isLoadingCancelButton) return; - setIsLoadingCancelButton(true); - - const response = await performUserSubscriptionAction({ - subscriptionId: cancellingSubscription?.id || "", - action: "cancel", + // Set up retention funnel data with default Red funnel + setRetainingData({ + funnel: ERetainingFunnel.Red, + cancellingSubscription, }); - if (response?.data?.status === "success") { - close(); - addToast({ - variant: "success", - message: t("success_cancel_message"), - duration: 3000, - }); - // Data will be automatically refreshed due to cache invalidation in the action - // No need to redirect, let the user see the updated subscription status - } - setIsLoadingCancelButton(false); + // Start journey tracking + startJourney(cancellingSubscription.id); + // Close modal and redirect to retention funnel close(); - }, [isLoadingCancelButton, cancellingSubscription?.id, close, addToast, t]); + router.push(ROUTES.retainingFunnelCancelSubscription()); + }, [cancellingSubscription, setRetainingData, startJourney, close, router]); const handleStay = useCallback(() => { close(); @@ -102,18 +96,10 @@ export default function CancelSubscriptionModalProvider({
- -
diff --git a/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.tsx b/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.tsx index 699b583..4faf356 100644 --- a/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.tsx @@ -25,7 +25,7 @@ export default function Buttons() { }); const { addToast } = useToast(); - const { cancellingSubscription, setFunnel } = useRetainingStore( + const { cancellingSubscription, setFunnel, journey } = useRetainingStore( state => state ); @@ -51,6 +51,7 @@ export default function Buttons() { const response = await performUserSubscriptionAction({ subscriptionId: cancellingSubscription?.id || "", action: "discount_50", + retainingJourney: journey || undefined, }); if (response?.data?.status === "success") { 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 a9d5762..3a40208 100644 --- a/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Spinner, Toast } from "@/components/ui"; +import { Spinner } from "@/components/ui"; import { performUserSubscriptionAction } from "@/entities/subscriptions/actions"; import { useRetainingStore } from "@/providers/retaining-store-provider"; import { useToast } from "@/providers/toast-provider"; @@ -19,9 +19,8 @@ export default function Buttons() { const router = useRouter(); const { addToast } = useToast(); - const { cancellingSubscription } = useRetainingStore(state => state); + const { cancellingSubscription, journey } = useRetainingStore(state => state); - const [isToastVisible, setIsToastVisible] = useState(false); const [isLoadingOfferButton, setIsLoadingOfferButton] = useState(false); const [isLoadingCancelButton, setIsLoadingCancelButton] = @@ -33,6 +32,7 @@ export default function Buttons() { const response = await performUserSubscriptionAction({ subscriptionId: cancellingSubscription?.id || "", action: "pause_60", + retainingJourney: journey || undefined, }); if (response?.data?.status === "success") { @@ -47,20 +47,17 @@ export default function Buttons() { }; const handleCancelClick = async () => { - if (isToastVisible || isLoadingOfferButton || isLoadingCancelButton) return; + if (isLoadingOfferButton || isLoadingCancelButton) return; setIsLoadingCancelButton(true); const response = await performUserSubscriptionAction({ subscriptionId: cancellingSubscription?.id || "", action: "cancel", + retainingJourney: journey || undefined, }); if (response?.data?.status === "success") { - setIsToastVisible(true); - const timer = setTimeout(() => { - router.push(ROUTES.profile()); - }, 7000); - return () => clearTimeout(timer); + return router.push(ROUTES.retainingFunnelPlanCancelled()); } setIsLoadingCancelButton(false); addToast({ @@ -95,11 +92,6 @@ export default function Buttons() { )} {t("cancel_button")} - {isToastVisible && ( - - {t("toast_message")} - - )} ); } diff --git a/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx b/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx index 70373fd..34fe201 100644 --- a/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx @@ -29,10 +29,18 @@ export default function Buttons({ answers }: ButtonsProps) { const [activeAnswer, setActiveAnswer] = useState( null ); - const { funnel } = useRetainingStore(state => state); + const { funnel, addChoice } = useRetainingStore(state => state); const handleNext = (answer: ChangeMindAnswer) => { setActiveAnswer(answer); + + // Track user choice + addChoice({ + step: "change-mind", + choiceId: answer.id, + choiceTitle: answer.title, + }); + const timer = setTimeout(() => { if (funnel === ERetainingFunnel.Red) { router.push(ROUTES.retainingFunnelStopFor30Days()); diff --git a/src/components/domains/retaining/free-chat-activated/Button/Button.module.scss b/src/components/domains/retaining/free-chat-activated/Button/Button.module.scss new file mode 100644 index 0000000..2c1b7be --- /dev/null +++ b/src/components/domains/retaining/free-chat-activated/Button/Button.module.scss @@ -0,0 +1,5 @@ +.button { + width: 100%; + max-width: 280px; + margin-top: 20px; +} diff --git a/src/components/domains/retaining/free-chat-activated/Button/Button.tsx b/src/components/domains/retaining/free-chat-activated/Button/Button.tsx new file mode 100644 index 0000000..17f4a01 --- /dev/null +++ b/src/components/domains/retaining/free-chat-activated/Button/Button.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import { ROUTES } from "@/shared/constants/client-routes"; + +import { RetainingButton } from "../.."; + +import styles from "./Button.module.scss"; + +export default function Button() { + const t = useTranslations("FreeChatActivated"); + const router = useRouter(); + + const handleButtonClick = () => { + router.push(ROUTES.home()); + }; + + return ( + + {t("button")} + + ); +} diff --git a/src/components/domains/retaining/free-chat-activated/index.ts b/src/components/domains/retaining/free-chat-activated/index.ts new file mode 100644 index 0000000..c1f7622 --- /dev/null +++ b/src/components/domains/retaining/free-chat-activated/index.ts @@ -0,0 +1 @@ +export { default as FreeChatActivatedButton } from "./Button/Button"; diff --git a/src/components/domains/retaining/index.ts b/src/components/domains/retaining/index.ts index a7002e1..77c48ed 100644 --- a/src/components/domains/retaining/index.ts +++ b/src/components/domains/retaining/index.ts @@ -1,4 +1,5 @@ export { default as RetainingButton } from "./Button/Button"; export { default as CheckMark } from "./CheckMark/CheckMark"; +export * from "./free-chat-activated"; export { default as Offer } from "./Offer/Offer"; export { default as RetainingStepper } from "./RetainingStepper/RetainingStepper"; diff --git a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx index 8cd4350..21dce73 100644 --- a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx +++ b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx @@ -35,7 +35,9 @@ export default function SecondChancePage() { "pause_30" ); const [isLoadingButton, setIsLoadingButton] = useState(false); - const { funnel, cancellingSubscription } = useRetainingStore(state => state); + const { funnel, cancellingSubscription, journey } = useRetainingStore( + state => state + ); const handleOfferClick = (offer: "pause_30" | "free_chat_30") => { if (isLoadingButton) return; @@ -49,10 +51,15 @@ export default function SecondChancePage() { const response = await performUserSubscriptionAction({ subscriptionId: cancellingSubscription?.id || "", action: activeOffer, + retainingJourney: journey || undefined, }); if (response?.data?.status === "success") { - return router.push(ROUTES.retainingFunnelPlanCancelled()); + if (activeOffer === "pause_30") { + return router.push(ROUTES.retainingFunnelSubscriptionStopped()); + } else if (activeOffer === "free_chat_30") { + return router.push(ROUTES.retainingFunnelFreeChatActivated()); + } } setIsLoadingButton(false); addToast({ diff --git a/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.tsx b/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.tsx index 579d772..9506c12 100644 --- a/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.tsx @@ -29,7 +29,9 @@ export default function Buttons() { const [isLoadingButton, setIsLoadingButton] = useState(false); - const { funnel, cancellingSubscription } = useRetainingStore(state => state); + const { funnel, cancellingSubscription, journey } = useRetainingStore( + state => state + ); const handleStopClick = async () => { if (isLoadingButton) return; @@ -38,6 +40,7 @@ export default function Buttons() { const response = await performUserSubscriptionAction({ subscriptionId: cancellingSubscription?.id || "", action: "pause_30", + retainingJourney: journey || undefined, }); if (response?.data?.status === "success") { diff --git a/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx b/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx index 82b1da7..47b9582 100644 --- a/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx @@ -24,7 +24,9 @@ interface ButtonsProps { export default function Buttons({ answers }: ButtonsProps) { const router = useRouter(); - const { setFunnel } = useRetainingStore(state => state); + const { setFunnel, addChoice, updateCurrentStep } = useRetainingStore( + state => state + ); const [activeAnswer, setActiveAnswer] = useState( null @@ -33,15 +35,34 @@ export default function Buttons({ answers }: ButtonsProps) { const handleNext = (answer: WhatReasonAnswer) => { setActiveAnswer(answer); setFunnel(answer.funnel); + + // Track user choice + addChoice({ + step: "what-reason", + choiceId: answer.id, + choiceTitle: answer.title, + }); + const timer = setTimeout(() => { + let nextStep = ""; + let nextRoute = ""; + if (answer.funnel === ERetainingFunnel.Red) { - router.push(ROUTES.retainingFunnelSecondChance()); + nextStep = "second-chance"; + nextRoute = ROUTES.retainingFunnelSecondChance(); } if (answer.funnel === ERetainingFunnel.Green) { - router.push(ROUTES.retainingFunnelStopFor30Days()); + nextStep = "stop-for-30-days"; + nextRoute = ROUTES.retainingFunnelStopFor30Days(); } if (answer.funnel === ERetainingFunnel.Purple) { - router.push(ROUTES.retainingFunnelChangeMind()); + nextStep = "change-mind"; + nextRoute = ROUTES.retainingFunnelChangeMind(); + } + + if (nextStep) { + updateCurrentStep(nextStep); + router.push(nextRoute); } }, 1000); return () => clearTimeout(timer); diff --git a/src/components/layout/NavigationBar/NavigationBar.tsx b/src/components/layout/NavigationBar/NavigationBar.tsx index 45a0aee..16e56ba 100644 --- a/src/components/layout/NavigationBar/NavigationBar.tsx +++ b/src/components/layout/NavigationBar/NavigationBar.tsx @@ -24,6 +24,11 @@ export default function NavigationBar() { const pathnameWithoutLocale = stripLocale(pathname, locale); const { totalUnreadCount } = useChats(); + // Hide navigation bar on retaining funnel pages + const isRetainingFunnel = pathnameWithoutLocale.startsWith("/retaining"); + + if (isRetainingFunnel) return null; + return (