diff --git a/messages/de.json b/messages/de.json index 112ccd3..b2aef39 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 4802ca1..a03f631 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 112ccd3..8ddb49d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/src/app/[locale]/(core)/layout.tsx b/src/app/[locale]/(core)/layout.tsx index a2730b8..27c1044 100644 --- a/src/app/[locale]/(core)/layout.tsx +++ b/src/app/[locale]/(core)/layout.tsx @@ -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({
+
{children}
diff --git a/src/app/[locale]/(core)/page.tsx b/src/app/[locale]/(core)/page.tsx index 54a0512..797edfa 100644 --- a/src/app/[locale]/(core)/page.tsx +++ b/src/app/[locale]/(core)/page.tsx @@ -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 (
- }> - - - }> diff --git a/src/app/[locale]/(payment)/layout.tsx b/src/app/[locale]/(payment)/layout.tsx index ba4f04d..e49f5b3 100644 --- a/src/app/[locale]/(payment)/layout.tsx +++ b/src/app/[locale]/(payment)/layout.tsx @@ -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 ( -
-
{children}
+ +
+ +
{children}
+ ); } diff --git a/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.module.scss b/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.module.scss new file mode 100644 index 0000000..a57a294 --- /dev/null +++ b/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.module.scss @@ -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); +} diff --git a/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx b/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx new file mode 100644 index 0000000..c1df217 --- /dev/null +++ b/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx @@ -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 ( +
+ {unreadChats.length > 1 && ( + setHomeNewMessages({ isVisibleAll: !isVisibleAll })} + /> + )} + +
+ ); +} diff --git a/src/components/domains/chat/index.ts b/src/components/domains/chat/index.ts index 81bece5..6bef8bf 100644 --- a/src/components/domains/chat/index.ts +++ b/src/components/domains/chat/index.ts @@ -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"; diff --git a/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.module.scss b/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.module.scss new file mode 100644 index 0000000..7b55478 --- /dev/null +++ b/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.module.scss @@ -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); +} diff --git a/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.tsx b/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.tsx new file mode 100644 index 0000000..a3296ba --- /dev/null +++ b/src/components/domains/profile/AutoTopUpToggle/AutoTopUpToggle.tsx @@ -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(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 ; + } + + return ( +
+
+
{t("auto_top_up.title")}
+
{t("auto_top_up.description")}
+
+ +
+ ); +} diff --git a/src/components/domains/profile/Billing/Billing.tsx b/src/components/domains/profile/Billing/Billing.tsx index 99ff2ca..0772df2 100644 --- a/src/components/domains/profile/Billing/Billing.tsx +++ b/src/components/domains/profile/Billing/Billing.tsx @@ -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")} + => { + return http.get( + 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 => { + return http.put( + API_ROUTES.updateMyChatSettings(), + settings, + { + tags: ["profile", "chat-settings"], + schema: UpdateMyChatSettingsResponseSchema, + revalidate: 0, + } + ); +}; + diff --git a/src/entities/chats/types.ts b/src/entities/chats/types.ts index de675eb..5a8cfbd 100644 --- a/src/entities/chats/types.ts +++ b/src/entities/chats/types.ts @@ -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; export type ICategorizedChats = z.infer; export type ICreateAllChatsResponse = z.infer< @@ -63,11 +79,22 @@ export type IChatMessage = z.infer; export type IGetChatMessagesResponse = z.infer< typeof GetChatMessagesResponseSchema >; +export type IChatSettings = z.infer; +export type IGetMyChatSettingsResponse = z.infer< + typeof GetMyChatSettingsResponseSchema +>; +export type IUpdateMyChatSettingsResponse = z.infer< + typeof UpdateMyChatSettingsResponseSchema +>; export { ChatMessageSchema, + ChatSettingsSchema, CreateAllChatsResponseSchema, CreateChatResponseSchema, GetChatMessagesResponseSchema, GetChatsListResponseSchema, + GetMyChatSettingsResponseSchema, + UpdateMyChatSettingsResponseSchema, }; + diff --git a/src/hooks/chats/useChatSocket.ts b/src/hooks/chats/useChatSocket.ts index 3853bc9..e723ea2 100644 --- a/src/hooks/chats/useChatSocket.ts +++ b/src/hooks/chats/useChatSocket.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { fetchChatMessages } from "@/entities/chats/actions"; import type { IChatMessage } from "@/entities/chats/types"; +import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout"; import { useSocketEvent } from "@/hooks/socket/useSocketEvent"; import { useChatStore } from "@/providers/chat-store-provider"; import { @@ -112,7 +113,25 @@ 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); + const balancePollId = useRef(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(null); + const lastChatIdRef = useRef(chatId); const startBalancePolling = useCallback(() => { if (balancePollId.current) return; @@ -177,13 +196,32 @@ export const useChatSocket = ( 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 => { 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_ended", () => setSession(null)); 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(() => { if (!session?.maxFinishedAt) return; @@ -204,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 () => { - 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(() => { setSuggestions(messages[0]?.suggestions); diff --git a/src/services/socket/events.ts b/src/services/socket/events.ts index 1fc5f2e..c9cfb11 100644 --- a/src/services/socket/events.ts +++ b/src/services/socket/events.ts @@ -44,6 +44,12 @@ export interface IRefillModals { products?: IRefillModalsProduct[]; } +export interface IAutoTopUpRequest { + productId: string; + key: string; + isAutoTopUp?: boolean; +} + export interface IUnreadMessagesCount { unreadCount: number; } @@ -81,6 +87,9 @@ export interface ServerToClientEvents { show_refill_modals: ( data: ServerToClientEventsBaseData ) => void; + auto_topup_request: ( + data: ServerToClientEventsBaseData + ) => void; chats_updated: (data: IGetChatsListResponse) => void; unread_messages_count: (data: IUnreadMessagesCount) => void; } diff --git a/src/shared/api/httpClient.ts b/src/shared/api/httpClient.ts index ba91e84..1844085 100644 --- a/src/shared/api/httpClient.ts +++ b/src/shared/api/httpClient.ts @@ -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( - 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/token"); + 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) { - 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); } @@ -180,6 +195,8 @@ class HttpClient { this.request("GET", u, p, o); post = (p: string, b: unknown, o?: RequestOpts, u?: string) => this.request("POST", u, p, o, b); + put = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("PUT", u, p, o, b); patch = (p: string, b: unknown, o?: RequestOpts, u?: string) => this.request("PATCH", u, p, o, b); delete = (p: string, o?: RequestOpts, u?: string) => diff --git a/src/shared/auth/token.ts b/src/shared/auth/token.ts index 7450379..a04cee5 100644 --- a/src/shared/auth/token.ts +++ b/src/shared/auth/token.ts @@ -1,5 +1,17 @@ -import { cookies } from "next/headers"; - +// Server-side token functions (only for Server Components) export async function getServerAccessToken() { + const { cookies } = await import("next/headers"); 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]; +} diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts index bc74dc5..6ebeeca 100644 --- a/src/shared/constants/api-routes.ts +++ b/src/shared/constants/api-routes.ts @@ -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"]), }; +