commit
60cabbaa59
@ -177,7 +177,7 @@
|
|||||||
"PlanCancelled": {
|
"PlanCancelled": {
|
||||||
"title": "Standard plan cancelled!",
|
"title": "Standard plan cancelled!",
|
||||||
"icon": "🥳",
|
"icon": "🥳",
|
||||||
"description": "Completed transition to a free 30-day plan",
|
"description": "Your subscription has been successfully cancelled",
|
||||||
"button": "Done"
|
"button": "Done"
|
||||||
},
|
},
|
||||||
"SubscriptionStopped": {
|
"SubscriptionStopped": {
|
||||||
|
|||||||
@ -177,12 +177,19 @@
|
|||||||
"PlanCancelled": {
|
"PlanCancelled": {
|
||||||
"title": "Standard plan cancelled!",
|
"title": "Standard plan cancelled!",
|
||||||
"icon": "🥳",
|
"icon": "🥳",
|
||||||
"description": "Completed transition to a free 30-day plan",
|
"description": "Your subscription has been successfully cancelled",
|
||||||
"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",
|
||||||
|
|||||||
@ -177,7 +177,7 @@
|
|||||||
"PlanCancelled": {
|
"PlanCancelled": {
|
||||||
"title": "Standard plan cancelled!",
|
"title": "Standard plan cancelled!",
|
||||||
"icon": "🥳",
|
"icon": "🥳",
|
||||||
"description": "Completed transition to a free 30-day plan",
|
"description": "Your subscription has been successfully cancelled",
|
||||||
"button": "Done"
|
"button": "Done"
|
||||||
},
|
},
|
||||||
"SubscriptionStopped": {
|
"SubscriptionStopped": {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -5,9 +5,9 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
import { Skeleton } from "@/components/ui";
|
import { Skeleton } from "@/components/ui";
|
||||||
import {
|
import {
|
||||||
getMyChatSettings,
|
fetchMyChatSettings,
|
||||||
updateMyChatSettings,
|
updateChatSettings,
|
||||||
} from "@/entities/chats/chatSettings.api";
|
} from "@/entities/chats/actions";
|
||||||
import type { IChatSettings } from "@/entities/chats/types";
|
import type { IChatSettings } from "@/entities/chats/types";
|
||||||
|
|
||||||
import styles from "./AutoTopUpToggle.module.scss";
|
import styles from "./AutoTopUpToggle.module.scss";
|
||||||
@ -22,9 +22,9 @@ export default function AutoTopUpToggle() {
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getMyChatSettings();
|
const res = await fetchMyChatSettings();
|
||||||
if (mounted) {
|
if (mounted && res.data) {
|
||||||
setSettings(res.settings);
|
setSettings(res.data.settings);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// silent failure
|
// silent failure
|
||||||
@ -45,8 +45,10 @@ export default function AutoTopUpToggle() {
|
|||||||
setSettings(next); // optimistic
|
setSettings(next); // optimistic
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
try {
|
try {
|
||||||
const res = await updateMyChatSettings(next);
|
const res = await updateChatSettings(next);
|
||||||
setSettings(res.settings);
|
if (res.data) {
|
||||||
|
setSettings(res.data.settings);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// revert on error silently
|
// revert on error silently
|
||||||
setSettings(settings);
|
setSettings(settings);
|
||||||
|
|||||||
@ -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({
|
||||||
@ -62,13 +69,30 @@ export default function SecondChancePage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelClick = () => {
|
const handleCancelClick = async () => {
|
||||||
if (isLoadingButton) return;
|
if (isLoadingButton) return;
|
||||||
if (funnel === ERetainingFunnel.Red) {
|
if (funnel === ERetainingFunnel.Red) {
|
||||||
router.push(ROUTES.retainingFunnelChangeMind());
|
router.push(ROUTES.retainingFunnelChangeMind());
|
||||||
}
|
}
|
||||||
if (funnel === ERetainingFunnel.Green) {
|
if (funnel === ERetainingFunnel.Green) {
|
||||||
return router.push(ROUTES.retainingFunnelCancellationOfSubscription());
|
// Direct cancellation instead of 60-day pause screen
|
||||||
|
setIsLoadingButton(true);
|
||||||
|
const response = await performUserSubscriptionAction({
|
||||||
|
subscriptionId: cancellingSubscription?.id || "",
|
||||||
|
action: "cancel",
|
||||||
|
retainingJourney: journey || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.data?.status === "success") {
|
||||||
|
return router.push(ROUTES.retainingFunnelPlanCancelled());
|
||||||
|
}
|
||||||
|
setIsLoadingButton(false);
|
||||||
|
addToast({
|
||||||
|
variant: "error",
|
||||||
|
message: "Something went wrong. Please try again later.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (funnel === ERetainingFunnel.Purple) {
|
if (funnel === ERetainingFunnel.Purple) {
|
||||||
return router.push(ROUTES.retainingFunnelStopFor30Days());
|
return router.push(ROUTES.retainingFunnelStopFor30Days());
|
||||||
|
|||||||
@ -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") {
|
||||||
@ -51,12 +54,29 @@ export default function Buttons() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelClick = () => {
|
const handleCancelClick = async () => {
|
||||||
if (isLoadingButton) return;
|
if (isLoadingButton) return;
|
||||||
if (funnel === ERetainingFunnel.Green) {
|
if (funnel === ERetainingFunnel.Green) {
|
||||||
return router.push(ROUTES.retainingFunnelChangeMind());
|
return router.push(ROUTES.retainingFunnelChangeMind());
|
||||||
}
|
}
|
||||||
router.push(ROUTES.retainingFunnelCancellationOfSubscription());
|
|
||||||
|
// Direct cancellation instead of 60-day pause screen
|
||||||
|
setIsLoadingButton(true);
|
||||||
|
const response = await performUserSubscriptionAction({
|
||||||
|
subscriptionId: cancellingSubscription?.id || "",
|
||||||
|
action: "cancel",
|
||||||
|
retainingJourney: journey || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.data?.status === "success") {
|
||||||
|
return router.push(ROUTES.retainingFunnelPlanCancelled());
|
||||||
|
}
|
||||||
|
setIsLoadingButton(false);
|
||||||
|
addToast({
|
||||||
|
variant: "error",
|
||||||
|
message: "Something went wrong. Please try again later.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
7
src/entities/balance/actions.ts
Normal file
7
src/entities/balance/actions.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getUserBalance } from "./api";
|
||||||
|
|
||||||
|
export async function fetchUserBalance() {
|
||||||
|
return getUserBalance();
|
||||||
|
}
|
||||||
@ -1,39 +1,16 @@
|
|||||||
"use client";
|
import { http } from "@/shared/api/httpClient";
|
||||||
|
|
||||||
import { getClientAccessToken } from "@/shared/auth/clientToken";
|
|
||||||
import { API_ROUTES } from "@/shared/constants/api-routes";
|
import { API_ROUTES } from "@/shared/constants/api-routes";
|
||||||
|
|
||||||
import { IUserBalanceResponse, UserBalanceSchema } from "./types";
|
import { IUserBalanceResponse, UserBalanceSchema } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the current user balance using client-side authentication
|
* Fetches the current user balance (server-side with httpOnly cookie access)
|
||||||
* @returns Promise with user balance information
|
* @returns Promise with user balance information
|
||||||
*/
|
*/
|
||||||
export const getUserBalance = async (): Promise<IUserBalanceResponse> => {
|
export const getUserBalance = async (): Promise<IUserBalanceResponse> => {
|
||||||
const accessToken = getClientAccessToken();
|
return http.get<IUserBalanceResponse>(API_ROUTES.getUserBalance(), {
|
||||||
if (!accessToken) {
|
tags: ["balance"],
|
||||||
throw new Error("No access token available");
|
schema: UserBalanceSchema,
|
||||||
}
|
revalidate: 0,
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
if (!apiUrl) {
|
|
||||||
throw new Error("API URL not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(API_ROUTES.getUserBalance(), apiUrl);
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch balance: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return UserBalanceSchema.parse(data);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,11 +6,15 @@ import { http } from "@/shared/api/httpClient";
|
|||||||
import { API_ROUTES } from "@/shared/constants/api-routes";
|
import { API_ROUTES } from "@/shared/constants/api-routes";
|
||||||
import { ActionResponse } from "@/types";
|
import { ActionResponse } from "@/types";
|
||||||
|
|
||||||
|
import { getMyChatSettings, updateMyChatSettings } from "./chatSettings.api";
|
||||||
import {
|
import {
|
||||||
CreateAllChatsResponseSchema,
|
CreateAllChatsResponseSchema,
|
||||||
GetChatMessagesResponseSchema,
|
GetChatMessagesResponseSchema,
|
||||||
|
IChatSettings,
|
||||||
ICreateAllChatsResponse,
|
ICreateAllChatsResponse,
|
||||||
IGetChatMessagesResponse,
|
IGetChatMessagesResponse,
|
||||||
|
IGetMyChatSettingsResponse,
|
||||||
|
IUpdateMyChatSettingsResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export async function createAllChats(): Promise<
|
export async function createAllChats(): Promise<
|
||||||
@ -60,6 +64,38 @@ export async function fetchChatMessages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchMyChatSettings(): Promise<
|
||||||
|
ActionResponse<IGetMyChatSettingsResponse>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const response = await getMyChatSettings();
|
||||||
|
return { data: response, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Failed to fetch chat settings:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Something went wrong.";
|
||||||
|
return { data: null, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChatSettings(
|
||||||
|
settings: IChatSettings
|
||||||
|
): Promise<ActionResponse<IUpdateMyChatSettingsResponse>> {
|
||||||
|
try {
|
||||||
|
const response = await updateMyChatSettings(settings);
|
||||||
|
revalidateTag("profile");
|
||||||
|
revalidateTag("chat-settings");
|
||||||
|
return { data: response, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Failed to update chat settings:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Something went wrong.";
|
||||||
|
return { data: null, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function revalidateChatsPage() {
|
export async function revalidateChatsPage() {
|
||||||
revalidateTag("chats-list");
|
revalidateTag("chats-list");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { http } from "@/shared/api/httpClient";
|
import { http } from "@/shared/api/httpClient";
|
||||||
import { API_ROUTES } from "@/shared/constants/api-routes";
|
import { API_ROUTES } from "@/shared/constants/api-routes";
|
||||||
|
|
||||||
@ -12,7 +10,7 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch current user's chat settings (client-side)
|
* Fetch current user's chat settings (server-side with httpOnly cookie access)
|
||||||
*/
|
*/
|
||||||
export const getMyChatSettings =
|
export const getMyChatSettings =
|
||||||
async (): Promise<IGetMyChatSettingsResponse> => {
|
async (): Promise<IGetMyChatSettingsResponse> => {
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getUserBalance } from "@/entities/balance/api";
|
import { fetchUserBalance } from "@/entities/balance/actions";
|
||||||
|
|
||||||
export const useUserBalance = () => {
|
export const useUserBalance = () => {
|
||||||
const [balance, setBalance] = useState<number | null>(null);
|
const [balance, setBalance] = useState<number | null>(null);
|
||||||
@ -13,17 +13,12 @@ export const useUserBalance = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await getUserBalance();
|
const response = await fetchUserBalance();
|
||||||
setBalance(response.balance);
|
setBalance(response.balance);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err : new Error("Failed to fetch balance")
|
err instanceof Error ? err : new Error("Failed to fetch balance")
|
||||||
);
|
);
|
||||||
// Используем devLogger или другой механизм логирования в продакшене
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Error fetching user balance:", err);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -13,5 +13,7 @@ export function getClientAccessToken(): string | undefined {
|
|||||||
cookie.trim().startsWith("accessToken=")
|
cookie.trim().startsWith("accessToken=")
|
||||||
);
|
);
|
||||||
|
|
||||||
return accessTokenCookie?.split("=")[1];
|
if (!accessTokenCookie) return undefined;
|
||||||
|
const token = accessTokenCookie.trim().substring("accessToken=".length);
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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