diff --git a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx index 531983e..94d3f24 100644 --- a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx +++ b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx @@ -6,6 +6,7 @@ import { Button, Spinner, Typography } from "@/components/ui"; import { BlurComponent } from "@/components/widgets"; import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout"; import { useToast } from "@/providers/toast-provider"; +import { ROUTES } from "@/shared/constants/client-routes"; import { useMultiPageNavigationContext } from ".."; @@ -30,6 +31,10 @@ export default function AddConsultantButton() { duration: 5000, }); }, + returnUrl: new URL( + navigation.getNextPageUrl() || ROUTES.home(), + process.env.NEXT_PUBLIC_APP_URL || "" + ).toString(), }); const handleGetConsultation = () => { diff --git a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx index 018942d..3e0a305 100644 --- a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx +++ b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx @@ -6,6 +6,7 @@ import { Button, Spinner, Typography } from "@/components/ui"; import { BlurComponent } from "@/components/widgets"; import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout"; import { useToast } from "@/providers/toast-provider"; +import { ROUTES } from "@/shared/constants/client-routes"; import { useMultiPageNavigationContext } from ".."; import { useProductSelection } from "../ProductSelectionProvider"; @@ -29,6 +30,10 @@ export default function AddGuidesButton() { duration: 5000, }); }, + returnUrl: new URL( + navigation.getNextPageUrl() || ROUTES.home(), + process.env.NEXT_PUBLIC_APP_URL || "" + ).toString(), }); const handlePurchase = () => { diff --git a/src/components/domains/additional-purchases/MultiPageNavigationProvider.tsx b/src/components/domains/additional-purchases/MultiPageNavigationProvider.tsx index 108f6f2..6186261 100644 --- a/src/components/domains/additional-purchases/MultiPageNavigationProvider.tsx +++ b/src/components/domains/additional-purchases/MultiPageNavigationProvider.tsx @@ -34,9 +34,10 @@ export function MultiPageNavigationProvider({ data, currentType, getTypeFromItem: item => item.type ?? "", - navigateToItemByType: type => { - router.push(ROUTES.additionalPurchases(type)); - }, + // navigateToItemByType: type => { + // router.push(ROUTES.additionalPurchases(type)); + // }, + getPageUrlByItem: item => ROUTES.additionalPurchases(item.type ?? ""), onComplete: () => { router.push(ROUTES.home()); }, diff --git a/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx index 9063c4a..16d38f6 100644 --- a/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx +++ b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx @@ -5,7 +5,6 @@ import { useTranslations } from "next-intl"; import { Button, Spinner, Typography } from "@/components/ui"; import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout"; -import { useChatStore } from "@/providers/chat-store-provider"; import { IRefillModals, IRefillModalsProduct } from "@/services/socket/events"; import BenefitsList from "../BenefitsList/BenefitsList"; @@ -29,11 +28,10 @@ export default function RefillOptionsModal({ }: RefillOptionsModalProps) { const t = useTranslations("RefillOptionsModal"); - const isAutoTopUp = useChatStore(state => state.isAutoTopUp); - const { handleSingleCheckout, isLoading } = useSingleCheckout({ onSuccess: onPaymentSuccess, onError: onPaymentError, + returnUrl: typeof window !== "undefined" ? window.location.href : "", }); const [selectedOption, setSelectedOption] = useState( @@ -45,7 +43,6 @@ export default function RefillOptionsModal({ handleSingleCheckout({ productId: selectedOption.id, key: selectedOption.key, - isAutoTopUp, }); }; diff --git a/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx index f19f0da..ab5b977 100644 --- a/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx +++ b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx @@ -37,6 +37,7 @@ export default function RefillTimerModal({ const { handleSingleCheckout, isLoading } = useSingleCheckout({ onSuccess: onPaymentSuccess, onError: onPaymentError, + returnUrl: typeof window !== "undefined" ? window.location.href : "", }); const { seconds, isFinished } = useTimer({ diff --git a/src/components/domains/chat/index.ts b/src/components/domains/chat/index.ts index 6bef8bf..905530e 100644 --- a/src/components/domains/chat/index.ts +++ b/src/components/domains/chat/index.ts @@ -25,7 +25,10 @@ export { export { default as RefillOptionsModal } from "./CreditsModals/RefillOptionsModal/RefillOptionsModal"; export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal"; export { default as GlobalNewMessagesBanner } from "./GlobalNewMessagesBanner/GlobalNewMessagesBanner"; -export { default as LastMessagePreview, type LastMessagePreviewProps } from "./LastMessagePreview/LastMessagePreview"; +export { + default as LastMessagePreview, + type LastMessagePreviewProps, +} from "./LastMessagePreview/LastMessagePreview"; export { default as MessageInput } from "./MessageInput/MessageInput"; export { default as MessageInputWrapper } from "./MessageInputWrapper/MessageInputWrapper"; export { default as NewMessages } from "./NewMessages/NewMessages"; diff --git a/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.tsx b/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.tsx index a3296ba..d806e54 100644 --- a/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.tsx +++ b/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.tsx @@ -4,7 +4,10 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { Skeleton } from "@/components/ui"; -import { getMyChatSettings, updateMyChatSettings } from "@/entities/chats/chatSettings.api"; +import { + getMyChatSettings, + updateMyChatSettings, +} from "@/entities/chats/chatSettings.api"; import type { IChatSettings } from "@/entities/chats/types"; import styles from "./AutoTopUpToggle.module.scss"; diff --git a/src/entities/chats/chatSettings.api.ts b/src/entities/chats/chatSettings.api.ts index 1ea826a..9da8486 100644 --- a/src/entities/chats/chatSettings.api.ts +++ b/src/entities/chats/chatSettings.api.ts @@ -14,16 +14,17 @@ import { /** * Fetch current user's chat settings (client-side) */ -export const getMyChatSettings = async (): Promise => { - return http.get( - API_ROUTES.getMyChatSettings(), - { - tags: ["profile", "chat-settings"], - schema: GetMyChatSettingsResponseSchema, - revalidate: 0, - } - ); -}; +export const getMyChatSettings = + async (): Promise => { + return http.get( + API_ROUTES.getMyChatSettings(), + { + tags: ["profile", "chat-settings"], + schema: GetMyChatSettingsResponseSchema, + revalidate: 0, + } + ); + }; /** * Update current user's chat settings (client-side) @@ -41,4 +42,3 @@ export const updateMyChatSettings = async ( } ); }; - diff --git a/src/entities/chats/types.ts b/src/entities/chats/types.ts index 5a8cfbd..323111f 100644 --- a/src/entities/chats/types.ts +++ b/src/entities/chats/types.ts @@ -97,4 +97,3 @@ export { GetMyChatSettingsResponseSchema, UpdateMyChatSettingsResponseSchema, }; - diff --git a/src/entities/payment/types.ts b/src/entities/payment/types.ts index e4c097f..314ee97 100644 --- a/src/entities/payment/types.ts +++ b/src/entities/payment/types.ts @@ -24,6 +24,7 @@ export type PaymentInfo = z.infer; export const SingleCheckoutRequestSchema = z.object({ paymentInfo: PaymentInfoSchema, return_url: z.string().optional(), + pageUrl: z.string().optional(), }); export type SingleCheckoutRequest = z.infer; @@ -31,6 +32,7 @@ export const SingleCheckoutSuccessSchema = z.object({ payment: z.object({ status: z.string(), invoiceId: z.string(), + paymentUrl: z.string().url().optional(), }), }); export type SingleCheckoutSuccess = z.infer; diff --git a/src/hooks/chats/useChatSocket.ts b/src/hooks/chats/useChatSocket.ts index e723ea2..394ff6a 100644 --- a/src/hooks/chats/useChatSocket.ts +++ b/src/hooks/chats/useChatSocket.ts @@ -113,19 +113,29 @@ export const useChatSocket = ( emit("fetch_balance", { chatId }); }, [emit, chatId]); - // Auto top-up: use existing single checkout flow - const { handleSingleCheckout, isLoading: isAutoTopUpLoading } = useSingleCheckout({ - onSuccess: fetchBalance, - onError: () => { - // eslint-disable-next-line no-console - console.error("Auto top-up payment failed"); - // Release in-flight lock on error so a future event can retry - autoTopUpInProgressRef.current = false; - }, - }); - // Auto top-up: silent flow (no UI prompt) const autoTopUpInProgressRef = useRef(false); + // Timer for delayed unlock after error + const autoTopUpUnlockTimerRef = useRef(null); + + // Auto top-up: use existing single checkout flow + const { handleSingleCheckout, isLoading: isAutoTopUpLoading } = + useSingleCheckout({ + onSuccess: fetchBalance, + onError: () => { + // eslint-disable-next-line no-console + console.error("Auto top-up payment failed"); + // Throttle retries: keep the lock for 30 seconds after an error + if (autoTopUpUnlockTimerRef.current) { + clearTimeout(autoTopUpUnlockTimerRef.current); + } + autoTopUpUnlockTimerRef.current = setTimeout(() => { + autoTopUpInProgressRef.current = false; + autoTopUpUnlockTimerRef.current = null; + }, 30_000); + }, + returnUrl: typeof window !== "undefined" ? window.location.href : "", + }); const balancePollId = useRef(null); // Avoid immediate leave_chat right after join in React 18 StrictMode (dev) double-invoke @@ -201,12 +211,20 @@ export const useChatSocket = ( // If auto top-up was in-flight, release the lock only after balance became positive if (autoTopUpInProgressRef.current && b?.data?.balance > 0) { autoTopUpInProgressRef.current = false; + if (autoTopUpUnlockTimerRef.current) { + clearTimeout(autoTopUpUnlockTimerRef.current); + autoTopUpUnlockTimerRef.current = null; + } } }); useSocketEvent("balance_updated", b => { setBalance(prev => (prev ? { ...prev, balance: b.data.balance } : null)); if (autoTopUpInProgressRef.current && b?.data?.balance > 0) { autoTopUpInProgressRef.current = false; + if (autoTopUpUnlockTimerRef.current) { + clearTimeout(autoTopUpUnlockTimerRef.current); + autoTopUpUnlockTimerRef.current = null; + } } }); useSocketEvent("session_started", s => setSession(s.data)); @@ -222,7 +240,6 @@ export const useChatSocket = ( handleSingleCheckout(r.data); }); - useEffect(() => { if (!session?.maxFinishedAt) return; @@ -309,6 +326,16 @@ export const useChatSocket = ( }; }, [session, endSession]); + // Cleanup pending unlock timer on unmount + useEffect(() => { + return () => { + if (autoTopUpUnlockTimerRef.current) { + clearTimeout(autoTopUpUnlockTimerRef.current); + autoTopUpUnlockTimerRef.current = null; + } + }; + }, []); + const isAvailableChatting = !!balance?.balance && !!session && !isSessionExpired; diff --git a/src/hooks/multiPages/useMultiPageNavigation.ts b/src/hooks/multiPages/useMultiPageNavigation.ts index 71ec504..c1680b8 100644 --- a/src/hooks/multiPages/useMultiPageNavigation.ts +++ b/src/hooks/multiPages/useMultiPageNavigation.ts @@ -1,12 +1,14 @@ "use client"; import { useCallback, useMemo } from "react"; +import { useRouter } from "next/navigation"; interface PageNavigationOptions { data: T[]; currentType: string; getTypeFromItem: (item: T) => string; - navigateToItemByType: (type: string) => void; + // navigateToItemByType: (type: string) => void; + getPageUrlByItem: (item: T) => string; onBeforeNext?: (nextItem: T) => boolean | Promise; onBeforePrevious?: (prevItem: T) => boolean | Promise; onComplete: () => void; @@ -25,6 +27,7 @@ interface PageNavigationReturn { goToFirst: () => Promise; goToLast: () => Promise; goToIndex: (index: number) => Promise; + getNextPageUrl: () => string | undefined; totalPages: number; } @@ -32,18 +35,22 @@ export function useMultiPageNavigation({ data, currentType, getTypeFromItem, - navigateToItemByType, + // navigateToItemByType, + getPageUrlByItem, onBeforeNext, onBeforePrevious, onComplete, onStart, }: PageNavigationOptions): PageNavigationReturn { + const router = useRouter(); + const currentIndex = useMemo( () => data.findIndex(item => getTypeFromItem(item) === currentType), [data, currentType, getTypeFromItem] ); const currentItem = useMemo(() => data[currentIndex], [data, currentIndex]); + const nextItem = useMemo(() => data[currentIndex + 1], [data, currentIndex]); const isFirst = currentIndex === 0; const isLast = currentIndex === data.length - 1; @@ -53,10 +60,11 @@ export function useMultiPageNavigation({ const navigateToItem = useCallback( async (item: T) => { - const type = getTypeFromItem(item); - navigateToItemByType(type); + // const type = getTypeFromItem(item); + // navigateToItemByType(type); + router.push(getPageUrlByItem(item)); }, - [navigateToItemByType, getTypeFromItem] + [getPageUrlByItem, router] ); const goToNext = useCallback(async () => { @@ -109,9 +117,15 @@ export function useMultiPageNavigation({ [data, currentIndex, navigateToItem] ); + const getNextPageUrl = useCallback(() => { + if (!hasNext || !nextItem) return; + return getPageUrlByItem(nextItem); + }, [getPageUrlByItem, hasNext, nextItem]); + return useMemo( () => ({ currentItem, + nextItem, currentIndex, isFirst, isLast, @@ -122,10 +136,13 @@ export function useMultiPageNavigation({ goToFirst, goToLast, goToIndex, + getTypeFromItem, + getNextPageUrl, totalPages, }), [ currentItem, + nextItem, currentIndex, isFirst, isLast, @@ -136,6 +153,8 @@ export function useMultiPageNavigation({ goToFirst, goToLast, goToIndex, + getTypeFromItem, + getNextPageUrl, totalPages, ] ); diff --git a/src/hooks/payment/useSingleCheckout.ts b/src/hooks/payment/useSingleCheckout.ts index 72295f7..21b89a5 100644 --- a/src/hooks/payment/useSingleCheckout.ts +++ b/src/hooks/payment/useSingleCheckout.ts @@ -6,6 +6,7 @@ import { performSingleCheckout } from "@/entities/payment/actions"; import { PaymentInfo, SingleCheckoutRequest } from "@/entities/payment/types"; interface UseSingleCheckoutOptions { + returnUrl?: string; onSuccess?: () => void; onError?: (error: string) => void; } @@ -13,7 +14,7 @@ interface UseSingleCheckoutOptions { export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) { const [isLoading, setIsLoading] = useState(false); - const { onSuccess, onError } = options; + const { returnUrl, onSuccess, onError } = options; const handleSingleCheckout = useCallback( async (paymentInfo: PaymentInfo) => { @@ -24,6 +25,8 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) { try { const payload: SingleCheckoutRequest = { paymentInfo, + pageUrl: typeof window !== "undefined" ? window.location.href : "", + return_url: returnUrl, }; const response = await performSingleCheckout(payload); @@ -39,7 +42,11 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) { } if ("payment" in response.data) { - const { status } = response.data.payment; + const { status, paymentUrl } = response.data.payment; + + if (paymentUrl) { + return window.location.replace(paymentUrl); + } if (status === "paid") { onSuccess?.(); @@ -58,7 +65,7 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) { setIsLoading(false); } }, - [onSuccess, onError, isLoading] + [isLoading, returnUrl, onError, onSuccess] ); return useMemo( diff --git a/src/shared/auth/token.ts b/src/shared/auth/token.ts index a04cee5..10b28e4 100644 --- a/src/shared/auth/token.ts +++ b/src/shared/auth/token.ts @@ -8,10 +8,10 @@ export async function getServerAccessToken() { export function getClientAccessToken(): string | undefined { if (typeof window === "undefined") return undefined; - const cookies = document.cookie.split(';'); + const cookies = document.cookie.split(";"); const accessTokenCookie = cookies.find(cookie => - cookie.trim().startsWith('accessToken=') + cookie.trim().startsWith("accessToken=") ); - return accessTokenCookie?.split('=')[1]; + return accessTokenCookie?.split("=")[1]; } diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts index 6ebeeca..406ffa9 100644 --- a/src/shared/constants/api-routes.ts +++ b/src/shared/constants/api-routes.ts @@ -42,6 +42,6 @@ export const API_ROUTES = { createRoute(["chats", chatId, "messages"]), getUserBalance: () => createRoute(["chats", "balance"]), getMyChatSettings: () => createRoute(["chats", "profile", "chat-settings"]), - updateMyChatSettings: () => createRoute(["chats", "profile", "chat-settings"]), + updateMyChatSettings: () => + createRoute(["chats", "profile", "chat-settings"]), }; -