fix(chat): add voice unlock payment choices

This commit is contained in:
2026-06-25 14:41:21 +08:00
parent 5879b7acf3
commit ffc3db9a91
7 changed files with 201 additions and 2 deletions
+5
View File
@@ -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<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
@@ -61,6 +63,7 @@ export function ChatArea({
unlockingPrivateMessageId,
onUnlockPrivateMessage,
onUnlockImagePaywall,
onUnlockVoiceMessage,
)}
{isReplyingAI && <LottieMessageBubble />}
@@ -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}
/>
),
);
+1
View File
@@ -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";
@@ -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}
/>
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
</div>
@@ -89,6 +92,7 @@ export function MessageBubble({
isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
onUnlockVoiceMessage={onUnlockVoiceMessage}
/>
<div style={{ width: 8 }} />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
+4 -2
View File
@@ -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
}
/>
@@ -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;
}
@@ -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 (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="voice-unlock-options-title"
>
<div className={styles.dialog}>
<h2 id="voice-unlock-options-title" className={styles.title}>
Unlock voice message
</h2>
<p className={styles.content}>
Choose how you would like to unlock this voice message.
</p>
<div className={styles.options}>
<button
type="button"
className={`${styles.optionButton} ${styles.primary}`}
onClick={onBuyVoicePackage}
>
</button>
<button
type="button"
className={`${styles.optionButton} ${styles.secondaryOption}`}
onClick={onActivateVip}
>
<span className={styles.optionTitle}> VIP</span>
<span className={styles.optionDescription}>
10+
</span>
</button>
</div>
<button type="button" className={styles.cancel} onClick={onClose}>
Cancel
</button>
</div>
</div>
);
}