Merge pull request #56 from pennyteenycat/develop

Develop
This commit is contained in:
pennyteenycat 2025-09-13 23:15:53 +02:00 committed by GitHub
commit 60cabbaa59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 520 additions and 120 deletions

View File

@ -177,7 +177,7 @@
"PlanCancelled": {
"title": "Standard plan cancelled!",
"icon": "🥳",
"description": "Completed transition to a free 30-day plan",
"description": "Your subscription has been successfully cancelled",
"button": "Done"
},
"SubscriptionStopped": {

View File

@ -177,12 +177,19 @@
"PlanCancelled": {
"title": "Standard plan cancelled!",
"icon": "🥳",
"description": "Completed transition to a free 30-day plan",
"description": "Your subscription has been successfully cancelled",
"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",

View File

@ -177,7 +177,7 @@
"PlanCancelled": {
"title": "Standard plan cancelled!",
"icon": "🥳",
"description": "Completed transition to a free 30-day plan",
"description": "Your subscription has been successfully cancelled",
"button": "Done"
},
"SubscriptionStopped": {

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

View File

@ -5,9 +5,9 @@ import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui";
import {
getMyChatSettings,
updateMyChatSettings,
} from "@/entities/chats/chatSettings.api";
fetchMyChatSettings,
updateChatSettings,
} from "@/entities/chats/actions";
import type { IChatSettings } from "@/entities/chats/types";
import styles from "./AutoTopUpToggle.module.scss";
@ -22,9 +22,9 @@ export default function AutoTopUpToggle() {
let mounted = true;
(async () => {
try {
const res = await getMyChatSettings();
if (mounted) {
setSettings(res.settings);
const res = await fetchMyChatSettings();
if (mounted && res.data) {
setSettings(res.data.settings);
}
} catch (e) {
// silent failure
@ -45,8 +45,10 @@ export default function AutoTopUpToggle() {
setSettings(next); // optimistic
setIsUpdating(true);
try {
const res = await updateMyChatSettings(next);
setSettings(res.settings);
const res = await updateChatSettings(next);
if (res.data) {
setSettings(res.data.settings);
}
} catch (e) {
// revert on error silently
setSettings(settings);

View File

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

View File

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

View File

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

View File

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

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 CheckMark } from "./CheckMark/CheckMark";
export * from "./free-chat-activated";
export { default as Offer } from "./Offer/Offer";
export { default as RetainingStepper } from "./RetainingStepper/RetainingStepper";

View File

@ -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({
@ -62,13 +69,30 @@ export default function SecondChancePage() {
});
};
const handleCancelClick = () => {
const handleCancelClick = async () => {
if (isLoadingButton) return;
if (funnel === ERetainingFunnel.Red) {
router.push(ROUTES.retainingFunnelChangeMind());
}
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) {
return router.push(ROUTES.retainingFunnelStopFor30Days());

View File

@ -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") {
@ -51,12 +54,29 @@ export default function Buttons() {
});
};
const handleCancelClick = () => {
const handleCancelClick = async () => {
if (isLoadingButton) return;
if (funnel === ERetainingFunnel.Green) {
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 (

View File

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

View File

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

View File

@ -0,0 +1,7 @@
"use server";
import { getUserBalance } from "./api";
export async function fetchUserBalance() {
return getUserBalance();
}

View File

@ -1,39 +1,16 @@
"use client";
import { getClientAccessToken } from "@/shared/auth/clientToken";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
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
*/
export const getUserBalance = async (): Promise<IUserBalanceResponse> => {
const accessToken = getClientAccessToken();
if (!accessToken) {
throw new Error("No access token available");
}
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",
},
return http.get<IUserBalanceResponse>(API_ROUTES.getUserBalance(), {
tags: ["balance"],
schema: UserBalanceSchema,
revalidate: 0,
});
if (!response.ok) {
throw new Error(`Failed to fetch balance: ${response.status}`);
}
const data = await response.json();
return UserBalanceSchema.parse(data);
};

View File

@ -6,11 +6,15 @@ import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import { ActionResponse } from "@/types";
import { getMyChatSettings, updateMyChatSettings } from "./chatSettings.api";
import {
CreateAllChatsResponseSchema,
GetChatMessagesResponseSchema,
IChatSettings,
ICreateAllChatsResponse,
IGetChatMessagesResponse,
IGetMyChatSettingsResponse,
IUpdateMyChatSettingsResponse,
} from "./types";
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() {
revalidateTag("chats-list");
}

View File

@ -1,5 +1,3 @@
"use client";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
@ -12,7 +10,7 @@ import {
} 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 =
async (): Promise<IGetMyChatSettingsResponse> => {

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

View File

@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { getUserBalance } from "@/entities/balance/api";
import { fetchUserBalance } from "@/entities/balance/actions";
export const useUserBalance = () => {
const [balance, setBalance] = useState<number | null>(null);
@ -13,17 +13,12 @@ export const useUserBalance = () => {
setIsLoading(true);
setError(null);
try {
const response = await getUserBalance();
const response = await fetchUserBalance();
setBalance(response.balance);
} catch (err) {
setError(
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 {
setIsLoading(false);
}

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

@ -13,5 +13,7 @@ export function getClientAccessToken(): string | undefined {
cookie.trim().startsWith("accessToken=")
);
return accessTokenCookie?.split("=")[1];
if (!accessTokenCookie) return undefined;
const token = accessTokenCookie.trim().substring("accessToken=".length);
return token;
}

View File

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

View File

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