This commit is contained in:
dev.daminik00 2025-08-24 22:53:35 +02:00
parent 13b8e0a6a7
commit f7c5ab1351
17 changed files with 344 additions and 19 deletions

View File

@ -10,6 +10,10 @@
"email_placeholder": "Email",
"name_placeholder": "Name"
},
"auto_top_up": {
"title": "Automatische Aufladung",
"description": "Automatisch Guthaben hinzufügen, wenn es aufgebraucht ist, um Chats ohne Unterbrechung fortzusetzen."
},
"billing": {
"title": "Billing",
"description": "To access your subscription information, please log into your billing account.",

View File

@ -10,6 +10,10 @@
"email_placeholder": "Email",
"name_placeholder": "Name"
},
"auto_top_up": {
"title": "Auto Top-Up",
"description": "Automatically add credits when you run out to keep chats uninterrupted."
},
"billing": {
"title": "Billing",
"description": "To access your subscription information, please log into your billing account.",

View File

@ -10,6 +10,10 @@
"email_placeholder": "Email",
"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": {
"title": "Billing",
"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 { ChatStoreProvider } from "@/providers/chat-store-provider";
@ -12,6 +13,7 @@ export default function CoreLayout({
<DrawerProvider>
<ChatStoreProvider>
<Header className={styles.navBar} />
<GlobalNewMessagesBanner />
<main className={styles.main}>{children}</main>
<NavigationBar />
</ChatStoreProvider>

View File

@ -7,8 +7,6 @@ import {
CompatibilitySectionSkeleton,
MeditationSection,
MeditationSectionSkeleton,
NewMessagesSection,
NewMessagesSectionSkeleton,
PalmSection,
PalmSectionSkeleton,
} from "@/components/domains/dashboard";
@ -27,10 +25,6 @@ export default function Home() {
const chatsPromise = loadChatsList();
return (
<section className={styles.page}>
<Suspense fallback={<NewMessagesSectionSkeleton />}>
<NewMessagesSection />
</Suspense>
<Horoscope />
<Suspense fallback={<AdvisersSectionSkeleton />}>

View File

@ -1,4 +1,6 @@
import { GlobalNewMessagesBanner } from "@/components/domains/chat";
import { DrawerProvider, Header } from "@/components/layout";
import { ChatStoreProvider } from "@/providers/chat-store-provider";
import styles from "./layout.module.scss";
@ -9,8 +11,11 @@ export default function PaymentLayout({
}>) {
return (
<DrawerProvider>
<ChatStoreProvider>
<Header className={styles.navBar} />
<GlobalNewMessagesBanner />
<main className={styles.main}>{children}</main>
</ChatStoreProvider>
</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";
export { default as RefillOptionsModal } from "./CreditsModals/RefillOptionsModal/RefillOptionsModal";
export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal";
export {
default as LastMessagePreview,
type LastMessagePreviewProps,
} from "./LastMessagePreview/LastMessagePreview";
export { default as GlobalNewMessagesBanner } from "./GlobalNewMessagesBanner/GlobalNewMessagesBanner";
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

@ -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 { useTranslations } from "next-intl";
import AutoTopUpToggle from "@/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle";
import { Button, Typography } from "@/components/ui";
import { useUserBalance } from "@/hooks/balance/useUserBalance";
import { ROUTES } from "@/shared/constants/client-routes";
@ -48,6 +49,7 @@ function Billing() {
{t("credits.description")}
</Typography>
</div>
<AutoTopUpToggle />
<Typography
as="p"
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(),
});
// 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 ICategorizedChats = z.infer<typeof CategorizedChatsSchema>;
export type ICreateAllChatsResponse = z.infer<
@ -63,11 +79,22 @@ export type IChatMessage = z.infer<typeof ChatMessageSchema>;
export type IGetChatMessagesResponse = z.infer<
typeof GetChatMessagesResponseSchema
>;
export type IChatSettings = z.infer<typeof ChatSettingsSchema>;
export type IGetMyChatSettingsResponse = z.infer<
typeof GetMyChatSettingsResponseSchema
>;
export type IUpdateMyChatSettingsResponse = z.infer<
typeof UpdateMyChatSettingsResponseSchema
>;
export {
ChatMessageSchema,
ChatSettingsSchema,
CreateAllChatsResponseSchema,
CreateChatResponseSchema,
GetChatMessagesResponseSchema,
GetChatsListResponseSchema,
GetMyChatSettingsResponseSchema,
UpdateMyChatSettingsResponseSchema,
};

View File

@ -128,6 +128,10 @@ export const useChatSocket = (
const autoTopUpInProgressRef = useRef(false);
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(() => {
if (balancePollId.current) return;
@ -238,13 +242,39 @@ export const useChatSocket = (
useEffect(() => {
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();
fetchBalance();
lastChatIdRef.current = chatId;
return () => {
// 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(() => {
setSuggestions(messages[0]?.suggestions);

View File

@ -1,8 +1,6 @@
/* eslint-disable no-console */
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerAccessToken } from "../auth/token";
import { devLogger } from "../utils/logger";
export class ApiError extends Error {
@ -41,7 +39,7 @@ class HttpClient {
}
private async request<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
rootUrl: string = this.baseUrl,
path: string,
opts: RequestOpts = {},
@ -81,7 +79,18 @@ class HttpClient {
}
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/clientToken");
accessToken = getClientAccessToken();
} catch {
// ignore
}
}
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
headers.set("Content-Type", "application/json");
@ -127,7 +136,13 @@ class HttpClient {
}
if (res.status === 401 && !skipAuthRedirect) {
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);
}
@ -180,6 +195,8 @@ class HttpClient {
this.request<T>("GET", u, p, o);
post = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
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) =>
this.request<T>("PATCH", u, p, o, b);
delete = <T>(p: string, o?: RequestOpts, u?: string) =>

View File

@ -41,4 +41,7 @@ export const API_ROUTES = {
getChatMessages: (chatId: string) =>
createRoute(["chats", chatId, "messages"]),
getUserBalance: () => createRoute(["chats", "balance"]),
getMyChatSettings: () => createRoute(["chats", "profile", "chat-settings"]),
updateMyChatSettings: () => createRoute(["chats", "profile", "chat-settings"]),
};