fix(chat): add voice unlock payment choices
This commit is contained in:
@@ -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 (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
@@ -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()}
|
||||
/>
|
||||
<VoiceUnlockOptionsDialog
|
||||
open={showVoiceUnlockDialog}
|
||||
onClose={() => setShowVoiceUnlockDialog(false)}
|
||||
onBuyVoicePackage={() => navigateToSubscription("voice")}
|
||||
onActivateVip={() => navigateToSubscription("vip")}
|
||||
/>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user