From ffc3db9a91e424057341427f994dd7340d776822 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 25 Jun 2026 14:41:21 +0800 Subject: [PATCH] fix(chat): add voice unlock payment choices --- src/app/chat/chat-screen.tsx | 27 +++++ src/app/chat/components/chat-area.tsx | 5 + src/app/chat/components/index.ts | 1 + src/app/chat/components/message-bubble.tsx | 4 + src/app/chat/components/message-content.tsx | 6 +- .../voice-unlock-options-dialog.module.css | 99 +++++++++++++++++++ .../voice-unlock-options-dialog.tsx | 61 ++++++++++++ 7 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/app/chat/components/voice-unlock-options-dialog.module.css create mode 100644 src/app/chat/components/voice-unlock-options-dialog.tsx diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 01adc20e..c2b13ec6 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -23,6 +23,7 @@ import { ChatQuotaExhaustedBanner, ExternalBrowserDialog, PwaInstallOverlay, + VoiceUnlockOptionsDialog, } from "./components"; import styles from "./components/chat-screen.module.css"; @@ -40,6 +41,7 @@ export function ChatScreen() { const router = useRouter(); const [showExternalBrowserDialog, setShowExternalBrowserDialog] = useState(false); + const [showVoiceUnlockDialog, setShowVoiceUnlockDialog] = useState(false); // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) const isGuest = deriveIsGuest(authState.loginStatus); @@ -60,6 +62,20 @@ export function ChatScreen() { returnTo: "chat", }); + function navigateToSubscription(type: "vip" | "voice"): void { + const subscriptionUrl = ROUTE_BUILDERS.subscription(type, { + returnTo: "chat", + }); + + setShowVoiceUnlockDialog(false); + if (isGuest) { + router.push(ROUTE_BUILDERS.authWithRedirect(subscriptionUrl)); + return; + } + + router.push(subscriptionUrl); + } + useEffect(() => { if (!authState.hasInitialized || authState.isLoading) return; const isDev = AppEnvUtil.isDevelopment(); @@ -153,6 +169,10 @@ export function ChatScreen() { router.push(chatPaywallSubscriptionUrl); } + function handleUnlockVoiceMessage(): void { + setShowVoiceUnlockDialog(true); + } + return (
@@ -178,6 +198,7 @@ export function ChatScreen() { unlockingPrivateMessageId={state.unlockingPrivateMessageId} onUnlockPrivateMessage={handleUnlockPrivateMessage} onUnlockImagePaywall={handleUnlockImagePaywall} + onUnlockVoiceMessage={handleUnlockVoiceMessage} /> {showPrivatePaywallBanner ? ( @@ -209,6 +230,12 @@ export function ChatScreen() { onClose={() => setShowExternalBrowserDialog(false)} onConfirm={() => void handleOpenExternalBrowser()} /> + setShowVoiceUnlockDialog(false)} + onBuyVoicePackage={() => navigateToSubscription("voice")} + onActivateVip={() => navigateToSubscription("vip")} + />
); diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index fc9d1f7b..20f0020f 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -29,6 +29,7 @@ export interface ChatAreaProps { unlockingPrivateMessageId?: string | null; onUnlockPrivateMessage?: (messageId: string) => void; onUnlockImagePaywall?: () => void; + onUnlockVoiceMessage?: () => void; } export function ChatArea({ @@ -37,6 +38,7 @@ export function ChatArea({ unlockingPrivateMessageId, onUnlockPrivateMessage, onUnlockImagePaywall, + onUnlockVoiceMessage, }: ChatAreaProps) { const scrollRef = useRef(null); const prevLengthRef = useRef(messages.length); @@ -61,6 +63,7 @@ export function ChatArea({ unlockingPrivateMessageId, onUnlockPrivateMessage, onUnlockImagePaywall, + onUnlockVoiceMessage, )} {isReplyingAI && } @@ -74,6 +77,7 @@ function renderMessagesWithDateHeaders( unlockingPrivateMessageId?: string | null, onUnlockPrivateMessage?: (messageId: string) => void, onUnlockImagePaywall?: () => void, + onUnlockVoiceMessage?: () => void, ) { const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; @@ -106,6 +110,7 @@ function renderMessagesWithDateHeaders( } onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockImagePaywall={onUnlockImagePaywall} + onUnlockVoiceMessage={onUnlockVoiceMessage} /> ), ); diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index c798d8b3..e1e067cd 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -26,3 +26,4 @@ export * from "./pwa-install-overlay"; export * from "./text-bubble"; export * from "./user-message-avatar"; export * from "./voice-bubble"; +export * from "./voice-unlock-options-dialog"; diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 1f3d29fe..d32d6587 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -28,6 +28,7 @@ export interface MessageBubbleProps { isUnlockingPrivate?: boolean; onUnlockPrivateMessage?: (messageId: string) => void; onUnlockImagePaywall?: () => void; + onUnlockVoiceMessage?: () => void; } export function MessageBubble({ @@ -44,6 +45,7 @@ export function MessageBubble({ isUnlockingPrivate, onUnlockPrivateMessage, onUnlockImagePaywall, + onUnlockVoiceMessage, }: MessageBubbleProps) { const { avatarUrl } = useUserState(); @@ -66,6 +68,7 @@ export function MessageBubble({ isUnlockingPrivate={isUnlockingPrivate} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockImagePaywall={onUnlockImagePaywall} + onUnlockVoiceMessage={onUnlockVoiceMessage} />
{/* 占位:保持与头像宽度一致 */}
@@ -89,6 +92,7 @@ export function MessageBubble({ isUnlockingPrivate={isUnlockingPrivate} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockImagePaywall={onUnlockImagePaywall} + onUnlockVoiceMessage={onUnlockVoiceMessage} />
diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index d3fecb49..2c86a2b9 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -18,6 +18,7 @@ export interface MessageContentProps { isUnlockingPrivate?: boolean; onUnlockPrivateMessage?: (messageId: string) => void; onUnlockImagePaywall?: () => void; + onUnlockVoiceMessage?: () => void; } const IMAGE_PLACEHOLDER = "[图片]"; @@ -36,6 +37,7 @@ export function MessageContent({ isUnlockingPrivate = false, onUnlockPrivateMessage, onUnlockImagePaywall, + onUnlockVoiceMessage, }: MessageContentProps) { const hasImage = imageUrl != null && imageUrl.length > 0; const hasAudio = audioUrl != null && audioUrl.length > 0; @@ -85,8 +87,8 @@ export function MessageContent({ hint={privateMessageHint} isUnlocking={isUnlockingPrivate} onUnlock={ - isLockedVoiceMessage && messageId - ? () => onUnlockPrivateMessage?.(messageId) + isLockedVoiceMessage + ? onUnlockVoiceMessage : undefined } /> diff --git a/src/app/chat/components/voice-unlock-options-dialog.module.css b/src/app/chat/components/voice-unlock-options-dialog.module.css new file mode 100644 index 00000000..a60491f4 --- /dev/null +++ b/src/app/chat/components/voice-unlock-options-dialog.module.css @@ -0,0 +1,99 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 72; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.5); +} + +.dialog { + width: 100%; + max-width: 380px; + padding: 24px 18px 18px; + border-radius: 36px; + background: var(--color-page-background, #ffffff); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16); +} + +.title { + margin: 0 0 8px; + color: var(--color-text-foreground, #171717); + font-size: var(--font-size-22, 22px); + font-weight: 700; + line-height: 1.2; + text-align: center; +} + +.content { + margin: 0 12px 20px; + color: #393939; + font-size: var(--font-size-md, 14px); + line-height: 1.5; + text-align: center; +} + +.options { + display: flex; + flex-direction: column; + gap: 12px; +} + +.optionButton, +.cancel { + width: 100%; + min-height: 48px; + border: 0; + border-radius: 28px; + cursor: pointer; + font-size: var(--font-size-lg, 16px); + font-weight: 700; +} + +.optionButton { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 10px 16px; +} + +.primary { + color: #ffffff; + background: linear-gradient(90deg, #ff67e0 0%, #ff52a2 100%); + box-shadow: 0 5px 7px rgba(247, 89, 168, 0.24); +} + +.secondaryOption { + color: #f657a0; + background: + linear-gradient(#ffffff, #ffffff) padding-box, + linear-gradient(90deg, #ff67e0, #ff52a2) border-box; + border: 1px solid transparent; +} + +.optionTitle { + line-height: 1.2; +} + +.optionDescription { + color: #3c3b3b; + font-size: 13px; + font-weight: 500; + line-height: 1.25; +} + +.cancel { + margin-top: 14px; + color: #ffffff; + background: var(--color-text-secondary, #9e9e9e); +} + +.optionButton:focus-visible, +.cancel:focus-visible { + outline: 2px solid #f657a0; + outline-offset: 3px; +} diff --git a/src/app/chat/components/voice-unlock-options-dialog.tsx b/src/app/chat/components/voice-unlock-options-dialog.tsx new file mode 100644 index 00000000..918ef93e --- /dev/null +++ b/src/app/chat/components/voice-unlock-options-dialog.tsx @@ -0,0 +1,61 @@ +"use client"; + +import styles from "./voice-unlock-options-dialog.module.css"; + +export interface VoiceUnlockOptionsDialogProps { + open: boolean; + onClose: () => void; + onBuyVoicePackage: () => void; + onActivateVip: () => void; +} + +export function VoiceUnlockOptionsDialog({ + open, + onClose, + onBuyVoicePackage, + onActivateVip, +}: VoiceUnlockOptionsDialogProps) { + if (!open) return null; + + return ( +
+
+

+ Unlock voice message +

+

+ Choose how you would like to unlock this voice message. +

+ +
+ + +
+ + +
+
+ ); +}