commit
5e51d2c2cf
@ -10,6 +10,10 @@
|
|||||||
"email_placeholder": "Email",
|
"email_placeholder": "Email",
|
||||||
"name_placeholder": "Name"
|
"name_placeholder": "Name"
|
||||||
},
|
},
|
||||||
|
"auto_top_up": {
|
||||||
|
"title": "Automatische Aufladung",
|
||||||
|
"description": "Automatisch Guthaben hinzufügen, wenn es aufgebraucht ist, um Chats ohne Unterbrechung fortzusetzen."
|
||||||
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Billing",
|
"title": "Billing",
|
||||||
"description": "To access your subscription information, please log into your billing account.",
|
"description": "To access your subscription information, please log into your billing account.",
|
||||||
|
|||||||
@ -10,6 +10,10 @@
|
|||||||
"email_placeholder": "Email",
|
"email_placeholder": "Email",
|
||||||
"name_placeholder": "Name"
|
"name_placeholder": "Name"
|
||||||
},
|
},
|
||||||
|
"auto_top_up": {
|
||||||
|
"title": "Auto Top-Up",
|
||||||
|
"description": "Automatically add credits when you run out to keep chats uninterrupted."
|
||||||
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Billing",
|
"title": "Billing",
|
||||||
"description": "To access your subscription information, please log into your billing account.",
|
"description": "To access your subscription information, please log into your billing account.",
|
||||||
|
|||||||
@ -10,6 +10,10 @@
|
|||||||
"email_placeholder": "Email",
|
"email_placeholder": "Email",
|
||||||
"name_placeholder": "Name"
|
"name_placeholder": "Name"
|
||||||
},
|
},
|
||||||
|
"auto_top_up": {
|
||||||
|
"title": "Recarga automática",
|
||||||
|
"description": "Añade créditos automáticamente cuando te quedes sin saldo para mantener los chats sin interrupciones."
|
||||||
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Billing",
|
"title": "Billing",
|
||||||
"description": "To access your subscription information, please log into your billing account.",
|
"description": "To access your subscription information, please log into your billing account.",
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { GlobalNewMessagesBanner } from "@/components/domains/chat";
|
||||||
import { DrawerProvider, Header, NavigationBar } from "@/components/layout";
|
import { DrawerProvider, Header, NavigationBar } from "@/components/layout";
|
||||||
import { ChatStoreProvider } from "@/providers/chat-store-provider";
|
import { ChatStoreProvider } from "@/providers/chat-store-provider";
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ export default function CoreLayout({
|
|||||||
<DrawerProvider>
|
<DrawerProvider>
|
||||||
<ChatStoreProvider>
|
<ChatStoreProvider>
|
||||||
<Header className={styles.navBar} />
|
<Header className={styles.navBar} />
|
||||||
|
<GlobalNewMessagesBanner />
|
||||||
<main className={styles.main}>{children}</main>
|
<main className={styles.main}>{children}</main>
|
||||||
<NavigationBar />
|
<NavigationBar />
|
||||||
</ChatStoreProvider>
|
</ChatStoreProvider>
|
||||||
|
|||||||
@ -7,8 +7,6 @@ import {
|
|||||||
CompatibilitySectionSkeleton,
|
CompatibilitySectionSkeleton,
|
||||||
MeditationSection,
|
MeditationSection,
|
||||||
MeditationSectionSkeleton,
|
MeditationSectionSkeleton,
|
||||||
NewMessagesSection,
|
|
||||||
NewMessagesSectionSkeleton,
|
|
||||||
PalmSection,
|
PalmSection,
|
||||||
PalmSectionSkeleton,
|
PalmSectionSkeleton,
|
||||||
} from "@/components/domains/dashboard";
|
} from "@/components/domains/dashboard";
|
||||||
@ -27,10 +25,6 @@ export default function Home() {
|
|||||||
const chatsPromise = loadChatsList();
|
const chatsPromise = loadChatsList();
|
||||||
return (
|
return (
|
||||||
<section className={styles.page}>
|
<section className={styles.page}>
|
||||||
<Suspense fallback={<NewMessagesSectionSkeleton />}>
|
|
||||||
<NewMessagesSection />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Horoscope />
|
<Horoscope />
|
||||||
|
|
||||||
<Suspense fallback={<AdvisersSectionSkeleton />}>
|
<Suspense fallback={<AdvisersSectionSkeleton />}>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import { GlobalNewMessagesBanner } from "@/components/domains/chat";
|
||||||
import { DrawerProvider, Header } from "@/components/layout";
|
import { DrawerProvider, Header } from "@/components/layout";
|
||||||
|
import { ChatStoreProvider } from "@/providers/chat-store-provider";
|
||||||
|
|
||||||
import styles from "./layout.module.scss";
|
import styles from "./layout.module.scss";
|
||||||
|
|
||||||
@ -9,8 +11,11 @@ export default function PaymentLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<DrawerProvider>
|
<DrawerProvider>
|
||||||
<Header className={styles.navBar} />
|
<ChatStoreProvider>
|
||||||
<main className={styles.main}>{children}</main>
|
<Header className={styles.navBar} />
|
||||||
|
<GlobalNewMessagesBanner />
|
||||||
|
<main className={styles.main}>{children}</main>
|
||||||
|
</ChatStoreProvider>
|
||||||
</DrawerProvider>
|
</DrawerProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 76px;
|
||||||
|
z-index: 100;
|
||||||
|
margin-bottom: clamp(16px, 2.5vw, 32px);
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
|
||||||
|
import NewMessages from "@/components/domains/chat/NewMessages/NewMessages";
|
||||||
|
import ViewAll from "@/components/domains/chat/ViewAll/ViewAll";
|
||||||
|
import { useAppUiStore } from "@/providers/app-ui-store-provider";
|
||||||
|
import { useChats } from "@/providers/chats-provider";
|
||||||
|
import { ROUTES } from "@/shared/constants/client-routes";
|
||||||
|
import { stripLocale } from "@/shared/utils/path";
|
||||||
|
|
||||||
|
import styles from "./GlobalNewMessagesBanner.module.scss";
|
||||||
|
|
||||||
|
export default function GlobalNewMessagesBanner() {
|
||||||
|
const { unreadChats } = useChats();
|
||||||
|
|
||||||
|
// Exclude banner on chat-related and settings (profile) pages
|
||||||
|
const pathname = usePathname();
|
||||||
|
const locale = useLocale();
|
||||||
|
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||||
|
const isExcluded =
|
||||||
|
pathnameWithoutLocale.startsWith(ROUTES.chat()) ||
|
||||||
|
pathnameWithoutLocale.startsWith(ROUTES.profile());
|
||||||
|
|
||||||
|
const hasHydrated = useAppUiStore(state => state._hasHydrated);
|
||||||
|
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
|
||||||
|
const setHomeNewMessages = useAppUiStore(state => state.setHomeNewMessages);
|
||||||
|
|
||||||
|
if (!hasHydrated || isExcluded || unreadChats.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{unreadChats.length > 1 && (
|
||||||
|
<ViewAll
|
||||||
|
count={unreadChats.length}
|
||||||
|
isAll={isVisibleAll}
|
||||||
|
onClick={() => setHomeNewMessages({ isVisibleAll: !isVisibleAll })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<NewMessages chats={unreadChats} isVisibleAll={isVisibleAll} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,10 +24,8 @@ export {
|
|||||||
} from "./CorrespondenceStartedWrapper/CorrespondenceStartedWrapper";
|
} from "./CorrespondenceStartedWrapper/CorrespondenceStartedWrapper";
|
||||||
export { default as RefillOptionsModal } from "./CreditsModals/RefillOptionsModal/RefillOptionsModal";
|
export { default as RefillOptionsModal } from "./CreditsModals/RefillOptionsModal/RefillOptionsModal";
|
||||||
export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal";
|
export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal";
|
||||||
export {
|
export { default as GlobalNewMessagesBanner } from "./GlobalNewMessagesBanner/GlobalNewMessagesBanner";
|
||||||
default as LastMessagePreview,
|
export { default as LastMessagePreview, type LastMessagePreviewProps } from "./LastMessagePreview/LastMessagePreview";
|
||||||
type LastMessagePreviewProps,
|
|
||||||
} from "./LastMessagePreview/LastMessagePreview";
|
|
||||||
export { default as MessageInput } from "./MessageInput/MessageInput";
|
export { default as MessageInput } from "./MessageInput/MessageInput";
|
||||||
export { default as MessageInputWrapper } from "./MessageInputWrapper/MessageInputWrapper";
|
export { default as MessageInputWrapper } from "./MessageInputWrapper/MessageInputWrapper";
|
||||||
export { default as NewMessages } from "./NewMessages/NewMessages";
|
export { default as NewMessages } from "./NewMessages/NewMessages";
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #777;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchButton {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background-color: #d3d3d3;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchButton[aria-checked="true"] {
|
||||||
|
background-color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: white;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchButton[aria-checked="true"] .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { Skeleton } from "@/components/ui";
|
||||||
|
import { getMyChatSettings, updateMyChatSettings } from "@/entities/chats/chatSettings.api";
|
||||||
|
import type { IChatSettings } from "@/entities/chats/types";
|
||||||
|
|
||||||
|
import styles from "./AutoTopUpToggle.module.scss";
|
||||||
|
|
||||||
|
export default function AutoTopUpToggle() {
|
||||||
|
const t = useTranslations("Profile");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<IChatSettings | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getMyChatSettings();
|
||||||
|
if (mounted) {
|
||||||
|
setSettings(res.settings);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// silent failure
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Failed to fetch chat settings", e);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onToggle = async () => {
|
||||||
|
if (!settings || isUpdating) return;
|
||||||
|
const next = { ...settings, autoTopUp: !settings.autoTopUp };
|
||||||
|
setSettings(next); // optimistic
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const res = await updateMyChatSettings(next);
|
||||||
|
setSettings(res.settings);
|
||||||
|
} catch (e) {
|
||||||
|
// revert on error silently
|
||||||
|
setSettings(settings);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Failed to update chat settings", e);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Skeleton style={{ height: 72 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.text}>
|
||||||
|
<div className={styles.title}>{t("auto_top_up.title")}</div>
|
||||||
|
<div className={styles.description}>{t("auto_top_up.description")}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.switchButton}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={settings?.autoTopUp ? "true" : "false"}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={t("auto_top_up.title")}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<span className={styles.thumb} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import AutoTopUpToggle from "@/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle";
|
||||||
import { Button, Typography } from "@/components/ui";
|
import { Button, Typography } from "@/components/ui";
|
||||||
import { useUserBalance } from "@/hooks/balance/useUserBalance";
|
import { useUserBalance } from "@/hooks/balance/useUserBalance";
|
||||||
import { ROUTES } from "@/shared/constants/client-routes";
|
import { ROUTES } from "@/shared/constants/client-routes";
|
||||||
@ -48,6 +49,7 @@ function Billing() {
|
|||||||
{t("credits.description")}
|
{t("credits.description")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
<AutoTopUpToggle />
|
||||||
<Typography
|
<Typography
|
||||||
as="p"
|
as="p"
|
||||||
weight="bold"
|
weight="bold"
|
||||||
|
|||||||
44
src/entities/chats/chatSettings.api.ts
Normal file
44
src/entities/chats/chatSettings.api.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { http } from "@/shared/api/httpClient";
|
||||||
|
import { API_ROUTES } from "@/shared/constants/api-routes";
|
||||||
|
|
||||||
|
import {
|
||||||
|
GetMyChatSettingsResponseSchema,
|
||||||
|
type IChatSettings,
|
||||||
|
type IGetMyChatSettingsResponse,
|
||||||
|
type IUpdateMyChatSettingsResponse,
|
||||||
|
UpdateMyChatSettingsResponseSchema,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current user's chat settings (client-side)
|
||||||
|
*/
|
||||||
|
export const updateMyChatSettings = async (
|
||||||
|
settings: IChatSettings
|
||||||
|
): Promise<IUpdateMyChatSettingsResponse> => {
|
||||||
|
return http.put<IUpdateMyChatSettingsResponse>(
|
||||||
|
API_ROUTES.updateMyChatSettings(),
|
||||||
|
settings,
|
||||||
|
{
|
||||||
|
tags: ["profile", "chat-settings"],
|
||||||
|
schema: UpdateMyChatSettingsResponseSchema,
|
||||||
|
revalidate: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@ -52,6 +52,22 @@ const GetChatMessagesResponseSchema = z.object({
|
|||||||
limit: z.number(),
|
limit: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chat settings
|
||||||
|
const ChatSettingsSchema = z.object({
|
||||||
|
autoTopUp: z.boolean(),
|
||||||
|
topUpAmount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const GetMyChatSettingsResponseSchema = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
settings: ChatSettingsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateMyChatSettingsResponseSchema = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
settings: ChatSettingsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export type IChat = z.infer<typeof ChatSchema>;
|
export type IChat = z.infer<typeof ChatSchema>;
|
||||||
export type ICategorizedChats = z.infer<typeof CategorizedChatsSchema>;
|
export type ICategorizedChats = z.infer<typeof CategorizedChatsSchema>;
|
||||||
export type ICreateAllChatsResponse = z.infer<
|
export type ICreateAllChatsResponse = z.infer<
|
||||||
@ -63,11 +79,22 @@ export type IChatMessage = z.infer<typeof ChatMessageSchema>;
|
|||||||
export type IGetChatMessagesResponse = z.infer<
|
export type IGetChatMessagesResponse = z.infer<
|
||||||
typeof GetChatMessagesResponseSchema
|
typeof GetChatMessagesResponseSchema
|
||||||
>;
|
>;
|
||||||
|
export type IChatSettings = z.infer<typeof ChatSettingsSchema>;
|
||||||
|
export type IGetMyChatSettingsResponse = z.infer<
|
||||||
|
typeof GetMyChatSettingsResponseSchema
|
||||||
|
>;
|
||||||
|
export type IUpdateMyChatSettingsResponse = z.infer<
|
||||||
|
typeof UpdateMyChatSettingsResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ChatMessageSchema,
|
ChatMessageSchema,
|
||||||
|
ChatSettingsSchema,
|
||||||
CreateAllChatsResponseSchema,
|
CreateAllChatsResponseSchema,
|
||||||
CreateChatResponseSchema,
|
CreateChatResponseSchema,
|
||||||
GetChatMessagesResponseSchema,
|
GetChatMessagesResponseSchema,
|
||||||
GetChatsListResponseSchema,
|
GetChatsListResponseSchema,
|
||||||
|
GetMyChatSettingsResponseSchema,
|
||||||
|
UpdateMyChatSettingsResponseSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { fetchChatMessages } from "@/entities/chats/actions";
|
import { fetchChatMessages } from "@/entities/chats/actions";
|
||||||
import type { IChatMessage } from "@/entities/chats/types";
|
import type { IChatMessage } from "@/entities/chats/types";
|
||||||
|
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
|
||||||
import { useSocketEvent } from "@/hooks/socket/useSocketEvent";
|
import { useSocketEvent } from "@/hooks/socket/useSocketEvent";
|
||||||
import { useChatStore } from "@/providers/chat-store-provider";
|
import { useChatStore } from "@/providers/chat-store-provider";
|
||||||
import {
|
import {
|
||||||
@ -112,7 +113,25 @@ export const useChatSocket = (
|
|||||||
emit("fetch_balance", { chatId });
|
emit("fetch_balance", { chatId });
|
||||||
}, [emit, 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);
|
||||||
|
|
||||||
const balancePollId = useRef<NodeJS.Timeout | null>(null);
|
const balancePollId = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
// Avoid immediate leave_chat right after join in React 18 StrictMode (dev) double-invoke
|
||||||
|
// We debounce leave so that a quick remount cancels it, but real navigation (or chat switch) proceeds.
|
||||||
|
const leaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastChatIdRef = useRef(chatId);
|
||||||
|
|
||||||
const startBalancePolling = useCallback(() => {
|
const startBalancePolling = useCallback(() => {
|
||||||
if (balancePollId.current) return;
|
if (balancePollId.current) return;
|
||||||
@ -177,13 +196,32 @@ export const useChatSocket = (
|
|||||||
options.onNewMessage(data[0]);
|
options.onNewMessage(data[0]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
useSocketEvent("current_balance", b => setBalance(b.data));
|
useSocketEvent("current_balance", b => {
|
||||||
|
setBalance(b.data);
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
useSocketEvent("balance_updated", b => {
|
useSocketEvent("balance_updated", b => {
|
||||||
setBalance(prev => (prev ? { ...prev, balance: b.data.balance } : null));
|
setBalance(prev => (prev ? { ...prev, balance: b.data.balance } : null));
|
||||||
|
if (autoTopUpInProgressRef.current && b?.data?.balance > 0) {
|
||||||
|
autoTopUpInProgressRef.current = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
useSocketEvent("session_started", s => setSession(s.data));
|
useSocketEvent("session_started", s => setSession(s.data));
|
||||||
useSocketEvent("session_ended", () => setSession(null));
|
useSocketEvent("session_ended", () => setSession(null));
|
||||||
useSocketEvent("show_refill_modals", r => setRefillModals(r.data));
|
useSocketEvent("show_refill_modals", r => setRefillModals(r.data));
|
||||||
|
useSocketEvent("auto_topup_request", r => {
|
||||||
|
if (!r?.data) return;
|
||||||
|
if (isAutoTopUpLoading) return;
|
||||||
|
// Prevent concurrent or rapid duplicate attempts
|
||||||
|
if (autoTopUpInProgressRef.current) return;
|
||||||
|
autoTopUpInProgressRef.current = true;
|
||||||
|
// Trigger checkout silently
|
||||||
|
handleSingleCheckout(r.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.maxFinishedAt) return;
|
if (!session?.maxFinishedAt) return;
|
||||||
@ -204,13 +242,39 @@ export const useChatSocket = (
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || status !== ESocketStatus.CONNECTED) return;
|
if (!socket || status !== ESocketStatus.CONNECTED) return;
|
||||||
|
|
||||||
|
// If a leave was scheduled for the same chat (StrictMode remount), cancel it
|
||||||
|
if (leaveTimeoutRef.current && lastChatIdRef.current === chatId) {
|
||||||
|
clearTimeout(leaveTimeoutRef.current);
|
||||||
|
leaveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
joinChat();
|
joinChat();
|
||||||
fetchBalance();
|
fetchBalance();
|
||||||
|
lastChatIdRef.current = chatId;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
leaveChat();
|
// Debounce leave to avoid leaving room between StrictMode unmount/mount cycle
|
||||||
|
// For real chat switch (different chatId), we won't cancel this in the next effect
|
||||||
|
leaveTimeoutRef.current = setTimeout(() => {
|
||||||
|
leaveChat();
|
||||||
|
}, 300);
|
||||||
};
|
};
|
||||||
}, [socket, status, joinChat, leaveChat, fetchBalance]);
|
}, [socket, status, joinChat, leaveChat, fetchBalance, chatId]);
|
||||||
|
|
||||||
|
// Re-join chat on socket reconnects while staying on the chat page
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
const handleConnect = () => {
|
||||||
|
// Ensure current chat is tracked and we actually reconnect to it
|
||||||
|
lastChatIdRef.current = chatId;
|
||||||
|
joinChat();
|
||||||
|
fetchBalance();
|
||||||
|
};
|
||||||
|
socket.on("connect", handleConnect);
|
||||||
|
return () => {
|
||||||
|
socket.off("connect", handleConnect);
|
||||||
|
};
|
||||||
|
}, [socket, chatId, joinChat, fetchBalance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSuggestions(messages[0]?.suggestions);
|
setSuggestions(messages[0]?.suggestions);
|
||||||
|
|||||||
@ -44,6 +44,12 @@ export interface IRefillModals {
|
|||||||
products?: IRefillModalsProduct[];
|
products?: IRefillModalsProduct[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IAutoTopUpRequest {
|
||||||
|
productId: string;
|
||||||
|
key: string;
|
||||||
|
isAutoTopUp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IUnreadMessagesCount {
|
export interface IUnreadMessagesCount {
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
}
|
}
|
||||||
@ -81,6 +87,9 @@ export interface ServerToClientEvents {
|
|||||||
show_refill_modals: (
|
show_refill_modals: (
|
||||||
data: ServerToClientEventsBaseData<IRefillModals>
|
data: ServerToClientEventsBaseData<IRefillModals>
|
||||||
) => void;
|
) => void;
|
||||||
|
auto_topup_request: (
|
||||||
|
data: ServerToClientEventsBaseData<IAutoTopUpRequest>
|
||||||
|
) => void;
|
||||||
chats_updated: (data: IGetChatsListResponse) => void;
|
chats_updated: (data: IGetChatsListResponse) => void;
|
||||||
unread_messages_count: (data: IUnreadMessagesCount) => void;
|
unread_messages_count: (data: IUnreadMessagesCount) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getServerAccessToken } from "../auth/token";
|
|
||||||
import { devLogger } from "../utils/logger";
|
import { devLogger } from "../utils/logger";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@ -41,7 +39,7 @@ class HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
method: "GET" | "POST" | "PATCH" | "DELETE",
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
||||||
rootUrl: string = this.baseUrl,
|
rootUrl: string = this.baseUrl,
|
||||||
path: string,
|
path: string,
|
||||||
opts: RequestOpts = {},
|
opts: RequestOpts = {},
|
||||||
@ -81,7 +79,18 @@ class HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const accessToken = await getServerAccessToken();
|
let accessToken: string | undefined;
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
const { getServerAccessToken } = await import("../auth/token");
|
||||||
|
accessToken = await getServerAccessToken();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { getClientAccessToken } = await import("../auth/token");
|
||||||
|
accessToken = getClientAccessToken();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
|
|
||||||
@ -127,7 +136,13 @@ class HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401 && !skipAuthRedirect) {
|
if (res.status === 401 && !skipAuthRedirect) {
|
||||||
redirect(process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL || "");
|
if (typeof window === "undefined") {
|
||||||
|
const { redirect } = await import("next/navigation");
|
||||||
|
redirect(process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL || "");
|
||||||
|
} else {
|
||||||
|
const url = process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL || "/";
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new ApiError(res.status, payload, errorMessage);
|
throw new ApiError(res.status, payload, errorMessage);
|
||||||
}
|
}
|
||||||
@ -180,6 +195,8 @@ class HttpClient {
|
|||||||
this.request<T>("GET", u, p, o);
|
this.request<T>("GET", u, p, o);
|
||||||
post = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
|
post = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
|
||||||
this.request<T>("POST", u, p, o, b);
|
this.request<T>("POST", u, p, o, b);
|
||||||
|
put = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
|
||||||
|
this.request<T>("PUT", u, p, o, b);
|
||||||
patch = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
|
patch = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
|
||||||
this.request<T>("PATCH", u, p, o, b);
|
this.request<T>("PATCH", u, p, o, b);
|
||||||
delete = <T>(p: string, o?: RequestOpts, u?: string) =>
|
delete = <T>(p: string, o?: RequestOpts, u?: string) =>
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
import { cookies } from "next/headers";
|
// Server-side token functions (only for Server Components)
|
||||||
|
|
||||||
export async function getServerAccessToken() {
|
export async function getServerAccessToken() {
|
||||||
|
const { cookies } = await import("next/headers");
|
||||||
return (await cookies()).get("accessToken")?.value;
|
return (await cookies()).get("accessToken")?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client-side token functions
|
||||||
|
export function getClientAccessToken(): string | undefined {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
const accessTokenCookie = cookies.find(cookie =>
|
||||||
|
cookie.trim().startsWith('accessToken=')
|
||||||
|
);
|
||||||
|
|
||||||
|
return accessTokenCookie?.split('=')[1];
|
||||||
|
}
|
||||||
|
|||||||
@ -41,4 +41,7 @@ export const API_ROUTES = {
|
|||||||
getChatMessages: (chatId: string) =>
|
getChatMessages: (chatId: string) =>
|
||||||
createRoute(["chats", chatId, "messages"]),
|
createRoute(["chats", chatId, "messages"]),
|
||||||
getUserBalance: () => createRoute(["chats", "balance"]),
|
getUserBalance: () => createRoute(["chats", "balance"]),
|
||||||
|
getMyChatSettings: () => createRoute(["chats", "profile", "chat-settings"]),
|
||||||
|
updateMyChatSettings: () => createRoute(["chats", "profile", "chat-settings"]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user