refactor(app): tighten chat and sync boundaries
This commit is contained in:
@@ -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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
/* ChatQuotaExhaustedBanner — 游客配额耗尽横幅
|
/* ChatInsufficientCreditsBanner — 积分不足横幅
|
||||||
* 视觉规格(与设计稿对齐):
|
* 视觉规格(与设计稿对齐):
|
||||||
* - 粉→品红渐变背景(与 splash gradient 一致)
|
* - 粉→品红渐变背景(与 splash gradient 一致)
|
||||||
* - 居中文案 + 渐变 pill 按钮
|
* - 居中文案 + 渐变 pill 按钮
|
||||||
+11
-13
@@ -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 machine(chat 机器不感知 UI 层)
|
* - 不直接管理 state machine(chat 机器不感知 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) => (
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./chat-auth-sync";
|
||||||
|
export * from "./payment-success-sync";
|
||||||
|
export * from "./user-auth-sync";
|
||||||
+1
-2
@@ -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();
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user