Merge pull request #52 from pennyteenycat/feature/cancelFunnel
Вернул воронку отмены
This commit is contained in:
commit
dead22cb50
@ -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",
|
||||
|
||||
@ -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() {
|
||||
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);
|
||||
|
||||
@ -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<boolean>(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({
|
||||
</Typography>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
className={styles.action}
|
||||
onClick={handleCancel}
|
||||
disabled={isLoadingCancelButton}
|
||||
>
|
||||
<Button className={styles.action} onClick={handleCancel}>
|
||||
{t("modal.cancel_button")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStay}
|
||||
className={styles.action}
|
||||
disabled={isLoadingCancelButton}
|
||||
>
|
||||
<Button onClick={handleStay} className={styles.action}>
|
||||
{t("modal.stay_button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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<boolean>(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")}
|
||||
</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>(
|
||||
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());
|
||||
|
||||
@ -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 CheckMark } from "./CheckMark/CheckMark";
|
||||
export * from "./free-chat-activated";
|
||||
export { default as Offer } from "./Offer/Offer";
|
||||
export { default as RetainingStepper } from "./RetainingStepper/RetainingStepper";
|
||||
|
||||
@ -35,7 +35,9 @@ export default function SecondChancePage() {
|
||||
"pause_30"
|
||||
);
|
||||
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") => {
|
||||
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({
|
||||
|
||||
@ -29,7 +29,9 @@ export default function Buttons() {
|
||||
|
||||
const [isLoadingButton, setIsLoadingButton] = useState<boolean>(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") {
|
||||
|
||||
@ -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<WhatReasonAnswer | null>(
|
||||
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);
|
||||
|
||||
@ -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 (
|
||||
<nav className={styles.container}>
|
||||
{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 { ERetainingFunnel } from "@/stores/retaining-store";
|
||||
import { Currency } from "@/types";
|
||||
|
||||
export const SubscriptionTypeEnum = z.enum(["DAY", "WEEK", "MONTH", "YEAR"]);
|
||||
@ -45,6 +46,23 @@ export type UserSubscriptionActionEnum = z.infer<
|
||||
export const UserSubscriptionActionPayloadSchema = z.object({
|
||||
subscriptionId: z.string(),
|
||||
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<
|
||||
typeof UserSubscriptionActionPayloadSchema
|
||||
@ -52,7 +70,7 @@ export type UserSubscriptionActionPayload = z.infer<
|
||||
|
||||
export const UserSubscriptionActionResponseSchema = z.object({
|
||||
status: z.string(),
|
||||
data: z.array(UserSubscriptionSchema),
|
||||
data: UserSubscriptionSchema,
|
||||
});
|
||||
export type UserSubscriptionActionResponse = z.infer<
|
||||
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"]),
|
||||
retainingFunnelSubscriptionStopped: () =>
|
||||
createRoute([retainingFunnelPrefix, "subscription-stopped"]),
|
||||
retainingFunnelFreeChatActivated: () =>
|
||||
createRoute([retainingFunnelPrefix, "free-chat-activated"]),
|
||||
|
||||
// Payment
|
||||
payment: (queryParams?: Record<string, string>) =>
|
||||
|
||||
@ -12,9 +12,26 @@ export enum ERetainingFunnel {
|
||||
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 {
|
||||
funnel: ERetainingFunnel;
|
||||
cancellingSubscription: UserSubscription | null;
|
||||
journey: RetainingJourney | null;
|
||||
}
|
||||
|
||||
export type RetainingActions = {
|
||||
@ -25,6 +42,13 @@ export type RetainingActions = {
|
||||
cancellingSubscription: UserSubscription;
|
||||
}) => 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;
|
||||
@ -32,6 +56,7 @@ export type RetainingStore = RetainingState & RetainingActions;
|
||||
const initialState: RetainingState = {
|
||||
funnel: ERetainingFunnel.Red,
|
||||
cancellingSubscription: null,
|
||||
journey: null,
|
||||
};
|
||||
|
||||
export const createRetainingStore = (
|
||||
@ -39,7 +64,7 @@ export const createRetainingStore = (
|
||||
) => {
|
||||
return createStore<RetainingStore>()(
|
||||
persist(
|
||||
set => ({
|
||||
(set, get) => ({
|
||||
...initState,
|
||||
setFunnel: (funnel: ERetainingFunnel) => set({ funnel }),
|
||||
setCancellingSubscription: (cancellingSubscription: UserSubscription) =>
|
||||
@ -49,6 +74,78 @@ export const createRetainingStore = (
|
||||
cancellingSubscription: UserSubscription;
|
||||
}) => set(data),
|
||||
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" }
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user