refactor(router): introduce app navigation manager

This commit is contained in:
2026-07-03 14:09:13 +08:00
parent 8a586e4471
commit 91dde42f92
23 changed files with 669 additions and 277 deletions
-35
View File
@@ -1,8 +1,6 @@
"use client";
import type { LoginStatus } from "@/data/dto/auth";
import type { PayChannel } from "@/data/dto/payment";
import { ROUTE_BUILDERS } from "@/router/routes";
import { AppEnvUtil, BrowserDetector } from "@/utils";
export interface ExternalBrowserPromptState {
@@ -31,41 +29,8 @@ export function shouldStartExternalBrowserPrompt({
);
}
export function getChatPaywallSubscriptionUrl(
payChannel: PayChannel = "stripe",
): string {
return ROUTE_BUILDERS.subscription("vip", {
payChannel,
returnTo: "chat",
});
}
export function getChatCreditsTopUpSubscriptionUrl(
payChannel: PayChannel = "stripe",
): string {
return ROUTE_BUILDERS.subscription("topup", {
payChannel,
returnTo: "chat",
});
}
export function getInsufficientCreditsSubscriptionType(
isVip: boolean,
): "vip" | "topup" {
return isVip ? "topup" : "vip";
}
export function getChatPaywallNavigationUrl(
loginStatus: LoginStatus,
type: "vip" | "topup" = "vip",
payChannel: PayChannel = "stripe",
): string {
const subscriptionUrl =
type === "topup"
? getChatCreditsTopUpSubscriptionUrl(payChannel)
: getChatPaywallSubscriptionUrl(payChannel);
if (deriveIsGuest(loginStatus)) {
return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl);
}
return subscriptionUrl;
}
+6 -14
View File
@@ -2,18 +2,17 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { MobileShell } from "@/app/_components/core";
@@ -31,7 +30,6 @@ import {
} from "./components";
import {
deriveIsGuest,
getChatPaywallNavigationUrl,
getInsufficientCreditsSubscriptionType,
isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt,
@@ -45,7 +43,7 @@ export function ChatScreen() {
const chatDispatch = useChatDispatch();
const authState = useAuthState();
const userState = useUserState();
const router = useRouter();
const navigator = useAppNavigator();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
const {
@@ -122,16 +120,10 @@ export function ChatScreen() {
}
function handleMessageLimitUnlock(): void {
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
router.push(
getChatPaywallNavigationUrl(
authState.loginStatus,
getInsufficientCreditsSubscriptionType(userState.isVip),
defaultPayChannel,
),
);
navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(userState.isVip),
returnTo: "chat",
});
}
function handleUnlockVoiceMessage(messageId: string): void {
+4 -4
View File
@@ -7,9 +7,9 @@
*/
import type { ReactNode } from "react";
import { Lock, Menu } from "lucide-react";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import styles from "./chat-header.module.css";
@@ -19,7 +19,7 @@ export interface ChatHeaderProps {
}
export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
const router = useRouter();
const navigator = useAppNavigator();
if (isGuest) {
return (
@@ -27,7 +27,7 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
<button
type="button"
className={styles.guestBanner}
onClick={() => router.push(ROUTES.auth)}
onClick={() => navigator.openAuth(ROUTES.chat)}
aria-label="Sign up to unlock more features"
>
<Lock className={styles.guestBannerIcon} size={16} aria-hidden="true" />
@@ -46,7 +46,7 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
<button
type="button"
className={styles.menuButton}
onClick={() => router.push(ROUTES.sidebar)}
onClick={() => navigator.push(ROUTES.sidebar)}
aria-label="Menu"
>
<Menu className={styles.menuIcon} size={24} aria-hidden="true" />
@@ -16,9 +16,7 @@
* 不做的事:
* - 不直接管理 state machinechat 机器不感知 UI 层)
*/
import { useRouter } from "next/navigation";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import styles from "./chat-insufficient-credits-banner.module.css";
@@ -26,7 +24,7 @@ export interface ChatInsufficientCreditsBannerProps {
title?: string;
ctaLabel?: string;
/**
* 自定义点击回调(不传则默认 router.push(topup subscription)
* 自定义点击回调(不传则默认走统一订阅导航
* - 测试时可传 mock
* - 嵌入其他场景时(如 nested overlay)可传自定义
*/
@@ -38,7 +36,7 @@ export function ChatInsufficientCreditsBanner({
ctaLabel = "Top up credits to continue",
onUnlock,
}: ChatInsufficientCreditsBannerProps) {
const router = useRouter();
const navigator = useAppNavigator();
const titleLines = title.split("\n");
const handleClick = () => {
@@ -46,7 +44,7 @@ export function ChatInsufficientCreditsBanner({
onUnlock();
return;
}
router.push(ROUTE_BUILDERS.subscription("topup"));
navigator.openSubscription({ type: "topup", returnTo: "chat" });
};
return (
@@ -1,21 +1,17 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import { useUserState } from "@/stores/user/user-context";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
savePendingChatUnlock,
type PendingChatUnlock,
type PendingChatUnlockKind,
} from "@/lib/navigation/chat_unlock_session";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import { useUserState } from "@/stores/user/user-context";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
@@ -40,14 +36,10 @@ export function useChatUnlockNavigationFlow({
expectedKind,
expectedMessageId,
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const router = useRouter();
const authState = useAuthState();
const navigator = useAppNavigator();
const userState = useUserState();
const chatState = useChatState();
const chatDispatch = useChatDispatch();
const isAuthenticatedUser =
authState.loginStatus !== "notLoggedIn" &&
authState.loginStatus !== "guest";
const unlockPaywallRequest = getScopedUnlockPaywallRequest({
request: chatState.unlockPaywallRequest,
expectedKind,
@@ -55,7 +47,7 @@ export function useChatUnlockNavigationFlow({
});
useEffect(() => {
if (!chatState.historyLoaded || !isAuthenticatedUser) return;
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
let cancelled = false;
@@ -93,7 +85,7 @@ export function useChatUnlockNavigationFlow({
chatState.historyLoaded,
expectedKind,
expectedMessageId,
isAuthenticatedUser,
navigator.isAuthenticatedUser,
returnUrl,
]);
@@ -101,23 +93,17 @@ export function useChatUnlockNavigationFlow({
messageId: string,
kind: PendingChatUnlockKind,
): void {
if (!isAuthenticatedUser) {
void (async () => {
await savePendingChatUnlock({
messageId,
kind,
returnUrl,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
})();
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
navigator.startMessageUnlock({
messageId,
kind,
returnUrl,
onAuthenticated: () => {
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
kind,
});
},
});
}
@@ -128,24 +114,13 @@ export function useChatUnlockNavigationFlow({
function confirmInsufficientCreditsDialog(): void {
if (!unlockPaywallRequest) return;
void (async () => {
await savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ payChannel: defaultPayChannel, returnTo: "chat" },
),
);
})();
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
navigator.openSubscriptionForPendingUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl,
type: getInsufficientCreditsSubscriptionType(userState.isVip),
});
}
return {
@@ -1,10 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { LoginStatus } from "@/data/dto/auth";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { usePaymentDispatch, usePaymentState } from "@/stores/payment";
import { useUserState } from "@/stores/user/user-context";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
@@ -55,7 +54,7 @@ export function useFirstRechargeOfferBanner({
historyLoaded,
loginStatus,
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
const router = useRouter();
const navigator = useAppNavigator();
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const userState = useUserState();
@@ -127,15 +126,7 @@ export function useFirstRechargeOfferBanner({
}
function claim(): void {
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
router.push(
ROUTE_BUILDERS.subscription("vip", {
payChannel: defaultPayChannel,
returnTo: "chat",
}),
);
navigator.openSubscription({ type: "vip", returnTo: "chat" });
}
return {