Merge pull request #52 from pennyteenycat/feature/cancelFunnel

Вернул воронку отмены
This commit is contained in:
pennyteenycat 2025-09-12 21:47:26 +02:00 committed by GitHub
commit dead22cb50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 414 additions and 65 deletions

View File

@ -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",

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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>

View File

@ -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") {

View File

@ -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>
)}
</> </>
); );
} }

View File

@ -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());

View File

@ -0,0 +1,5 @@
.button {
width: 100%;
max-width: 280px;
margin-top: 20px;
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export { default as FreeChatActivatedButton } from "./Button/Button";

View File

@ -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";

View File

@ -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({

View File

@ -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") {

View File

@ -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);

View File

@ -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 => {

View 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;
}

View File

@ -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

View 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,
};
}

View File

@ -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>) =>

View File

@ -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" }
) )