Merge pull request #49 from pennyteenycat/develop

Develop
This commit is contained in:
pennyteenycat 2025-09-08 00:16:18 +02:00 committed by GitHub
commit f1071ccb0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 115 additions and 46 deletions

View File

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

View File

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

View File

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

View File

@ -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<IRefillModalsProduct>(
@ -45,7 +43,6 @@ export default function RefillOptionsModal({
handleSingleCheckout({
productId: selectedOption.id,
key: selectedOption.key,
isAutoTopUp,
});
};

View File

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

View File

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

View File

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

View File

@ -14,16 +14,17 @@ import {
/**
* Fetch current user's chat settings (client-side)
*/
export const getMyChatSettings = async (): Promise<IGetMyChatSettingsResponse> => {
return http.get<IGetMyChatSettingsResponse>(
API_ROUTES.getMyChatSettings(),
{
tags: ["profile", "chat-settings"],
schema: GetMyChatSettingsResponseSchema,
revalidate: 0,
}
);
};
export const getMyChatSettings =
async (): Promise<IGetMyChatSettingsResponse> => {
return http.get<IGetMyChatSettingsResponse>(
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 (
}
);
};

View File

@ -97,4 +97,3 @@ export {
GetMyChatSettingsResponseSchema,
UpdateMyChatSettingsResponseSchema,
};

View File

@ -24,6 +24,7 @@ export type PaymentInfo = z.infer<typeof PaymentInfoSchema>;
export const SingleCheckoutRequestSchema = z.object({
paymentInfo: PaymentInfoSchema,
return_url: z.string().optional(),
pageUrl: z.string().optional(),
});
export type SingleCheckoutRequest = z.infer<typeof SingleCheckoutRequestSchema>;
@ -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<typeof SingleCheckoutSuccessSchema>;

View File

@ -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<NodeJS.Timeout | null>(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<NodeJS.Timeout | null>(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;

View File

@ -1,12 +1,14 @@
"use client";
import { useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
interface PageNavigationOptions<T> {
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<boolean>;
onBeforePrevious?: (prevItem: T) => boolean | Promise<boolean>;
onComplete: () => void;
@ -25,6 +27,7 @@ interface PageNavigationReturn<T> {
goToFirst: () => Promise<void>;
goToLast: () => Promise<void>;
goToIndex: (index: number) => Promise<void>;
getNextPageUrl: () => string | undefined;
totalPages: number;
}
@ -32,18 +35,22 @@ export function useMultiPageNavigation<T>({
data,
currentType,
getTypeFromItem,
navigateToItemByType,
// navigateToItemByType,
getPageUrlByItem,
onBeforeNext,
onBeforePrevious,
onComplete,
onStart,
}: PageNavigationOptions<T>): PageNavigationReturn<T> {
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<T>({
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<T>({
[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<T>({
goToFirst,
goToLast,
goToIndex,
getTypeFromItem,
getNextPageUrl,
totalPages,
}),
[
currentItem,
nextItem,
currentIndex,
isFirst,
isLast,
@ -136,6 +153,8 @@ export function useMultiPageNavigation<T>({
goToFirst,
goToLast,
goToIndex,
getTypeFromItem,
getNextPageUrl,
totalPages,
]
);

View File

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

View File

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

View File

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