refactor(app): tighten chat and sync boundaries

This commit is contained in:
2026-07-01 14:03:25 +08:00
parent f6e7adbe96
commit 760e4fe343
19 changed files with 215 additions and 677 deletions
-17
View File
@@ -1,17 +0,0 @@
import * as Sentry from "@sentry/nextjs";
export const dynamic = "force-dynamic";
class SentryExampleAPIError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = "SentryExampleAPIError";
}
}
// A faulty API route to test Sentry's error monitoring
export function GET() {
Sentry.logger.info("Sentry example API called");
throw new SentryExampleAPIError(
"This error is raised on the backend called by the example page.",
);
}
+12 -79
View File
@@ -7,18 +7,12 @@ 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 { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { import {
openChatInExternalBrowser, openChatInExternalBrowser,
recordExternalBrowserPromptShown, recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility, resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser"; } from "@/lib/chat/chat_external_browser";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
savePendingChatUnlock,
type PendingChatUnlockKind,
} from "@/lib/navigation/chat_unlock_session";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
@@ -27,7 +21,7 @@ import {
ChatArea, ChatArea,
ChatHeader, ChatHeader,
ChatInputBar, ChatInputBar,
ChatQuotaExhaustedBanner, ChatInsufficientCreditsBanner,
ExternalBrowserDialog, ExternalBrowserDialog,
HistoryUnlockDialog, HistoryUnlockDialog,
InsufficientCreditsDialog, InsufficientCreditsDialog,
@@ -40,6 +34,7 @@ import {
isChatDevelopmentEnvironment, isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt, shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers"; } from "./chat-screen.helpers";
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
import styles from "./components/chat-screen.module.css"; import styles from "./components/chat-screen.module.css";
export function ChatScreen() { export function ChatScreen() {
@@ -50,13 +45,15 @@ export function ChatScreen() {
const router = useRouter(); const router = useRouter();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] = const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false); useState(false);
const unlockPaywallRequest = state.unlockPaywallRequest; const {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({ returnUrl: ROUTES.chat });
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus); const isGuest = deriveIsGuest(authState.loginStatus);
const isAuthenticatedUser =
authState.loginStatus !== "notLoggedIn" &&
authState.loginStatus !== "guest";
// 发送能力由后端统一返回,当前只处理积分不足引导。 // 发送能力由后端统一返回,当前只处理积分不足引导。
const showMessageLimitBanner = const showMessageLimitBanner =
@@ -108,75 +105,11 @@ export function ChatScreen() {
authState.loginStatus, authState.loginStatus,
]); ]);
useEffect(() => {
if (!state.historyLoaded || !isAuthenticatedUser) return;
const pending = peekPendingChatUnlock();
if (!pending || pending.returnUrl !== ROUTES.chat) return;
const consumed = consumePendingChatUnlock();
if (!consumed) return;
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
kind: consumed.kind,
});
}, [
chatDispatch,
isAuthenticatedUser,
state.historyLoaded,
]);
function handleCloseInsufficientCreditsDialog(): void {
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
}
function handleConfirmInsufficientCreditsDialog(): void {
if (!unlockPaywallRequest) return;
savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl: ROUTES.chat,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" },
),
);
}
async function handleOpenExternalBrowser(): Promise<void> { async function handleOpenExternalBrowser(): Promise<void> {
setShowExternalBrowserDialog(false); setShowExternalBrowserDialog(false);
await openChatInExternalBrowser(); await openChatInExternalBrowser();
} }
function requestMessageUnlock(
messageId: string,
kind: PendingChatUnlockKind,
): void {
if (!isAuthenticatedUser) {
savePendingChatUnlock({
messageId,
kind,
returnUrl: ROUTES.chat,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(ROUTES.chat));
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
kind,
});
}
function handleUnlockPrivateMessage(messageId: string): void { function handleUnlockPrivateMessage(messageId: string): void {
requestMessageUnlock(messageId, "private"); requestMessageUnlock(messageId, "private");
} }
@@ -222,7 +155,7 @@ export function ChatScreen() {
/> />
{showMessageLimitBanner ? ( {showMessageLimitBanner ? (
<ChatQuotaExhaustedBanner <ChatInsufficientCreditsBanner
title={messageLimitTitle} title={messageLimitTitle}
ctaLabel={messageLimitCtaLabel} ctaLabel={messageLimitCtaLabel}
onUnlock={handleMessageLimitUnlock} onUnlock={handleMessageLimitUnlock}
@@ -258,8 +191,8 @@ export function ChatScreen() {
creditBalance={unlockPaywallRequest?.creditBalance ?? 0} creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0} requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0} shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={handleCloseInsufficientCreditsDialog} onClose={closeInsufficientCreditsDialog}
onConfirm={handleConfirmInsufficientCreditsDialog} onConfirm={confirmInsufficientCreditsDialog}
/> />
</div> </div>
</MobileShell> </MobileShell>
@@ -1,50 +0,0 @@
.panel {
min-height: 170px;
padding: 24px 16px 20px;
border-top: 1px solid rgba(246, 87, 160, 0.14);
background: #fff1f4;
}
.voiceCard {
width: 142px;
min-height: 153px;
padding: 12px 12px 14px;
border: 1px solid rgba(30, 30, 30, 0.08);
border-radius: 18px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(30, 30, 30, 0.03);
color: #1e1e1e;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.voiceCard:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 3px;
}
.voiceIcon {
display: block;
width: 78px;
height: 78px;
object-fit: contain;
}
.voiceTitle,
.voiceMinutes {
font-family: var(--font-athelas), Athelas, serif;
font-size: 14px;
line-height: 1.08;
text-align: center;
}
.voiceTitle {
margin-top: 8px;
}
.voiceMinutes {
color: var(--color-accent, #f84d96);
}
@@ -1,37 +0,0 @@
"use client";
import Image from "next/image";
import styles from "./chat-input-action-panel.module.css";
export interface ChatInputActionPanelProps {
voiceMinutesRemaining: number;
onVoiceMessageClick?: () => void;
}
export function ChatInputActionPanel({
voiceMinutesRemaining,
onVoiceMessageClick,
}: ChatInputActionPanelProps) {
return (
<div className={styles.panel}>
<button
type="button"
className={styles.voiceCard}
onClick={onVoiceMessageClick}
aria-label={`Voice message, ${voiceMinutesRemaining} minutes remaining`}
>
<Image
src="/images/chat/ic_voicemessage_act.png"
alt=""
width={78}
height={78}
className={styles.voiceIcon}
aria-hidden="true"
/>
<span className={styles.voiceTitle}>voice message</span>
<span className={styles.voiceMinutes}>({voiceMinutesRemaining}min)</span>
</button>
</div>
);
}
@@ -1,4 +1,4 @@
/* ChatQuotaExhaustedBanner 游客配额耗尽横幅 /* ChatInsufficientCreditsBanner 积分不足横幅
* 视觉规格与设计稿对齐 * 视觉规格与设计稿对齐
* - 品红渐变背景 splash gradient 一致 * - 品红渐变背景 splash gradient 一致
* - 居中文案 + 渐变 pill 按钮 * - 居中文案 + 渐变 pill 按钮
@@ -1,6 +1,6 @@
"use client"; "use client";
/** /**
* ChatQuotaExhaustedBanner * ChatInsufficientCreditsBanner
* *
* *
* - cannotSendReason="insufficient_credits" * - cannotSendReason="insufficient_credits"
@@ -8,38 +8,36 @@
* 稿 * 稿
* - splash * - splash
* - / * - /
* - pill /subscription * - pill /subscription?type=topup
* *
* *
* - `src/app/sidebar/components/vip-benefits-card.tsx` "Activate VIP Membership" * - top up
* - VIP
* *
* *
* - state machinechat UI * - state machinechat UI
* - total<=0 "耗尽"
*/ */
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ROUTES } from "@/router/routes"; import { ROUTE_BUILDERS } from "@/router/routes";
import styles from "./chat-quota-exhausted-banner.module.css"; import styles from "./chat-insufficient-credits-banner.module.css";
export interface ChatQuotaExhaustedBannerProps { export interface ChatInsufficientCreditsBannerProps {
title?: string; title?: string;
ctaLabel?: string; ctaLabel?: string;
/** /**
* router.push(ROUTES.subscription) * router.push(topup subscription)
* - mock * - mock
* - nested overlay * - nested overlay
*/ */
onUnlock?: () => void; onUnlock?: () => void;
} }
export function ChatQuotaExhaustedBanner({ export function ChatInsufficientCreditsBanner({
title = "Insufficient credits\nTop up to continue chatting", title = "Insufficient credits\nTop up to continue chatting",
ctaLabel = "Top up credits to continue", ctaLabel = "Top up credits to continue",
onUnlock, onUnlock,
}: ChatQuotaExhaustedBannerProps) { }: ChatInsufficientCreditsBannerProps) {
const router = useRouter(); const router = useRouter();
const titleLines = title.split("\n"); const titleLines = title.split("\n");
@@ -48,7 +46,7 @@ export function ChatQuotaExhaustedBanner({
onUnlock(); onUnlock();
return; return;
} }
router.push(`${ROUTES.subscription}?type=vip`); router.push(ROUTE_BUILDERS.subscription("topup"));
}; };
return ( return (
@@ -56,7 +54,7 @@ export function ChatQuotaExhaustedBanner({
className={styles.banner} className={styles.banner}
role="status" role="status"
aria-live="polite" aria-live="polite"
data-testid="chat-quota-exhausted-banner" data-testid="chat-insufficient-credits-banner"
> >
<p className={styles.text}> <p className={styles.text}>
{titleLines.map((line, index) => ( {titleLines.map((line, index) => (
+1 -2
View File
@@ -6,10 +6,9 @@ export * from "./ai-disclosure-banner";
export * from "./browser-hint-overlay"; export * from "./browser-hint-overlay";
export * from "./chat-area"; export * from "./chat-area";
export * from "./chat-header"; export * from "./chat-header";
export * from "./chat-input-action-panel"; export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar"; export * from "./chat-input-bar";
export * from "./chat-input-text-field"; export * from "./chat-input-text-field";
export * from "./chat-quota-exhausted-banner";
export * from "./chat-send-button"; export * from "./chat-send-button";
export * from "./date-header"; export * from "./date-header";
export * from "./external-browser-dialog"; export * from "./external-browser-dialog";
@@ -0,0 +1,164 @@
"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 { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
export interface UseChatUnlockNavigationFlowInput {
returnUrl: string;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
}
export interface UseChatUnlockNavigationFlowOutput {
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
requestMessageUnlock: (
messageId: string,
kind: PendingChatUnlockKind,
) => void;
closeInsufficientCreditsDialog: () => void;
confirmInsufficientCreditsDialog: () => void;
}
export function useChatUnlockNavigationFlow({
returnUrl,
expectedKind,
expectedMessageId,
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const router = useRouter();
const authState = useAuthState();
const userState = useUserState();
const chatState = useChatState();
const chatDispatch = useChatDispatch();
const isAuthenticatedUser =
authState.loginStatus !== "notLoggedIn" &&
authState.loginStatus !== "guest";
const unlockPaywallRequest = getScopedUnlockPaywallRequest({
request: chatState.unlockPaywallRequest,
expectedKind,
expectedMessageId,
});
useEffect(() => {
if (!chatState.historyLoaded || !isAuthenticatedUser) return;
const pending = peekPendingChatUnlock();
if (
!pending ||
pending.returnUrl !== returnUrl ||
!matchesPendingUnlockScope({
pending,
expectedKind,
expectedMessageId,
})
) {
return;
}
const consumed = consumePendingChatUnlock();
if (!consumed) return;
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
kind: consumed.kind,
});
}, [
chatDispatch,
chatState.historyLoaded,
expectedKind,
expectedMessageId,
isAuthenticatedUser,
returnUrl,
]);
function requestMessageUnlock(
messageId: string,
kind: PendingChatUnlockKind,
): void {
if (!isAuthenticatedUser) {
savePendingChatUnlock({
messageId,
kind,
returnUrl,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
kind,
});
}
function closeInsufficientCreditsDialog(): void {
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
}
function confirmInsufficientCreditsDialog(): void {
if (!unlockPaywallRequest) return;
savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" },
),
);
}
return {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
};
}
function getScopedUnlockPaywallRequest(input: {
request: ChatUnlockPaywallRequest | null;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
}): ChatUnlockPaywallRequest | null {
const { request, expectedKind, expectedMessageId } = input;
if (!request) return null;
if (expectedKind && request.kind !== expectedKind) return null;
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
return request;
}
function matchesPendingUnlockScope(input: {
pending: PendingChatUnlock;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
}): boolean {
const { pending, expectedKind, expectedMessageId } = input;
if (expectedKind && pending.kind !== expectedKind) return false;
if (expectedMessageId && pending.messageId !== expectedMessageId) {
return false;
}
return true;
}
@@ -1,25 +1,17 @@
"use client"; "use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
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 {
consumePendingChatUnlock,
peekPendingChatUnlock,
savePendingChatUnlock,
} from "@/lib/navigation/chat_unlock_session";
import { getInsufficientCreditsSubscriptionType } from "../../chat-screen.helpers";
import { import {
FullscreenImageViewer, FullscreenImageViewer,
HistoryUnlockDialog, HistoryUnlockDialog,
InsufficientCreditsDialog, InsufficientCreditsDialog,
} from "../../components"; } from "../../components";
import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow";
import styles from "./chat-image-viewer-screen.module.css"; import styles from "./chat-image-viewer-screen.module.css";
export interface ChatImageViewerScreenProps { export interface ChatImageViewerScreenProps {
@@ -30,95 +22,29 @@ export function ChatImageViewerScreen({
messageId, messageId,
}: ChatImageViewerScreenProps) { }: ChatImageViewerScreenProps) {
const router = useRouter(); const router = useRouter();
const authState = useAuthState();
const userState = useUserState();
const chatState = useChatState(); const chatState = useChatState();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const returnUrl = ROUTE_BUILDERS.chatImage(messageId); const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
const unlockPaywallRequest = const {
chatState.unlockPaywallRequest?.kind === "image" && unlockPaywallRequest,
chatState.unlockPaywallRequest.messageId === messageId requestMessageUnlock,
? chatState.unlockPaywallRequest closeInsufficientCreditsDialog,
: null; confirmInsufficientCreditsDialog,
const isAuthenticatedUser = } = useChatUnlockNavigationFlow({
authState.loginStatus !== "notLoggedIn" && returnUrl,
authState.loginStatus !== "guest"; expectedKind: "image",
expectedMessageId: messageId,
});
const message = chatState.messages.find( const message = chatState.messages.find(
(item) => item.id === messageId && item.imageUrl, (item) => item.id === messageId && item.imageUrl,
); );
useEffect(() => {
if (!chatState.historyLoaded || !isAuthenticatedUser) return;
const pending = peekPendingChatUnlock();
if (
!pending ||
pending.kind !== "image" ||
pending.messageId !== messageId ||
pending.returnUrl !== returnUrl
) {
return;
}
const consumed = consumePendingChatUnlock();
if (!consumed) return;
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
kind: consumed.kind,
});
}, [
chatDispatch,
chatState.historyLoaded,
isAuthenticatedUser,
messageId,
returnUrl,
]);
const handleCloseInsufficientCreditsDialog = () => {
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
};
const handleConfirmInsufficientCreditsDialog = () => {
if (!unlockPaywallRequest) return;
savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" },
),
);
};
const handleClose = () => { const handleClose = () => {
router.push(ROUTES.chat); router.push(ROUTES.chat);
}; };
const handleUnlockImagePaywall = () => { const handleUnlockImagePaywall = () => {
if (!isAuthenticatedUser) { requestMessageUnlock(messageId, "image");
savePendingChatUnlock({
messageId,
kind: "image",
returnUrl,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
kind: "image",
});
}; };
const historyUnlockDialog = ( const historyUnlockDialog = (
@@ -136,8 +62,8 @@ export function ChatImageViewerScreen({
creditBalance={unlockPaywallRequest?.creditBalance ?? 0} creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0} requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0} shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={handleCloseInsufficientCreditsDialog} onClose={closeInsufficientCreditsDialog}
onConfirm={handleConfirmInsufficientCreditsDialog} onConfirm={confirmInsufficientCreditsDialog}
/> />
</> </>
); );
-237
View File
@@ -1,237 +0,0 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import Head from "next/head";
import { useEffect, useState } from "react";
class SentryExampleFrontendError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = "SentryExampleFrontendError";
}
}
export default function Page() {
const [hasSentError, setHasSentError] = useState(false);
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
Sentry.logger.info("Sentry example page loaded");
async function checkConnectivity() {
const result = await Sentry.diagnoseSdkConnectivity();
setIsConnected(result !== "sentry-unreachable");
}
checkConnectivity();
}, []);
return (
<div>
<Head>
<title>sentry-example-page</title>
<meta name="description" content="Test Sentry for your Next.js app!" />
</Head>
<main>
<div className="flex-spacer" />
<svg
height="40"
width="40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Sentry logo"
>
<path
d="M21.85 2.995a3.698 3.698 0 0 1 1.353 1.354l16.303 28.278a3.703 3.703 0 0 1-1.354 5.053 3.694 3.694 0 0 1-1.848.496h-3.828a31.149 31.149 0 0 0 0-3.09h3.815a.61.61 0 0 0 .537-.917L20.523 5.893a.61.61 0 0 0-1.057 0l-3.739 6.494a28.948 28.948 0 0 1 9.63 10.453 28.988 28.988 0 0 1 3.499 13.78v1.542h-9.852v-1.544a19.106 19.106 0 0 0-2.182-8.85 19.08 19.08 0 0 0-6.032-6.829l-1.85 3.208a15.377 15.377 0 0 1 6.382 12.484v1.542H3.696A3.694 3.694 0 0 1 0 34.473c0-.648.17-1.286.494-1.849l2.33-4.074a8.562 8.562 0 0 1 2.689 1.536L3.158 34.17a.611.611 0 0 0 .538.917h8.448a12.481 12.481 0 0 0-6.037-9.09l-1.344-.772 4.908-8.545 1.344.77a22.16 22.16 0 0 1 7.705 7.444 22.193 22.193 0 0 1 3.316 10.193h3.699a25.892 25.892 0 0 0-3.811-12.033 25.856 25.856 0 0 0-9.046-8.796l-1.344-.772 5.269-9.136a3.698 3.698 0 0 1 3.2-1.849c.648 0 1.285.17 1.847.495Z"
fill="currentcolor"
/>
</svg>
<h1>sentry-example-page</h1>
<p className="description">
Click the button below, and view the sample error on the Sentry{" "}
<a
target="_blank"
rel="noopener"
href="https://shenzhen-banlv-zhidao-technolo.sentry.io/issues/?project=4511612532228096"
>
Issues Page
</a>
. For more details about setting up Sentry,{" "}
<a
target="_blank"
rel="noopener"
href="https://docs.sentry.io/platforms/javascript/guides/nextjs/"
>
read our docs
</a>
.
</p>
<button
type="button"
onClick={async () => {
Sentry.logger.info("User clicked the button, throwing a sample error");
await Sentry.startSpan(
{
name: "Example Frontend/Backend Span",
op: "test",
},
async () => {
const res = await fetch("/api/sentry-example-api");
if (!res.ok) {
setHasSentError(true);
}
},
);
throw new SentryExampleFrontendError(
"This error is raised on the frontend of the example page.",
);
}}
disabled={!isConnected}
>
<span>Throw Sample Error</span>
</button>
{hasSentError ? (
<p className="success">Error sent to Sentry.</p>
) : !isConnected ? (
<div className="connectivity-error">
<p>
It looks like network requests to Sentry are being blocked, which
will prevent errors from being captured. Try disabling your
ad-blocker to complete the test.
</p>
</div>
) : (
<div className="success_placeholder" />
)}
<div className="flex-spacer" />
</main>
<style>{`
main {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
padding: 16px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
}
h1 {
padding: 0px 4px;
border-radius: 4px;
background-color: rgba(24, 20, 35, 0.03);
font-family: monospace;
font-size: 20px;
line-height: 1.2;
}
p {
margin: 0;
font-size: 20px;
}
a {
color: #6341F0;
text-decoration: underline;
cursor: pointer;
@media (prefers-color-scheme: dark) {
color: #B3A1FF;
}
}
button {
border-radius: 8px;
color: white;
cursor: pointer;
background-color: #553DB8;
border: none;
padding: 0;
margin-top: 4px;
& > span {
display: inline-block;
padding: 12px 16px;
border-radius: inherit;
font-size: 20px;
font-weight: bold;
line-height: 1;
background-color: #7553FF;
border: 1px solid #553DB8;
transform: translateY(-4px);
}
&:hover > span {
transform: translateY(-8px);
}
&:active > span {
transform: translateY(0);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
& > span {
transform: translateY(0);
border: none
}
}
}
.description {
text-align: center;
color: #6E6C75;
max-width: 500px;
line-height: 1.5;
font-size: 20px;
@media (prefers-color-scheme: dark) {
color: #A49FB5;
}
}
.flex-spacer {
flex: 1;
}
.success {
padding: 12px 16px;
border-radius: 8px;
font-size: 20px;
line-height: 1;
background-color: #00F261;
border: 1px solid #00BF4D;
color: #181423;
}
.success_placeholder {
height: 46px;
}
.connectivity-error {
padding: 12px 16px;
background-color: #E50045;
border-radius: 8px;
width: 500px;
color: #FFFFFF;
border: 1px solid #A80033;
text-align: center;
margin: 0;
}
.connectivity-error a {
color: #FFFFFF;
text-decoration: underline;
}
`}</style>
</div>
);
}
-141
View File
@@ -1,141 +0,0 @@
"use client";
/**
* 订阅成功回跳页
*
* 流程:
* 1. 用户在支付服务托管页完成结账
* 2. 支付服务重定向到 `/subscription/success`
* 3. 这个页面显示 "购买成功" + 调 VIP 状态接口确认权益
* 4. 点 "返回订阅页" 跳回 `/subscription`
*
* 注意:权益发放由后端支付服务链路异步完成。
* 此页面轮询几次 VIP 状态接口等待权益完成更新。
*/
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core/mobile-shell";
import { getCurrentVipPaymentStatus } from "@/lib/payment/payment_status";
import { ROUTES } from "@/router/routes";
export default function SubscriptionSuccessPage() {
const router = useRouter();
const [status, setStatus] = useState<"polling" | "ok" | "error">("polling");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const hasRefreshed = useRef(false);
// 轮询 VIP 状态接口等待后端权益发放完成
useEffect(() => {
if (hasRefreshed.current) return;
hasRefreshed.current = true;
let cancelled = false;
const tryRefresh = async (attemptsLeft: number) => {
if (cancelled) return;
const r = await getCurrentVipPaymentStatus();
if (cancelled) return;
if (r.success) {
if (r.data.isVip) {
setStatus("ok");
return;
}
} else {
setErrorMessage(r.error?.message ?? "Unknown error");
}
if (attemptsLeft > 0) {
setTimeout(() => void tryRefresh(attemptsLeft - 1), 2000);
} else {
setStatus("error");
}
};
void tryRefresh(5);
return () => {
cancelled = true;
};
}, []);
return (
<MobileShell>
<div
style={{
padding: "2rem 1.5rem",
textAlign: "center",
minHeight: "60vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
}}
>
{status === "polling" ? (
<>
<h1 style={{ fontSize: "1.5rem", fontWeight: 600 }}>
Processing your subscription
</h1>
<p style={{ color: "#666" }}>
Your payment was received. We are confirming the details with
our server.
</p>
</>
) : status === "ok" ? (
<>
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, color: "#1f8b3a" }}>
🎉 Subscription activated!
</h1>
<p style={{ color: "#666" }}>
Welcome to VIP. Enjoy unlimited chat and more premium benefits.
</p>
<button
type="button"
onClick={() => router.push(ROUTES.chat)}
style={{
padding: "0.75rem 1.5rem",
borderRadius: "12px",
background: "linear-gradient(135deg, #f96ADE, #f657A0)",
color: "white",
border: "none",
fontSize: "1rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Start chatting
</button>
<Link
href={ROUTES.subscription}
style={{ color: "#888", fontSize: "0.875rem" }}
>
Back to subscription
</Link>
</>
) : (
<>
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, color: "#c0392b" }}>
Subscription status unclear
</h1>
<p style={{ color: "#666" }}>
We received your payment but could not confirm your VIP
status. {errorMessage ?? "Please refresh in a few seconds."}
</p>
<Link
href={ROUTES.subscription}
style={{
padding: "0.75rem 1.5rem",
borderRadius: "12px",
background: "#f0f0f0",
color: "#333",
textDecoration: "none",
fontSize: "1rem",
fontWeight: 600,
}}
>
Back to subscription
</Link>
</>
)}
</div>
</MobileShell>
);
}
+6 -3
View File
@@ -19,10 +19,13 @@ import type { ReactNode } from "react";
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";
import { ChatAuthSync } from "@/stores/chat/chat-auth-sync";
import { ChatProvider } from "@/stores/chat/chat-context"; import { ChatProvider } from "@/stores/chat/chat-context";
import { PaymentProvider, PaymentSuccessSync } from "@/stores/payment"; import { PaymentProvider } from "@/stores/payment";
import { UserAuthSync } from "@/stores/user/user-auth-sync"; import {
ChatAuthSync,
PaymentSuccessSync,
UserAuthSync,
} from "@/stores/sync";
import { UserProvider } from "@/stores/user/user-context"; import { UserProvider } from "@/stores/user/user-context";
export interface RootProvidersProps { export interface RootProvidersProps {
-1
View File
@@ -2,7 +2,6 @@
* @file Automatically generated by barrelsby. * @file Automatically generated by barrelsby.
*/ */
export * from "./chat-auth-sync";
export * from "./chat-context"; export * from "./chat-context";
export * from "./chat-events"; export * from "./chat-events";
export * from "./chat-machine.actors"; export * from "./chat-machine.actors";
-1
View File
@@ -6,5 +6,4 @@ export * from "./payment-context";
export * from "./payment-events"; export * from "./payment-events";
export * from "./payment-machine"; export * from "./payment-machine";
export * from "./payment-machine.actors"; export * from "./payment-machine.actors";
export * from "./payment-success-sync";
export * from "./payment-state"; export * from "./payment-state";
@@ -12,10 +12,9 @@ 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 { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes"; import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
import { useChatDispatch } from "./chat-context";
function isProtectedPath(pathname: string): boolean { function isProtectedPath(pathname: string): boolean {
return PROTECTED_ROUTES.some((route: StaticRoute) => pathname === route); return PROTECTED_ROUTES.some((route: StaticRoute) => pathname === route);
} }
+3
View File
@@ -0,0 +1,3 @@
export * from "./chat-auth-sync";
export * from "./payment-success-sync";
export * from "./user-auth-sync";
@@ -12,8 +12,7 @@ import { useEffect, useRef } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context"; import { useUserDispatch } from "@/stores/user/user-context";
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session"; import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
import { usePaymentState } from "@/stores/payment/payment-context";
import { usePaymentState } from "./payment-context";
export function PaymentSuccessSync() { export function PaymentSuccessSync() {
const paymentState = usePaymentState(); const paymentState = usePaymentState();
@@ -12,8 +12,7 @@ import { useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { useUserDispatch } from "./user-context";
export function UserAuthSync() { export function UserAuthSync() {
const { hasInitialized, isLoading, loginStatus } = useAuthState(); const { hasInitialized, isLoading, loginStatus } = useAuthState();
-1
View File
@@ -2,7 +2,6 @@
* @file Automatically generated by barrelsby. * @file Automatically generated by barrelsby.
*/ */
export * from "./user-auth-sync";
export * from "./user-context"; export * from "./user-context";
export * from "./user-events"; export * from "./user-events";
export * from "./user-machine.actors"; export * from "./user-machine.actors";