Merge pull request #52 from pennyteenycat/feature/cancelFunnel
Вернул воронку отмены
This commit is contained in:
commit
dead22cb50
@ -181,8 +181,15 @@
|
|||||||
"button": "Done"
|
"button": "Done"
|
||||||
},
|
},
|
||||||
"SubscriptionStopped": {
|
"SubscriptionStopped": {
|
||||||
"title": "Subscription stopped successfully!",
|
"title": "Billing paused successfully!",
|
||||||
"icon": "🎉"
|
"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": {
|
"DatePicker": {
|
||||||
"year": "YYYY",
|
"year": "YYYY",
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Typography as="h1" weight="semiBold" className={styles.title}>
|
||||||
|
{t("title")}
|
||||||
|
</Typography>
|
||||||
|
<span className={styles.icon}>{t("icon")}</span>
|
||||||
|
<FreeChatActivatedButton />
|
||||||
|
<Typography as="p" className={styles.description}>
|
||||||
|
{t("description")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,13 +15,14 @@ import styles from "./GlobalNewMessagesBanner.module.scss";
|
|||||||
export default function GlobalNewMessagesBanner() {
|
export default function GlobalNewMessagesBanner() {
|
||||||
const { unreadChats } = useChats();
|
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 pathname = usePathname();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||||
const isExcluded =
|
const isExcluded =
|
||||||
pathnameWithoutLocale.startsWith(ROUTES.chat()) ||
|
pathnameWithoutLocale.startsWith(ROUTES.chat()) ||
|
||||||
pathnameWithoutLocale.startsWith(ROUTES.profile());
|
pathnameWithoutLocale.startsWith(ROUTES.profile()) ||
|
||||||
|
pathnameWithoutLocale.startsWith("/retaining");
|
||||||
|
|
||||||
const hasHydrated = useAppUiStore(state => state._hasHydrated);
|
const hasHydrated = useAppUiStore(state => state._hasHydrated);
|
||||||
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
|
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
|
||||||
|
|||||||
@ -7,14 +7,15 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button, Typography } from "@/components/ui";
|
import { Button, Typography } from "@/components/ui";
|
||||||
import Modal from "@/components/ui/Modal/Modal";
|
import Modal from "@/components/ui/Modal/Modal";
|
||||||
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
|
|
||||||
import { UserSubscription } from "@/entities/subscriptions/types";
|
import { UserSubscription } from "@/entities/subscriptions/types";
|
||||||
import { useRetainingStore } from "@/providers/retaining-store-provider";
|
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";
|
import styles from "./CancelSubscriptionModalProvider.module.scss";
|
||||||
|
|
||||||
@ -35,14 +36,16 @@ export default function CancelSubscriptionModalProvider({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("Subscriptions");
|
const t = useTranslations("Subscriptions");
|
||||||
const { addToast } = useToast();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoadingCancelButton, setIsLoadingCancelButton] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
const { setCancellingSubscription, cancellingSubscription } =
|
const {
|
||||||
useRetainingStore(state => state);
|
setCancellingSubscription,
|
||||||
|
cancellingSubscription,
|
||||||
|
setRetainingData,
|
||||||
|
startJourney,
|
||||||
|
} = useRetainingStore(state => state);
|
||||||
|
|
||||||
const close = useCallback(() => setIsOpen(false), []);
|
const close = useCallback(() => setIsOpen(false), []);
|
||||||
const open = useCallback(
|
const open = useCallback(
|
||||||
@ -54,30 +57,21 @@ export default function CancelSubscriptionModalProvider({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCancel = useCallback(async () => {
|
const handleCancel = useCallback(async () => {
|
||||||
// router.push(ROUTES.retainingFunnelCancelSubscription());
|
if (!cancellingSubscription) return;
|
||||||
|
|
||||||
if (isLoadingCancelButton) return;
|
// Set up retention funnel data with default Red funnel
|
||||||
setIsLoadingCancelButton(true);
|
setRetainingData({
|
||||||
|
funnel: ERetainingFunnel.Red,
|
||||||
const response = await performUserSubscriptionAction({
|
cancellingSubscription,
|
||||||
subscriptionId: cancellingSubscription?.id || "",
|
|
||||||
action: "cancel",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response?.data?.status === "success") {
|
// Start journey tracking
|
||||||
close();
|
startJourney(cancellingSubscription.id);
|
||||||
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);
|
|
||||||
|
|
||||||
|
// Close modal and redirect to retention funnel
|
||||||
close();
|
close();
|
||||||
}, [isLoadingCancelButton, cancellingSubscription?.id, close, addToast, t]);
|
router.push(ROUTES.retainingFunnelCancelSubscription());
|
||||||
|
}, [cancellingSubscription, setRetainingData, startJourney, close, router]);
|
||||||
|
|
||||||
const handleStay = useCallback(() => {
|
const handleStay = useCallback(() => {
|
||||||
close();
|
close();
|
||||||
@ -102,18 +96,10 @@ export default function CancelSubscriptionModalProvider({
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button
|
<Button className={styles.action} onClick={handleCancel}>
|
||||||
className={styles.action}
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isLoadingCancelButton}
|
|
||||||
>
|
|
||||||
{t("modal.cancel_button")}
|
{t("modal.cancel_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleStay} className={styles.action}>
|
||||||
onClick={handleStay}
|
|
||||||
className={styles.action}
|
|
||||||
disabled={isLoadingCancelButton}
|
|
||||||
>
|
|
||||||
{t("modal.stay_button")}
|
{t("modal.stay_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export default function Buttons() {
|
|||||||
});
|
});
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
const { cancellingSubscription, setFunnel } = useRetainingStore(
|
const { cancellingSubscription, setFunnel, journey } = useRetainingStore(
|
||||||
state => state
|
state => state
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -51,6 +51,7 @@ export default function Buttons() {
|
|||||||
const response = await performUserSubscriptionAction({
|
const response = await performUserSubscriptionAction({
|
||||||
subscriptionId: cancellingSubscription?.id || "",
|
subscriptionId: cancellingSubscription?.id || "",
|
||||||
action: "discount_50",
|
action: "discount_50",
|
||||||
|
retainingJourney: journey || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response?.data?.status === "success") {
|
if (response?.data?.status === "success") {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Spinner, Toast } from "@/components/ui";
|
import { Spinner } from "@/components/ui";
|
||||||
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
|
import { performUserSubscriptionAction } from "@/entities/subscriptions/actions";
|
||||||
import { useRetainingStore } from "@/providers/retaining-store-provider";
|
import { useRetainingStore } from "@/providers/retaining-store-provider";
|
||||||
import { useToast } from "@/providers/toast-provider";
|
import { useToast } from "@/providers/toast-provider";
|
||||||
@ -19,9 +19,8 @@ export default function Buttons() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
const { cancellingSubscription } = useRetainingStore(state => state);
|
const { cancellingSubscription, journey } = useRetainingStore(state => state);
|
||||||
|
|
||||||
const [isToastVisible, setIsToastVisible] = useState(false);
|
|
||||||
const [isLoadingOfferButton, setIsLoadingOfferButton] =
|
const [isLoadingOfferButton, setIsLoadingOfferButton] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [isLoadingCancelButton, setIsLoadingCancelButton] =
|
const [isLoadingCancelButton, setIsLoadingCancelButton] =
|
||||||
@ -33,6 +32,7 @@ export default function Buttons() {
|
|||||||
const response = await performUserSubscriptionAction({
|
const response = await performUserSubscriptionAction({
|
||||||
subscriptionId: cancellingSubscription?.id || "",
|
subscriptionId: cancellingSubscription?.id || "",
|
||||||
action: "pause_60",
|
action: "pause_60",
|
||||||
|
retainingJourney: journey || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response?.data?.status === "success") {
|
if (response?.data?.status === "success") {
|
||||||
@ -47,20 +47,17 @@ export default function Buttons() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelClick = async () => {
|
const handleCancelClick = async () => {
|
||||||
if (isToastVisible || isLoadingOfferButton || isLoadingCancelButton) return;
|
if (isLoadingOfferButton || isLoadingCancelButton) return;
|
||||||
setIsLoadingCancelButton(true);
|
setIsLoadingCancelButton(true);
|
||||||
|
|
||||||
const response = await performUserSubscriptionAction({
|
const response = await performUserSubscriptionAction({
|
||||||
subscriptionId: cancellingSubscription?.id || "",
|
subscriptionId: cancellingSubscription?.id || "",
|
||||||
action: "cancel",
|
action: "cancel",
|
||||||
|
retainingJourney: journey || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response?.data?.status === "success") {
|
if (response?.data?.status === "success") {
|
||||||
setIsToastVisible(true);
|
return router.push(ROUTES.retainingFunnelPlanCancelled());
|
||||||
const timer = setTimeout(() => {
|
|
||||||
router.push(ROUTES.profile());
|
|
||||||
}, 7000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
setIsLoadingCancelButton(false);
|
setIsLoadingCancelButton(false);
|
||||||
addToast({
|
addToast({
|
||||||
@ -95,11 +92,6 @@ export default function Buttons() {
|
|||||||
)}
|
)}
|
||||||
{t("cancel_button")}
|
{t("cancel_button")}
|
||||||
</RetainingButton>
|
</RetainingButton>
|
||||||
{isToastVisible && (
|
|
||||||
<Toast classNameContainer={styles.toast} variant="success">
|
|
||||||
{t("toast_message")}
|
|
||||||
</Toast>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,10 +29,18 @@ export default function Buttons({ answers }: ButtonsProps) {
|
|||||||
const [activeAnswer, setActiveAnswer] = useState<ChangeMindAnswer | null>(
|
const [activeAnswer, setActiveAnswer] = useState<ChangeMindAnswer | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const { funnel } = useRetainingStore(state => state);
|
const { funnel, addChoice } = useRetainingStore(state => state);
|
||||||
|
|
||||||
const handleNext = (answer: ChangeMindAnswer) => {
|
const handleNext = (answer: ChangeMindAnswer) => {
|
||||||
setActiveAnswer(answer);
|
setActiveAnswer(answer);
|
||||||
|
|
||||||
|
// Track user choice
|
||||||
|
addChoice({
|
||||||
|
step: "change-mind",
|
||||||
|
choiceId: answer.id,
|
||||||
|
choiceTitle: answer.title,
|
||||||
|
});
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (funnel === ERetainingFunnel.Red) {
|
if (funnel === ERetainingFunnel.Red) {
|
||||||
router.push(ROUTES.retainingFunnelStopFor30Days());
|
router.push(ROUTES.retainingFunnelStopFor30Days());
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<RetainingButton
|
||||||
|
className={styles.button}
|
||||||
|
active={true}
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
>
|
||||||
|
{t("button")}
|
||||||
|
</RetainingButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as FreeChatActivatedButton } from "./Button/Button";
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export { default as RetainingButton } from "./Button/Button";
|
export { default as RetainingButton } from "./Button/Button";
|
||||||
export { default as CheckMark } from "./CheckMark/CheckMark";
|
export { default as CheckMark } from "./CheckMark/CheckMark";
|
||||||
|
export * from "./free-chat-activated";
|
||||||
export { default as Offer } from "./Offer/Offer";
|
export { default as Offer } from "./Offer/Offer";
|
||||||
export { default as RetainingStepper } from "./RetainingStepper/RetainingStepper";
|
export { default as RetainingStepper } from "./RetainingStepper/RetainingStepper";
|
||||||
|
|||||||
@ -35,7 +35,9 @@ export default function SecondChancePage() {
|
|||||||
"pause_30"
|
"pause_30"
|
||||||
);
|
);
|
||||||
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false);
|
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false);
|
||||||
const { funnel, cancellingSubscription } = useRetainingStore(state => state);
|
const { funnel, cancellingSubscription, journey } = useRetainingStore(
|
||||||
|
state => state
|
||||||
|
);
|
||||||
|
|
||||||
const handleOfferClick = (offer: "pause_30" | "free_chat_30") => {
|
const handleOfferClick = (offer: "pause_30" | "free_chat_30") => {
|
||||||
if (isLoadingButton) return;
|
if (isLoadingButton) return;
|
||||||
@ -49,10 +51,15 @@ export default function SecondChancePage() {
|
|||||||
const response = await performUserSubscriptionAction({
|
const response = await performUserSubscriptionAction({
|
||||||
subscriptionId: cancellingSubscription?.id || "",
|
subscriptionId: cancellingSubscription?.id || "",
|
||||||
action: activeOffer,
|
action: activeOffer,
|
||||||
|
retainingJourney: journey || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response?.data?.status === "success") {
|
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);
|
setIsLoadingButton(false);
|
||||||
addToast({
|
addToast({
|
||||||
|
|||||||
@ -29,7 +29,9 @@ export default function Buttons() {
|
|||||||
|
|
||||||
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false);
|
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(false);
|
||||||
|
|
||||||
const { funnel, cancellingSubscription } = useRetainingStore(state => state);
|
const { funnel, cancellingSubscription, journey } = useRetainingStore(
|
||||||
|
state => state
|
||||||
|
);
|
||||||
|
|
||||||
const handleStopClick = async () => {
|
const handleStopClick = async () => {
|
||||||
if (isLoadingButton) return;
|
if (isLoadingButton) return;
|
||||||
@ -38,6 +40,7 @@ export default function Buttons() {
|
|||||||
const response = await performUserSubscriptionAction({
|
const response = await performUserSubscriptionAction({
|
||||||
subscriptionId: cancellingSubscription?.id || "",
|
subscriptionId: cancellingSubscription?.id || "",
|
||||||
action: "pause_30",
|
action: "pause_30",
|
||||||
|
retainingJourney: journey || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response?.data?.status === "success") {
|
if (response?.data?.status === "success") {
|
||||||
|
|||||||
@ -24,7 +24,9 @@ interface ButtonsProps {
|
|||||||
export default function Buttons({ answers }: ButtonsProps) {
|
export default function Buttons({ answers }: ButtonsProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { setFunnel } = useRetainingStore(state => state);
|
const { setFunnel, addChoice, updateCurrentStep } = useRetainingStore(
|
||||||
|
state => state
|
||||||
|
);
|
||||||
|
|
||||||
const [activeAnswer, setActiveAnswer] = useState<WhatReasonAnswer | null>(
|
const [activeAnswer, setActiveAnswer] = useState<WhatReasonAnswer | null>(
|
||||||
null
|
null
|
||||||
@ -33,15 +35,34 @@ export default function Buttons({ answers }: ButtonsProps) {
|
|||||||
const handleNext = (answer: WhatReasonAnswer) => {
|
const handleNext = (answer: WhatReasonAnswer) => {
|
||||||
setActiveAnswer(answer);
|
setActiveAnswer(answer);
|
||||||
setFunnel(answer.funnel);
|
setFunnel(answer.funnel);
|
||||||
|
|
||||||
|
// Track user choice
|
||||||
|
addChoice({
|
||||||
|
step: "what-reason",
|
||||||
|
choiceId: answer.id,
|
||||||
|
choiceTitle: answer.title,
|
||||||
|
});
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
let nextStep = "";
|
||||||
|
let nextRoute = "";
|
||||||
|
|
||||||
if (answer.funnel === ERetainingFunnel.Red) {
|
if (answer.funnel === ERetainingFunnel.Red) {
|
||||||
router.push(ROUTES.retainingFunnelSecondChance());
|
nextStep = "second-chance";
|
||||||
|
nextRoute = ROUTES.retainingFunnelSecondChance();
|
||||||
}
|
}
|
||||||
if (answer.funnel === ERetainingFunnel.Green) {
|
if (answer.funnel === ERetainingFunnel.Green) {
|
||||||
router.push(ROUTES.retainingFunnelStopFor30Days());
|
nextStep = "stop-for-30-days";
|
||||||
|
nextRoute = ROUTES.retainingFunnelStopFor30Days();
|
||||||
}
|
}
|
||||||
if (answer.funnel === ERetainingFunnel.Purple) {
|
if (answer.funnel === ERetainingFunnel.Purple) {
|
||||||
router.push(ROUTES.retainingFunnelChangeMind());
|
nextStep = "change-mind";
|
||||||
|
nextRoute = ROUTES.retainingFunnelChangeMind();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStep) {
|
||||||
|
updateCurrentStep(nextStep);
|
||||||
|
router.push(nextRoute);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|||||||
@ -24,6 +24,11 @@ export default function NavigationBar() {
|
|||||||
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||||
const { totalUnreadCount } = useChats();
|
const { totalUnreadCount } = useChats();
|
||||||
|
|
||||||
|
// Hide navigation bar on retaining funnel pages
|
||||||
|
const isRetainingFunnel = pathnameWithoutLocale.startsWith("/retaining");
|
||||||
|
|
||||||
|
if (isRetainingFunnel) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.container}>
|
<nav className={styles.container}>
|
||||||
{navItems.map(item => {
|
{navItems.map(item => {
|
||||||
|
|||||||
36
src/entities/subscriptions/helpers.ts
Normal file
36
src/entities/subscriptions/helpers.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useRetainingStore } from "@/providers/retaining-store-provider";
|
||||||
|
|
||||||
|
import type { UserSubscriptionActionPayload } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get current retaining journey data
|
||||||
|
*/
|
||||||
|
export function useRetainingJourneyData() {
|
||||||
|
const { journey } = useRetainingStore(state => state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
journey,
|
||||||
|
hasJourney: !!journey,
|
||||||
|
getJourneyPayload: () => journey || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create subscription action payload with optional retaining journey data
|
||||||
|
*/
|
||||||
|
export function createSubscriptionActionPayload(
|
||||||
|
subscriptionId: string,
|
||||||
|
action: UserSubscriptionActionPayload["action"],
|
||||||
|
journey?: UserSubscriptionActionPayload["retainingJourney"]
|
||||||
|
): UserSubscriptionActionPayload {
|
||||||
|
const payload: UserSubscriptionActionPayload = {
|
||||||
|
subscriptionId,
|
||||||
|
action,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (journey) {
|
||||||
|
payload.retainingJourney = journey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { ERetainingFunnel } from "@/stores/retaining-store";
|
||||||
import { Currency } from "@/types";
|
import { Currency } from "@/types";
|
||||||
|
|
||||||
export const SubscriptionTypeEnum = z.enum(["DAY", "WEEK", "MONTH", "YEAR"]);
|
export const SubscriptionTypeEnum = z.enum(["DAY", "WEEK", "MONTH", "YEAR"]);
|
||||||
@ -45,6 +46,23 @@ export type UserSubscriptionActionEnum = z.infer<
|
|||||||
export const UserSubscriptionActionPayloadSchema = z.object({
|
export const UserSubscriptionActionPayloadSchema = z.object({
|
||||||
subscriptionId: z.string(),
|
subscriptionId: z.string(),
|
||||||
action: UserSubscriptionActionEnumSchema,
|
action: UserSubscriptionActionEnumSchema,
|
||||||
|
retainingJourney: z
|
||||||
|
.object({
|
||||||
|
startedAt: z.number(),
|
||||||
|
updatedAt: z.number(),
|
||||||
|
currentStep: z.string(),
|
||||||
|
choices: z.array(
|
||||||
|
z.object({
|
||||||
|
step: z.string(),
|
||||||
|
choiceId: z.union([z.string(), z.number()]),
|
||||||
|
choiceTitle: z.string(),
|
||||||
|
timestamp: z.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
funnel: z.nativeEnum(ERetainingFunnel),
|
||||||
|
completedSteps: z.array(z.string()),
|
||||||
|
})
|
||||||
|
.optional(), // Include retaining journey data when action comes from funnel
|
||||||
});
|
});
|
||||||
export type UserSubscriptionActionPayload = z.infer<
|
export type UserSubscriptionActionPayload = z.infer<
|
||||||
typeof UserSubscriptionActionPayloadSchema
|
typeof UserSubscriptionActionPayloadSchema
|
||||||
@ -52,7 +70,7 @@ export type UserSubscriptionActionPayload = z.infer<
|
|||||||
|
|
||||||
export const UserSubscriptionActionResponseSchema = z.object({
|
export const UserSubscriptionActionResponseSchema = z.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
data: z.array(UserSubscriptionSchema),
|
data: UserSubscriptionSchema,
|
||||||
});
|
});
|
||||||
export type UserSubscriptionActionResponse = z.infer<
|
export type UserSubscriptionActionResponse = z.infer<
|
||||||
typeof UserSubscriptionActionResponseSchema
|
typeof UserSubscriptionActionResponseSchema
|
||||||
|
|||||||
79
src/hooks/retaining/useRetainingTracker.ts
Normal file
79
src/hooks/retaining/useRetainingTracker.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { useRetainingStore } from "@/providers/retaining-store-provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for tracking retaining funnel progress
|
||||||
|
* Handles:
|
||||||
|
* - Step tracking when navigating between pages
|
||||||
|
* - Browser back button handling
|
||||||
|
* - Journey state management
|
||||||
|
*/
|
||||||
|
export function useRetainingTracker() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { journey, updateCurrentStep, completeStep } = useRetainingStore(
|
||||||
|
state => state
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastPathnameRef = useRef<string>("");
|
||||||
|
|
||||||
|
// Map pathname to step names
|
||||||
|
const getStepFromPathname = (path: string): string => {
|
||||||
|
if (path.includes("/appreciate-choice")) return "appreciate-choice";
|
||||||
|
if (path.includes("/what-reason")) return "what-reason";
|
||||||
|
if (path.includes("/second-chance")) return "second-chance";
|
||||||
|
if (path.includes("/change-mind")) return "change-mind";
|
||||||
|
if (path.includes("/stop-for-30-days")) return "stop-for-30-days";
|
||||||
|
if (path.includes("/cancellation-of-subscription"))
|
||||||
|
return "cancellation-of-subscription";
|
||||||
|
if (path.includes("/plan-cancelled")) return "plan-cancelled";
|
||||||
|
if (path.includes("/subscription-stopped")) return "subscription-stopped";
|
||||||
|
if (path.includes("/stay-50-done")) return "stay-50-done";
|
||||||
|
return "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track step changes and handle browser navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname.includes("/retaining") || !journey) return;
|
||||||
|
|
||||||
|
const currentStep = getStepFromPathname(pathname);
|
||||||
|
const lastPathname = lastPathnameRef.current;
|
||||||
|
|
||||||
|
// Update current step if changed
|
||||||
|
if (currentStep !== "unknown" && currentStep !== journey.currentStep) {
|
||||||
|
updateCurrentStep(currentStep);
|
||||||
|
|
||||||
|
// Complete previous step if we moved forward
|
||||||
|
if (lastPathname && !lastPathname.includes(currentStep)) {
|
||||||
|
const previousStep = getStepFromPathname(lastPathname);
|
||||||
|
if (previousStep !== "unknown") {
|
||||||
|
completeStep(previousStep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPathnameRef.current = pathname;
|
||||||
|
}, [pathname, journey, updateCurrentStep, completeStep]);
|
||||||
|
|
||||||
|
// Get current journey summary for debugging
|
||||||
|
const getJourneySummary = () => {
|
||||||
|
if (!journey) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep: journey.currentStep,
|
||||||
|
totalChoices: journey.choices.length,
|
||||||
|
completedSteps: journey.completedSteps,
|
||||||
|
funnel: journey.funnel,
|
||||||
|
duration: Date.now() - journey.startedAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getJourneySummary,
|
||||||
|
isTracking: !!journey,
|
||||||
|
currentStep: journey?.currentStep,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -63,6 +63,8 @@ export const ROUTES = {
|
|||||||
createRoute([retainingFunnelPrefix, "plan-cancelled"]),
|
createRoute([retainingFunnelPrefix, "plan-cancelled"]),
|
||||||
retainingFunnelSubscriptionStopped: () =>
|
retainingFunnelSubscriptionStopped: () =>
|
||||||
createRoute([retainingFunnelPrefix, "subscription-stopped"]),
|
createRoute([retainingFunnelPrefix, "subscription-stopped"]),
|
||||||
|
retainingFunnelFreeChatActivated: () =>
|
||||||
|
createRoute([retainingFunnelPrefix, "free-chat-activated"]),
|
||||||
|
|
||||||
// Payment
|
// Payment
|
||||||
payment: (queryParams?: Record<string, string>) =>
|
payment: (queryParams?: Record<string, string>) =>
|
||||||
|
|||||||
@ -12,9 +12,26 @@ export enum ERetainingFunnel {
|
|||||||
Stay50 = "stay50",
|
Stay50 = "stay50",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RetainingChoice {
|
||||||
|
step: string;
|
||||||
|
choiceId: string | number;
|
||||||
|
choiceTitle: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetainingJourney {
|
||||||
|
startedAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
currentStep: string;
|
||||||
|
choices: RetainingChoice[];
|
||||||
|
funnel: ERetainingFunnel;
|
||||||
|
completedSteps: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface RetainingState {
|
interface RetainingState {
|
||||||
funnel: ERetainingFunnel;
|
funnel: ERetainingFunnel;
|
||||||
cancellingSubscription: UserSubscription | null;
|
cancellingSubscription: UserSubscription | null;
|
||||||
|
journey: RetainingJourney | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RetainingActions = {
|
export type RetainingActions = {
|
||||||
@ -25,6 +42,13 @@ export type RetainingActions = {
|
|||||||
cancellingSubscription: UserSubscription;
|
cancellingSubscription: UserSubscription;
|
||||||
}) => void;
|
}) => void;
|
||||||
clearRetainingData: () => void;
|
clearRetainingData: () => void;
|
||||||
|
|
||||||
|
// Journey tracking methods
|
||||||
|
startJourney: (subscriptionId: string) => void;
|
||||||
|
addChoice: (choice: Omit<RetainingChoice, "timestamp">) => void;
|
||||||
|
updateCurrentStep: (step: string) => void;
|
||||||
|
completeStep: (step: string) => void;
|
||||||
|
getJourneyData: () => RetainingJourney | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RetainingStore = RetainingState & RetainingActions;
|
export type RetainingStore = RetainingState & RetainingActions;
|
||||||
@ -32,6 +56,7 @@ export type RetainingStore = RetainingState & RetainingActions;
|
|||||||
const initialState: RetainingState = {
|
const initialState: RetainingState = {
|
||||||
funnel: ERetainingFunnel.Red,
|
funnel: ERetainingFunnel.Red,
|
||||||
cancellingSubscription: null,
|
cancellingSubscription: null,
|
||||||
|
journey: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRetainingStore = (
|
export const createRetainingStore = (
|
||||||
@ -39,7 +64,7 @@ export const createRetainingStore = (
|
|||||||
) => {
|
) => {
|
||||||
return createStore<RetainingStore>()(
|
return createStore<RetainingStore>()(
|
||||||
persist(
|
persist(
|
||||||
set => ({
|
(set, get) => ({
|
||||||
...initState,
|
...initState,
|
||||||
setFunnel: (funnel: ERetainingFunnel) => set({ funnel }),
|
setFunnel: (funnel: ERetainingFunnel) => set({ funnel }),
|
||||||
setCancellingSubscription: (cancellingSubscription: UserSubscription) =>
|
setCancellingSubscription: (cancellingSubscription: UserSubscription) =>
|
||||||
@ -49,6 +74,78 @@ export const createRetainingStore = (
|
|||||||
cancellingSubscription: UserSubscription;
|
cancellingSubscription: UserSubscription;
|
||||||
}) => set(data),
|
}) => set(data),
|
||||||
clearRetainingData: () => set(initialState),
|
clearRetainingData: () => set(initialState),
|
||||||
|
|
||||||
|
// Journey tracking methods
|
||||||
|
startJourney: (_subscriptionId: string) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const journey: RetainingJourney = {
|
||||||
|
startedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
currentStep: "appreciate-choice",
|
||||||
|
choices: [],
|
||||||
|
funnel: ERetainingFunnel.Red,
|
||||||
|
completedSteps: [],
|
||||||
|
};
|
||||||
|
set({ journey });
|
||||||
|
},
|
||||||
|
|
||||||
|
addChoice: (choice: Omit<RetainingChoice, "timestamp">) => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.journey) return;
|
||||||
|
const newChoice: RetainingChoice = {
|
||||||
|
...choice,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove any existing choice for the same step (user changed mind)
|
||||||
|
const filteredChoices = state.journey.choices.filter(
|
||||||
|
c => c.step !== choice.step
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedJourney: RetainingJourney = {
|
||||||
|
...state.journey,
|
||||||
|
choices: [...filteredChoices, newChoice],
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set({ journey: updatedJourney });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCurrentStep: (step: string) => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.journey) return;
|
||||||
|
|
||||||
|
const updatedJourney: RetainingJourney = {
|
||||||
|
...state.journey,
|
||||||
|
currentStep: step,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set({ journey: updatedJourney });
|
||||||
|
},
|
||||||
|
|
||||||
|
completeStep: (step: string) => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.journey) return;
|
||||||
|
|
||||||
|
const completedSteps = [...state.journey.completedSteps];
|
||||||
|
if (!completedSteps.includes(step)) {
|
||||||
|
completedSteps.push(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedJourney: RetainingJourney = {
|
||||||
|
...state.journey,
|
||||||
|
completedSteps,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set({ journey: updatedJourney });
|
||||||
|
},
|
||||||
|
|
||||||
|
getJourneyData: () => {
|
||||||
|
const state = get();
|
||||||
|
return state.journey;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{ name: "retaining-storage" }
|
{ name: "retaining-storage" }
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user