Merge pull request #45 from pennyteenycat/develop

Develop
This commit is contained in:
pennyteenycat 2025-08-25 00:40:14 +02:00 committed by GitHub
commit 5e51d2c2cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 402 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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