refactor(router): introduce app navigation manager
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { LoginStatus } from "@/data/dto/auth";
|
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";
|
import { AppEnvUtil, BrowserDetector } from "@/utils";
|
||||||
|
|
||||||
export interface ExternalBrowserPromptState {
|
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(
|
export function getInsufficientCreditsSubscriptionType(
|
||||||
isVip: boolean,
|
isVip: boolean,
|
||||||
): "vip" | "topup" {
|
): "vip" | "topup" {
|
||||||
return isVip ? "topup" : "vip";
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||||
import { useUserState } from "@/stores/user/user-context";
|
import { useUserState } from "@/stores/user/user-context";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import {
|
import {
|
||||||
openChatInExternalBrowser,
|
openChatInExternalBrowser,
|
||||||
recordExternalBrowserPromptShown,
|
recordExternalBrowserPromptShown,
|
||||||
resolveExternalBrowserPromptEligibility,
|
resolveExternalBrowserPromptEligibility,
|
||||||
} from "@/lib/chat/chat_external_browser";
|
} from "@/lib/chat/chat_external_browser";
|
||||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ import {
|
|||||||
} from "./components";
|
} from "./components";
|
||||||
import {
|
import {
|
||||||
deriveIsGuest,
|
deriveIsGuest,
|
||||||
getChatPaywallNavigationUrl,
|
|
||||||
getInsufficientCreditsSubscriptionType,
|
getInsufficientCreditsSubscriptionType,
|
||||||
isChatDevelopmentEnvironment,
|
isChatDevelopmentEnvironment,
|
||||||
shouldStartExternalBrowserPrompt,
|
shouldStartExternalBrowserPrompt,
|
||||||
@@ -45,7 +43,7 @@ export function ChatScreen() {
|
|||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
const router = useRouter();
|
const navigator = useAppNavigator();
|
||||||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const {
|
const {
|
||||||
@@ -122,16 +120,10 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMessageLimitUnlock(): void {
|
function handleMessageLimitUnlock(): void {
|
||||||
const defaultPayChannel = getDefaultPayChannelForCountryCode(
|
navigator.openSubscription({
|
||||||
userState.currentUser?.countryCode,
|
type: getInsufficientCreditsSubscriptionType(userState.isVip),
|
||||||
);
|
returnTo: "chat",
|
||||||
router.push(
|
});
|
||||||
getChatPaywallNavigationUrl(
|
|
||||||
authState.loginStatus,
|
|
||||||
getInsufficientCreditsSubscriptionType(userState.isVip),
|
|
||||||
defaultPayChannel,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockVoiceMessage(messageId: string): void {
|
function handleUnlockVoiceMessage(messageId: string): void {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
*/
|
*/
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Lock, Menu } from "lucide-react";
|
import { Lock, Menu } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
|
||||||
import styles from "./chat-header.module.css";
|
import styles from "./chat-header.module.css";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export interface ChatHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
|
export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
|
||||||
const router = useRouter();
|
const navigator = useAppNavigator();
|
||||||
|
|
||||||
if (isGuest) {
|
if (isGuest) {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +27,7 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.guestBanner}
|
className={styles.guestBanner}
|
||||||
onClick={() => router.push(ROUTES.auth)}
|
onClick={() => navigator.openAuth(ROUTES.chat)}
|
||||||
aria-label="Sign up to unlock more features"
|
aria-label="Sign up to unlock more features"
|
||||||
>
|
>
|
||||||
<Lock className={styles.guestBannerIcon} size={16} aria-hidden="true" />
|
<Lock className={styles.guestBannerIcon} size={16} aria-hidden="true" />
|
||||||
@@ -46,7 +46,7 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.menuButton}
|
className={styles.menuButton}
|
||||||
onClick={() => router.push(ROUTES.sidebar)}
|
onClick={() => navigator.push(ROUTES.sidebar)}
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
>
|
>
|
||||||
<Menu className={styles.menuIcon} size={24} aria-hidden="true" />
|
<Menu className={styles.menuIcon} size={24} aria-hidden="true" />
|
||||||
|
|||||||
@@ -16,9 +16,7 @@
|
|||||||
* 不做的事:
|
* 不做的事:
|
||||||
* - 不直接管理 state machine(chat 机器不感知 UI 层)
|
* - 不直接管理 state machine(chat 机器不感知 UI 层)
|
||||||
*/
|
*/
|
||||||
import { useRouter } from "next/navigation";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
|
||||||
import { ROUTE_BUILDERS } from "@/router/routes";
|
|
||||||
|
|
||||||
import styles from "./chat-insufficient-credits-banner.module.css";
|
import styles from "./chat-insufficient-credits-banner.module.css";
|
||||||
|
|
||||||
@@ -26,7 +24,7 @@ export interface ChatInsufficientCreditsBannerProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
ctaLabel?: string;
|
ctaLabel?: string;
|
||||||
/**
|
/**
|
||||||
* 自定义点击回调(不传则默认 router.push(topup subscription))
|
* 自定义点击回调(不传则默认走统一订阅导航)
|
||||||
* - 测试时可传 mock
|
* - 测试时可传 mock
|
||||||
* - 嵌入其他场景时(如 nested overlay)可传自定义
|
* - 嵌入其他场景时(如 nested overlay)可传自定义
|
||||||
*/
|
*/
|
||||||
@@ -38,7 +36,7 @@ export function ChatInsufficientCreditsBanner({
|
|||||||
ctaLabel = "Top up credits to continue",
|
ctaLabel = "Top up credits to continue",
|
||||||
onUnlock,
|
onUnlock,
|
||||||
}: ChatInsufficientCreditsBannerProps) {
|
}: ChatInsufficientCreditsBannerProps) {
|
||||||
const router = useRouter();
|
const navigator = useAppNavigator();
|
||||||
const titleLines = title.split("\n");
|
const titleLines = title.split("\n");
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@@ -46,7 +44,7 @@ export function ChatInsufficientCreditsBanner({
|
|||||||
onUnlock();
|
onUnlock();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(ROUTE_BUILDERS.subscription("topup"));
|
navigator.openSubscription({ type: "topup", returnTo: "chat" });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
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 {
|
import {
|
||||||
consumePendingChatUnlock,
|
consumePendingChatUnlock,
|
||||||
peekPendingChatUnlock,
|
peekPendingChatUnlock,
|
||||||
savePendingChatUnlock,
|
|
||||||
type PendingChatUnlock,
|
type PendingChatUnlock,
|
||||||
type PendingChatUnlockKind,
|
type PendingChatUnlockKind,
|
||||||
} from "@/lib/navigation/chat_unlock_session";
|
} 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";
|
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
||||||
|
|
||||||
@@ -40,14 +36,10 @@ export function useChatUnlockNavigationFlow({
|
|||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
||||||
const router = useRouter();
|
const navigator = useAppNavigator();
|
||||||
const authState = useAuthState();
|
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
const chatState = useChatState();
|
const chatState = useChatState();
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const isAuthenticatedUser =
|
|
||||||
authState.loginStatus !== "notLoggedIn" &&
|
|
||||||
authState.loginStatus !== "guest";
|
|
||||||
const unlockPaywallRequest = getScopedUnlockPaywallRequest({
|
const unlockPaywallRequest = getScopedUnlockPaywallRequest({
|
||||||
request: chatState.unlockPaywallRequest,
|
request: chatState.unlockPaywallRequest,
|
||||||
expectedKind,
|
expectedKind,
|
||||||
@@ -55,7 +47,7 @@ export function useChatUnlockNavigationFlow({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatState.historyLoaded || !isAuthenticatedUser) return;
|
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -93,7 +85,7 @@ export function useChatUnlockNavigationFlow({
|
|||||||
chatState.historyLoaded,
|
chatState.historyLoaded,
|
||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
isAuthenticatedUser,
|
navigator.isAuthenticatedUser,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -101,23 +93,17 @@ export function useChatUnlockNavigationFlow({
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
kind: PendingChatUnlockKind,
|
kind: PendingChatUnlockKind,
|
||||||
): void {
|
): void {
|
||||||
if (!isAuthenticatedUser) {
|
navigator.startMessageUnlock({
|
||||||
void (async () => {
|
|
||||||
await savePendingChatUnlock({
|
|
||||||
messageId,
|
|
||||||
kind,
|
|
||||||
returnUrl,
|
|
||||||
stage: "auth",
|
|
||||||
});
|
|
||||||
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
|
|
||||||
})();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
chatDispatch({
|
|
||||||
type: "ChatUnlockMessageRequested",
|
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
|
returnUrl,
|
||||||
|
onAuthenticated: () => {
|
||||||
|
chatDispatch({
|
||||||
|
type: "ChatUnlockMessageRequested",
|
||||||
|
messageId,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,24 +114,13 @@ export function useChatUnlockNavigationFlow({
|
|||||||
function confirmInsufficientCreditsDialog(): void {
|
function confirmInsufficientCreditsDialog(): void {
|
||||||
if (!unlockPaywallRequest) return;
|
if (!unlockPaywallRequest) return;
|
||||||
|
|
||||||
void (async () => {
|
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
||||||
await savePendingChatUnlock({
|
navigator.openSubscriptionForPendingUnlock({
|
||||||
messageId: unlockPaywallRequest.messageId,
|
messageId: unlockPaywallRequest.messageId,
|
||||||
kind: unlockPaywallRequest.kind,
|
kind: unlockPaywallRequest.kind,
|
||||||
returnUrl,
|
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 {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import type { LoginStatus } from "@/data/dto/auth";
|
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 { usePaymentDispatch, usePaymentState } from "@/stores/payment";
|
||||||
import { useUserState } from "@/stores/user/user-context";
|
import { useUserState } from "@/stores/user/user-context";
|
||||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||||
@@ -55,7 +54,7 @@ export function useFirstRechargeOfferBanner({
|
|||||||
historyLoaded,
|
historyLoaded,
|
||||||
loginStatus,
|
loginStatus,
|
||||||
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
|
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
|
||||||
const router = useRouter();
|
const navigator = useAppNavigator();
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
@@ -127,15 +126,7 @@ export function useFirstRechargeOfferBanner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function claim(): void {
|
function claim(): void {
|
||||||
const defaultPayChannel = getDefaultPayChannelForCountryCode(
|
navigator.openSubscription({ type: "vip", returnTo: "chat" });
|
||||||
userState.currentUser?.countryCode,
|
|
||||||
);
|
|
||||||
router.push(
|
|
||||||
ROUTE_BUILDERS.subscription("vip", {
|
|
||||||
payChannel: defaultPayChannel,
|
|
||||||
returnTo: "chat",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { BackButton } from "@/app/_components";
|
import { BackButton } from "@/app/_components";
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useUserDispatch, useUserState } from "@/stores/user/user-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";
|
const FALLBACK_USERNAME = "User name";
|
||||||
|
|
||||||
export function SidebarScreen() {
|
export function SidebarScreen() {
|
||||||
const router = useRouter();
|
const navigator = useAppNavigator();
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
const userDispatch = useUserDispatch();
|
const userDispatch = useUserDispatch();
|
||||||
const auth = useAuthState();
|
const auth = useAuthState();
|
||||||
@@ -60,16 +59,6 @@ export function SidebarScreen() {
|
|||||||
|
|
||||||
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
||||||
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
|
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 (
|
return (
|
||||||
<MobileShell background="#fcf3f4">
|
<MobileShell background="#fcf3f4">
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
@@ -86,7 +75,7 @@ export function SidebarScreen() {
|
|||||||
name={name}
|
name={name}
|
||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
isLoading={isInitializingUser}
|
isLoading={isInitializingUser}
|
||||||
onLoginClick={() => router.push(ROUTES.auth)}
|
onLoginClick={() => navigator.openAuth(ROUTES.sidebar)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -96,10 +85,10 @@ export function SidebarScreen() {
|
|||||||
onActivateVip={
|
onActivateVip={
|
||||||
sidebarState === "vip"
|
sidebarState === "vip"
|
||||||
? undefined
|
? undefined
|
||||||
: () => router.push(vipSubscriptionUrl)
|
: () => navigator.openSubscription({ type: "vip" })
|
||||||
}
|
}
|
||||||
onTopUp={() => router.push(topUpSubscriptionUrl)}
|
onTopUp={() => navigator.openSubscription({ type: "topup" })}
|
||||||
onRulesClick={() => router.push(ROUTES.coinsRules)}
|
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from "./chat/chat_storage";
|
|||||||
export * from "./chat/local_chat_db";
|
export * from "./chat/local_chat_db";
|
||||||
export * from "./chat/local_chat_storage";
|
export * from "./chat/local_chat_storage";
|
||||||
export * from "./chat/local_message";
|
export * from "./chat/local_message";
|
||||||
|
export * from "./navigation/navigation_storage";
|
||||||
export * from "./payment/pending_payment_order_storage";
|
export * from "./payment/pending_payment_order_storage";
|
||||||
export * from "./user/iuser_storage";
|
export * from "./user/iuser_storage";
|
||||||
export * from "./user/user_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";
|
"use client";
|
||||||
|
|
||||||
import { z } from "zod";
|
import {
|
||||||
|
NavigationStorage,
|
||||||
|
type PendingChatImageReturn,
|
||||||
|
} from "@/data/storage/navigation";
|
||||||
|
|
||||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
export type { PendingChatImageReturn };
|
||||||
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 async function savePendingChatImageReturn(input: {
|
export async function savePendingChatImageReturn(input: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const payload: PendingChatImageReturn = {
|
await NavigationStorage.savePendingChatImageReturn(input);
|
||||||
reason: "image_paywall",
|
|
||||||
messageId: input.messageId,
|
|
||||||
returnUrl: input.returnUrl,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
await SessionAsyncUtil.setJson(
|
|
||||||
StorageKeys.pendingChatImageReturn,
|
|
||||||
payload,
|
|
||||||
PendingChatImageReturnSchema,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
return NavigationStorage.consumePendingChatImageReturn();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
"use client";
|
"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";
|
export type {
|
||||||
import { Result, SessionAsyncUtil } from "@/utils";
|
PendingChatUnlock,
|
||||||
|
PendingChatUnlockKind,
|
||||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
PendingChatUnlockStage,
|
||||||
|
};
|
||||||
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 async function savePendingChatUnlock(input: {
|
export async function savePendingChatUnlock(input: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -33,56 +19,21 @@ export async function savePendingChatUnlock(input: {
|
|||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
stage: PendingChatUnlockStage;
|
stage: PendingChatUnlockStage;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const payload: PendingChatUnlock = {
|
await NavigationStorage.savePendingChatUnlock(input);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
return NavigationStorage.consumePendingChatUnlock();
|
||||||
StorageKeys.pendingChatUnlock,
|
|
||||||
PendingChatUnlockSchema,
|
|
||||||
);
|
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
|
||||||
if (Result.isErr(result)) return null;
|
|
||||||
return parsePendingChatUnlock(result.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
return NavigationStorage.peekPendingChatUnlock();
|
||||||
StorageKeys.pendingChatUnlock,
|
|
||||||
PendingChatUnlockSchema,
|
|
||||||
);
|
|
||||||
if (Result.isErr(result)) return null;
|
|
||||||
const value = parsePendingChatUnlock(result.data);
|
|
||||||
if (!value) {
|
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasPendingChatUnlock(): Promise<boolean> {
|
export async function hasPendingChatUnlock(): Promise<boolean> {
|
||||||
return (await peekPendingChatUnlock()) !== null;
|
return NavigationStorage.hasPendingChatUnlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingChatUnlock(): Promise<void> {
|
export async function clearPendingChatUnlock(): Promise<void> {
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
await NavigationStorage.clearPendingChatUnlock();
|
||||||
}
|
|
||||||
|
|
||||||
function parsePendingChatUnlock(
|
|
||||||
value: PendingChatUnlock | null,
|
|
||||||
): PendingChatUnlock | null {
|
|
||||||
if (!value) return null;
|
|
||||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
|
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
|
||||||
|
import { AppNavigationGuard } from "@/router/app-navigation-guard";
|
||||||
import { AuthProvider } from "@/stores/auth/auth-context";
|
import { AuthProvider } from "@/stores/auth/auth-context";
|
||||||
import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
|
import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
|
||||||
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
|
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
|
||||||
@@ -42,6 +43,7 @@ export function RootProviders({ children }: RootProvidersProps) {
|
|||||||
<AuthStatusChecker />
|
<AuthStatusChecker />
|
||||||
{/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */}
|
{/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */}
|
||||||
<OAuthSessionSync />
|
<OAuthSessionSync />
|
||||||
|
<AppNavigationGuard />
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<UserAuthSync />
|
<UserAuthSync />
|
||||||
<PaymentProvider>
|
<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,
|
`${ROUTES.auth}?redirect=${encodeURIComponent(redirectTo)}` as const,
|
||||||
} 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 使用 */
|
/** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */
|
||||||
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
||||||
ROUTES.splash,
|
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 { useEffect, useRef } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { AuthStorage } from "@/data/storage/auth";
|
import { AuthStorage } from "@/data/storage/auth";
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-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() {
|
export function ChatAuthSync() {
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
const prevSessionKeyRef = useRef<string | null>(null);
|
const prevSessionKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,9 +27,6 @@ export function ChatAuthSync() {
|
|||||||
|
|
||||||
if (authState.loginStatus === "notLoggedIn") {
|
if (authState.loginStatus === "notLoggedIn") {
|
||||||
chatDispatch({ type: "ChatLogout" });
|
chatDispatch({ type: "ChatLogout" });
|
||||||
if (isProtectedPath(pathname)) {
|
|
||||||
router.replace(ROUTES.splash);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +54,6 @@ export function ChatAuthSync() {
|
|||||||
authState.isLoading,
|
authState.isLoading,
|
||||||
authState.loginStatus,
|
authState.loginStatus,
|
||||||
chatDispatch,
|
chatDispatch,
|
||||||
pathname,
|
|
||||||
router,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user