refactor(router): introduce app navigation manager
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 machine(chat 机器不感知 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 {
|
||||
|
||||
Reference in New Issue
Block a user