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,24 +93,18 @@ export function useChatUnlockNavigationFlow({
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
): void {
|
||||
if (!isAuthenticatedUser) {
|
||||
void (async () => {
|
||||
await savePendingChatUnlock({
|
||||
navigator.startMessageUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
stage: "auth",
|
||||
});
|
||||
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
onAuthenticated: () => {
|
||||
chatDispatch({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId,
|
||||
kind,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function closeInsufficientCreditsDialog(): void {
|
||||
@@ -128,24 +114,13 @@ export function useChatUnlockNavigationFlow({
|
||||
function confirmInsufficientCreditsDialog(): void {
|
||||
if (!unlockPaywallRequest) return;
|
||||
|
||||
void (async () => {
|
||||
await savePendingChatUnlock({
|
||||
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
||||
navigator.openSubscriptionForPendingUnlock({
|
||||
messageId: unlockPaywallRequest.messageId,
|
||||
kind: unlockPaywallRequest.kind,
|
||||
returnUrl,
|
||||
stage: "payment",
|
||||
type: getInsufficientCreditsSubscriptionType(userState.isVip),
|
||||
});
|
||||
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
||||
const defaultPayChannel = getDefaultPayChannelForCountryCode(
|
||||
userState.currentUser?.countryCode,
|
||||
);
|
||||
router.push(
|
||||
ROUTE_BUILDERS.subscription(
|
||||
getInsufficientCreditsSubscriptionType(userState.isVip),
|
||||
{ payChannel: defaultPayChannel, returnTo: "chat" },
|
||||
),
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { BackButton } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
||||
|
||||
@@ -23,7 +22,7 @@ import styles from "./components/sidebar-screen.module.css";
|
||||
const FALLBACK_USERNAME = "User name";
|
||||
|
||||
export function SidebarScreen() {
|
||||
const router = useRouter();
|
||||
const navigator = useAppNavigator();
|
||||
const user = useUserState();
|
||||
const userDispatch = useUserDispatch();
|
||||
const auth = useAuthState();
|
||||
@@ -60,16 +59,6 @@ export function SidebarScreen() {
|
||||
|
||||
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
||||
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
|
||||
const defaultPayChannel = getDefaultPayChannelForCountryCode(
|
||||
user.currentUser?.countryCode,
|
||||
);
|
||||
const vipSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", {
|
||||
payChannel: defaultPayChannel,
|
||||
});
|
||||
const topUpSubscriptionUrl = ROUTE_BUILDERS.subscription("topup", {
|
||||
payChannel: defaultPayChannel,
|
||||
});
|
||||
|
||||
return (
|
||||
<MobileShell background="#fcf3f4">
|
||||
<div className={styles.shell}>
|
||||
@@ -86,7 +75,7 @@ export function SidebarScreen() {
|
||||
name={name}
|
||||
avatarUrl={avatarUrl}
|
||||
isLoading={isInitializingUser}
|
||||
onLoginClick={() => router.push(ROUTES.auth)}
|
||||
onLoginClick={() => navigator.openAuth(ROUTES.sidebar)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -96,10 +85,10 @@ export function SidebarScreen() {
|
||||
onActivateVip={
|
||||
sidebarState === "vip"
|
||||
? undefined
|
||||
: () => router.push(vipSubscriptionUrl)
|
||||
: () => navigator.openSubscription({ type: "vip" })
|
||||
}
|
||||
onTopUp={() => router.push(topUpSubscriptionUrl)}
|
||||
onRulesClick={() => router.push(ROUTES.coinsRules)}
|
||||
onTopUp={() => navigator.openSubscription({ type: "topup" })}
|
||||
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from "./chat/chat_storage";
|
||||
export * from "./chat/local_chat_db";
|
||||
export * from "./chat/local_chat_storage";
|
||||
export * from "./chat/local_message";
|
||||
export * from "./navigation/navigation_storage";
|
||||
export * from "./payment/pending_payment_order_storage";
|
||||
export * from "./user/iuser_storage";
|
||||
export * from "./user/user_storage";
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStorage } from "unstorage";
|
||||
import memoryDriver from "unstorage/drivers/memory";
|
||||
|
||||
import { SessionAsyncUtil } from "@/utils";
|
||||
|
||||
import { NavigationStorage } from "../navigation_storage";
|
||||
|
||||
describe("NavigationStorage", () => {
|
||||
beforeEach(() => {
|
||||
SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("saves, peeks, and consumes pending chat unlock sessions", async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
stage: "payment",
|
||||
});
|
||||
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
stage: "payment",
|
||||
});
|
||||
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatUnlock(),
|
||||
).resolves.toMatchObject({
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
});
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("drops expired pending chat unlock sessions", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z"));
|
||||
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
messageId: "msg_1",
|
||||
kind: "private",
|
||||
returnUrl: "/chat",
|
||||
stage: "auth",
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-07-03T00:31:00.000Z"));
|
||||
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("saves and consumes pending chat image return sessions", async () => {
|
||||
await NavigationStorage.savePendingChatImageReturn({
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatImageReturn(),
|
||||
).resolves.toMatchObject({
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
});
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatImageReturn(),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./navigation_storage";
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { Result, SessionAsyncUtil } from "@/utils";
|
||||
|
||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
||||
export type PendingChatUnlockStage = "auth" | "payment";
|
||||
|
||||
const PendingChatUnlockSchema = z.object({
|
||||
reason: z.literal("single_message_unlock"),
|
||||
messageId: z.string().min(1),
|
||||
kind: z.enum(["private", "voice", "image"]),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||
"returnUrl must be an internal route",
|
||||
),
|
||||
stage: z.enum(["auth", "payment"]),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
const PendingChatImageReturnSchema = z.object({
|
||||
reason: z.literal("image_paywall"),
|
||||
messageId: z.string().min(1),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||
"returnUrl must be an internal route",
|
||||
),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
||||
export type PendingChatImageReturn = z.output<
|
||||
typeof PendingChatImageReturnSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* NavigationStorage owns short-lived cross-route sessions.
|
||||
*
|
||||
* UI / router code should not touch the raw session storage utility directly.
|
||||
* Keeping these payloads here makes auth, payment, and chat return flows
|
||||
* testable without spreading storage keys across screens.
|
||||
*/
|
||||
export class NavigationStorage {
|
||||
private constructor() {}
|
||||
|
||||
static async savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): Promise<void> {
|
||||
const payload: PendingChatUnlock = {
|
||||
reason: "single_message_unlock",
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
returnUrl: input.returnUrl,
|
||||
stage: input.stage,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
payload,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
}
|
||||
|
||||
static async consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatUnlock(result.data);
|
||||
}
|
||||
|
||||
static async peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
if (Result.isErr(result)) return null;
|
||||
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
||||
if (!value) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static async hasPendingChatUnlock(): Promise<boolean> {
|
||||
return (await NavigationStorage.peekPendingChatUnlock()) !== null;
|
||||
}
|
||||
|
||||
static async clearPendingChatUnlock(): Promise<void> {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
|
||||
static async savePendingChatImageReturn(input: {
|
||||
messageId: string;
|
||||
returnUrl: string;
|
||||
}): Promise<void> {
|
||||
const payload: PendingChatImageReturn = {
|
||||
reason: "image_paywall",
|
||||
messageId: input.messageId,
|
||||
returnUrl: input.returnUrl,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatImageReturn,
|
||||
payload,
|
||||
PendingChatImageReturnSchema,
|
||||
);
|
||||
}
|
||||
|
||||
static async consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatImageReturn,
|
||||
PendingChatImageReturnSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatImageReturn(result.data);
|
||||
}
|
||||
|
||||
private static parsePendingChatUnlock(
|
||||
value: PendingChatUnlock | null,
|
||||
): PendingChatUnlock | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static parsePendingChatImageReturn(
|
||||
value: PendingChatImageReturn | null,
|
||||
): PendingChatImageReturn | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
NavigationStorage,
|
||||
type PendingChatImageReturn,
|
||||
} from "@/data/storage/navigation";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { Result, SessionAsyncUtil } from "@/utils";
|
||||
|
||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
const PendingChatImageReturnSchema = z.object({
|
||||
reason: z.literal("image_paywall"),
|
||||
messageId: z.string().min(1),
|
||||
returnUrl: z.string().min(1),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export type PendingChatImageReturn = z.output<
|
||||
typeof PendingChatImageReturnSchema
|
||||
>;
|
||||
export type { PendingChatImageReturn };
|
||||
|
||||
export async function savePendingChatImageReturn(input: {
|
||||
messageId: string;
|
||||
returnUrl: string;
|
||||
}): Promise<void> {
|
||||
const payload: PendingChatImageReturn = {
|
||||
reason: "image_paywall",
|
||||
messageId: input.messageId,
|
||||
returnUrl: input.returnUrl,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatImageReturn,
|
||||
payload,
|
||||
PendingChatImageReturnSchema,
|
||||
);
|
||||
await NavigationStorage.savePendingChatImageReturn(input);
|
||||
}
|
||||
|
||||
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatImageReturn,
|
||||
PendingChatImageReturnSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
if (Result.isErr(result)) return null;
|
||||
return parsePendingChatImageReturn(result.data);
|
||||
}
|
||||
|
||||
function parsePendingChatImageReturn(
|
||||
value: PendingChatImageReturn | null,
|
||||
): PendingChatImageReturn | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
return NavigationStorage.consumePendingChatImageReturn();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
NavigationStorage,
|
||||
type PendingChatUnlock,
|
||||
type PendingChatUnlockKind,
|
||||
type PendingChatUnlockStage,
|
||||
} from "@/data/storage/navigation";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { Result, SessionAsyncUtil } from "@/utils";
|
||||
|
||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
||||
export type PendingChatUnlockStage = "auth" | "payment";
|
||||
|
||||
const PendingChatUnlockSchema = z.object({
|
||||
reason: z.literal("single_message_unlock"),
|
||||
messageId: z.string().min(1),
|
||||
kind: z.enum(["private", "voice", "image"]),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||
"returnUrl must be an internal route",
|
||||
),
|
||||
stage: z.enum(["auth", "payment"]),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
||||
export type {
|
||||
PendingChatUnlock,
|
||||
PendingChatUnlockKind,
|
||||
PendingChatUnlockStage,
|
||||
};
|
||||
|
||||
export async function savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
@@ -33,56 +19,21 @@ export async function savePendingChatUnlock(input: {
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): Promise<void> {
|
||||
const payload: PendingChatUnlock = {
|
||||
reason: "single_message_unlock",
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
returnUrl: input.returnUrl,
|
||||
stage: input.stage,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
payload,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
await NavigationStorage.savePendingChatUnlock(input);
|
||||
}
|
||||
|
||||
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
if (Result.isErr(result)) return null;
|
||||
return parsePendingChatUnlock(result.data);
|
||||
return NavigationStorage.consumePendingChatUnlock();
|
||||
}
|
||||
|
||||
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
if (Result.isErr(result)) return null;
|
||||
const value = parsePendingChatUnlock(result.data);
|
||||
if (!value) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
return value;
|
||||
return NavigationStorage.peekPendingChatUnlock();
|
||||
}
|
||||
|
||||
export async function hasPendingChatUnlock(): Promise<boolean> {
|
||||
return (await peekPendingChatUnlock()) !== null;
|
||||
return NavigationStorage.hasPendingChatUnlock();
|
||||
}
|
||||
|
||||
export async function clearPendingChatUnlock(): Promise<void> {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
|
||||
function parsePendingChatUnlock(
|
||||
value: PendingChatUnlock | null,
|
||||
): PendingChatUnlock | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
await NavigationStorage.clearPendingChatUnlock();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
|
||||
import { AppNavigationGuard } from "@/router/app-navigation-guard";
|
||||
import { AuthProvider } from "@/stores/auth/auth-context";
|
||||
import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
|
||||
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
|
||||
@@ -42,6 +43,7 @@ export function RootProviders({ children }: RootProvidersProps) {
|
||||
<AuthStatusChecker />
|
||||
{/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */}
|
||||
<OAuthSessionSync />
|
||||
<AppNavigationGuard />
|
||||
<UserProvider>
|
||||
<UserAuthSync />
|
||||
<PaymentProvider>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isAuthenticatedUser,
|
||||
resolveAuthenticatedNavigation,
|
||||
resolveRouteGuardRedirect,
|
||||
} from "../navigation-resolver";
|
||||
import { ROUTE_BUILDERS, ROUTES } from "../routes";
|
||||
|
||||
describe("navigation resolver", () => {
|
||||
it("treats only real login providers as authenticated users", () => {
|
||||
expect(isAuthenticatedUser("notLoggedIn")).toBe(false);
|
||||
expect(isAuthenticatedUser("guest")).toBe(false);
|
||||
expect(isAuthenticatedUser("email")).toBe(true);
|
||||
expect(isAuthenticatedUser("facebook")).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps protected navigation with auth redirect for guests", () => {
|
||||
expect(
|
||||
resolveAuthenticatedNavigation({
|
||||
loginStatus: "guest",
|
||||
targetUrl: "/subscription?type=vip",
|
||||
}),
|
||||
).toBe(ROUTE_BUILDERS.authWithRedirect("/subscription?type=vip"));
|
||||
});
|
||||
|
||||
it("keeps protected navigation targets for authenticated users", () => {
|
||||
expect(
|
||||
resolveAuthenticatedNavigation({
|
||||
loginStatus: "google",
|
||||
targetUrl: "/subscription?type=topup",
|
||||
}),
|
||||
).toBe("/subscription?type=topup");
|
||||
});
|
||||
|
||||
it("redirects not logged in session routes to splash", () => {
|
||||
expect(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "notLoggedIn",
|
||||
pathname: ROUTES.chat,
|
||||
}),
|
||||
).toBe(ROUTES.splash);
|
||||
});
|
||||
|
||||
it("redirects non-real users on subscription routes to auth", () => {
|
||||
expect(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "guest",
|
||||
pathname: ROUTES.subscription,
|
||||
searchParams: "type=vip&returnTo=chat",
|
||||
}),
|
||||
).toBe(
|
||||
ROUTE_BUILDERS.authWithRedirect(
|
||||
"/subscription?type=vip&returnTo=chat",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows public and authorized routes", () => {
|
||||
expect(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "guest",
|
||||
pathname: ROUTES.coinsRules,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "email",
|
||||
pathname: ROUTES.subscription,
|
||||
searchParams: "type=vip",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getRouteAccess, isInternalRoute } from "../route-meta";
|
||||
import { ROUTES } from "../routes";
|
||||
|
||||
describe("route meta", () => {
|
||||
it("classifies static routes by access level", () => {
|
||||
expect(getRouteAccess(ROUTES.splash)).toBe("authOnly");
|
||||
expect(getRouteAccess(ROUTES.auth)).toBe("authOnly");
|
||||
expect(getRouteAccess(ROUTES.chat)).toBe("session");
|
||||
expect(getRouteAccess(ROUTES.sidebar)).toBe("session");
|
||||
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
|
||||
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
|
||||
});
|
||||
|
||||
it("classifies known dynamic chat routes", () => {
|
||||
expect(getRouteAccess("/chat/image/msg_1")).toBe("session");
|
||||
expect(getRouteAccess("/chat/deviceid/device_1")).toBe("public");
|
||||
});
|
||||
|
||||
it("treats unknown routes as public by default", () => {
|
||||
expect(getRouteAccess("/unknown")).toBe("public");
|
||||
});
|
||||
|
||||
it("accepts only internal redirect targets", () => {
|
||||
expect(isInternalRoute("/chat")).toBe(true);
|
||||
expect(isInternalRoute("/subscription?type=vip")).toBe(true);
|
||||
expect(isInternalRoute("https://example.com/chat")).toBe(false);
|
||||
expect(isInternalRoute("//example.com/chat")).toBe(false);
|
||||
expect(isInternalRoute(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
|
||||
import { resolveRouteGuardRedirect } from "./navigation-resolver";
|
||||
|
||||
export function AppNavigationGuard() {
|
||||
const authState = useAuthState();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authState.hasInitialized || authState.isLoading) return;
|
||||
|
||||
const redirectTo = resolveRouteGuardRedirect({
|
||||
loginStatus: authState.loginStatus,
|
||||
pathname,
|
||||
searchParams: window.location.search.replace(/^\?/, ""),
|
||||
});
|
||||
if (redirectTo) router.replace(redirectTo);
|
||||
}, [
|
||||
authState.hasInitialized,
|
||||
authState.isLoading,
|
||||
authState.loginStatus,
|
||||
pathname,
|
||||
router,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { LoginStatus } from "@/data/dto/auth";
|
||||
|
||||
import { getRouteAccess } from "./route-meta";
|
||||
import { ROUTE_BUILDERS, ROUTES } from "./routes";
|
||||
|
||||
export function isAuthenticatedUser(loginStatus: LoginStatus): boolean {
|
||||
return loginStatus !== "notLoggedIn" && loginStatus !== "guest";
|
||||
}
|
||||
|
||||
export function resolveAuthenticatedNavigation(input: {
|
||||
loginStatus: LoginStatus;
|
||||
targetUrl: string;
|
||||
}): string {
|
||||
if (isAuthenticatedUser(input.loginStatus)) return input.targetUrl;
|
||||
return ROUTE_BUILDERS.authWithRedirect(input.targetUrl);
|
||||
}
|
||||
|
||||
export function resolveRouteGuardRedirect(input: {
|
||||
loginStatus: LoginStatus;
|
||||
pathname: string;
|
||||
searchParams?: string;
|
||||
}): string | null {
|
||||
const access = getRouteAccess(input.pathname);
|
||||
|
||||
if (access === "session" && input.loginStatus === "notLoggedIn") {
|
||||
return ROUTES.splash;
|
||||
}
|
||||
|
||||
if (access === "realUser" && !isAuthenticatedUser(input.loginStatus)) {
|
||||
return ROUTE_BUILDERS.authWithRedirect(
|
||||
buildCurrentUrl(input.pathname, input.searchParams),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCurrentUrl(pathname: string, searchParams?: string): string {
|
||||
if (!searchParams) return pathname;
|
||||
return `${pathname}?${searchParams}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PayChannel } from "@/data/dto/payment";
|
||||
import type {
|
||||
PendingChatUnlockKind,
|
||||
PendingChatUnlockStage,
|
||||
} from "@/data/storage/navigation";
|
||||
|
||||
export type AppSubscriptionType = "vip" | "topup";
|
||||
export type AppSubscriptionReturnTo = "chat" | null;
|
||||
|
||||
export interface OpenSubscriptionInput {
|
||||
type: AppSubscriptionType;
|
||||
payChannel?: PayChannel;
|
||||
returnTo?: AppSubscriptionReturnTo;
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
export interface StartMessageUnlockInput {
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
returnUrl: string;
|
||||
stage?: PendingChatUnlockStage;
|
||||
onAuthenticated: () => void;
|
||||
}
|
||||
|
||||
export interface OpenSubscriptionForPendingUnlockInput {
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
returnUrl: string;
|
||||
type: AppSubscriptionType;
|
||||
payChannel?: PayChannel;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ROUTES } from "./routes";
|
||||
|
||||
export type RouteAccess = "public" | "session" | "realUser" | "authOnly";
|
||||
|
||||
const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
|
||||
[ROUTES.root]: "public",
|
||||
[ROUTES.splash]: "authOnly",
|
||||
[ROUTES.auth]: "authOnly",
|
||||
[ROUTES.chat]: "session",
|
||||
[ROUTES.sidebar]: "session",
|
||||
[ROUTES.subscription]: "realUser",
|
||||
[ROUTES.coinsRules]: "public",
|
||||
};
|
||||
|
||||
export function getRouteAccess(pathname: string): RouteAccess {
|
||||
if (pathname.startsWith("/chat/image/")) return "session";
|
||||
if (pathname.startsWith("/chat/deviceid/")) return "public";
|
||||
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
|
||||
}
|
||||
|
||||
export function isInternalRoute(value: string | null | undefined): boolean {
|
||||
return Boolean(value && value.startsWith("/") && !value.startsWith("//"));
|
||||
}
|
||||
@@ -48,26 +48,6 @@ export const ROUTE_BUILDERS = {
|
||||
`${ROUTES.auth}?redirect=${encodeURIComponent(redirectTo)}` as const,
|
||||
} as const;
|
||||
|
||||
/** 仅未登录态可见的路由(命中后已登录用户应跳走) */
|
||||
export const AUTH_ONLY_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.splash,
|
||||
ROUTES.auth,
|
||||
] as const;
|
||||
|
||||
/** 仅登录态可见的路由(命中后未登录用户应跳走) */
|
||||
export const PROTECTED_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.chat,
|
||||
ROUTES.sidebar,
|
||||
ROUTES.subscription,
|
||||
] as const;
|
||||
|
||||
/** 公开路由(游客态也允许访问) */
|
||||
export const PUBLIC_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.chat,
|
||||
ROUTES.sidebar,
|
||||
ROUTES.coinsRules,
|
||||
] as const;
|
||||
|
||||
/** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */
|
||||
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.splash,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { NavigationStorage } from "@/data/storage/navigation";
|
||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import {
|
||||
ROUTE_BUILDERS,
|
||||
ROUTES,
|
||||
type Route,
|
||||
} from "./routes";
|
||||
import {
|
||||
isAuthenticatedUser as resolveIsAuthenticatedUser,
|
||||
resolveAuthenticatedNavigation,
|
||||
} from "./navigation-resolver";
|
||||
import type {
|
||||
OpenSubscriptionForPendingUnlockInput,
|
||||
OpenSubscriptionInput,
|
||||
StartMessageUnlockInput,
|
||||
} from "./navigation-types";
|
||||
|
||||
export interface AppNavigator {
|
||||
push: (href: string, options?: { scroll?: boolean }) => void;
|
||||
replace: (href: string, options?: { scroll?: boolean }) => void;
|
||||
openAuth: (redirectTo?: string) => void;
|
||||
openSubscription: (input: OpenSubscriptionInput) => void;
|
||||
startMessageUnlock: (input: StartMessageUnlockInput) => void;
|
||||
openSubscriptionForPendingUnlock: (
|
||||
input: OpenSubscriptionForPendingUnlockInput,
|
||||
) => void;
|
||||
getDefaultPayChannel: () => ReturnType<typeof getDefaultPayChannelForCountryCode>;
|
||||
isAuthenticatedUser: boolean;
|
||||
}
|
||||
|
||||
export function useAppNavigator(): AppNavigator {
|
||||
const router = useRouter();
|
||||
const authState = useAuthState();
|
||||
const userState = useUserState();
|
||||
const isAuthenticatedUser = resolveIsAuthenticatedUser(authState.loginStatus);
|
||||
|
||||
function push(href: string, options?: { scroll?: boolean }): void {
|
||||
router.push(href, options);
|
||||
}
|
||||
|
||||
function replace(href: string, options?: { scroll?: boolean }): void {
|
||||
router.replace(href, options);
|
||||
}
|
||||
|
||||
function openAuth(redirectTo: string = ROUTES.chat): void {
|
||||
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
|
||||
}
|
||||
|
||||
function getDefaultPayChannel() {
|
||||
return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode);
|
||||
}
|
||||
|
||||
function openSubscription({
|
||||
type,
|
||||
payChannel = getDefaultPayChannel(),
|
||||
returnTo = null,
|
||||
replace: shouldReplace = false,
|
||||
}: OpenSubscriptionInput): void {
|
||||
const target = ROUTE_BUILDERS.subscription(type, {
|
||||
payChannel,
|
||||
returnTo: returnTo ?? undefined,
|
||||
});
|
||||
|
||||
const nextUrl = resolveAuthenticatedNavigation({
|
||||
loginStatus: authState.loginStatus,
|
||||
targetUrl: target,
|
||||
});
|
||||
|
||||
if (shouldReplace) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
}
|
||||
router.push(nextUrl);
|
||||
}
|
||||
|
||||
function startMessageUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
stage = "auth",
|
||||
onAuthenticated,
|
||||
}: StartMessageUnlockInput): void {
|
||||
if (isAuthenticatedUser) {
|
||||
onAuthenticated();
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
stage,
|
||||
});
|
||||
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
|
||||
})();
|
||||
}
|
||||
|
||||
function openSubscriptionForPendingUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
type,
|
||||
payChannel = getDefaultPayChannel(),
|
||||
}: OpenSubscriptionForPendingUnlockInput): void {
|
||||
void (async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
stage: "payment",
|
||||
});
|
||||
openSubscription({
|
||||
type,
|
||||
payChannel,
|
||||
returnTo: "chat",
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
replace,
|
||||
openAuth,
|
||||
openSubscription,
|
||||
startMessageUnlock,
|
||||
openSubscriptionForPendingUnlock,
|
||||
getDefaultPayChannel,
|
||||
isAuthenticatedUser,
|
||||
};
|
||||
}
|
||||
|
||||
export type { Route };
|
||||
@@ -8,22 +8,14 @@
|
||||
* 不再承担全局状态同步职责。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
|
||||
|
||||
function isProtectedPath(pathname: string): boolean {
|
||||
return PROTECTED_ROUTES.some((route: StaticRoute) => pathname === route);
|
||||
}
|
||||
|
||||
export function ChatAuthSync() {
|
||||
const authState = useAuthState();
|
||||
const chatDispatch = useChatDispatch();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const prevSessionKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,9 +27,6 @@ export function ChatAuthSync() {
|
||||
|
||||
if (authState.loginStatus === "notLoggedIn") {
|
||||
chatDispatch({ type: "ChatLogout" });
|
||||
if (isProtectedPath(pathname)) {
|
||||
router.replace(ROUTES.splash);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,8 +54,6 @@ export function ChatAuthSync() {
|
||||
authState.isLoading,
|
||||
authState.loginStatus,
|
||||
chatDispatch,
|
||||
pathname,
|
||||
router,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user