fix(chat): add voice unlock payment choices
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
|||||||
ChatQuotaExhaustedBanner,
|
ChatQuotaExhaustedBanner,
|
||||||
ExternalBrowserDialog,
|
ExternalBrowserDialog,
|
||||||
PwaInstallOverlay,
|
PwaInstallOverlay,
|
||||||
|
VoiceUnlockOptionsDialog,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import styles from "./components/chat-screen.module.css";
|
import styles from "./components/chat-screen.module.css";
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export function ChatScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [showVoiceUnlockDialog, setShowVoiceUnlockDialog] = useState(false);
|
||||||
|
|
||||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
const isGuest = deriveIsGuest(authState.loginStatus);
|
||||||
@@ -60,6 +62,20 @@ export function ChatScreen() {
|
|||||||
returnTo: "chat",
|
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(() => {
|
useEffect(() => {
|
||||||
if (!authState.hasInitialized || authState.isLoading) return;
|
if (!authState.hasInitialized || authState.isLoading) return;
|
||||||
const isDev = AppEnvUtil.isDevelopment();
|
const isDev = AppEnvUtil.isDevelopment();
|
||||||
@@ -153,6 +169,10 @@ export function ChatScreen() {
|
|||||||
router.push(chatPaywallSubscriptionUrl);
|
router.push(chatPaywallSubscriptionUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUnlockVoiceMessage(): void {
|
||||||
|
setShowVoiceUnlockDialog(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
@@ -178,6 +198,7 @@ export function ChatScreen() {
|
|||||||
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
|
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
|
||||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||||
|
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showPrivatePaywallBanner ? (
|
{showPrivatePaywallBanner ? (
|
||||||
@@ -209,6 +230,12 @@ export function ChatScreen() {
|
|||||||
onClose={() => setShowExternalBrowserDialog(false)}
|
onClose={() => setShowExternalBrowserDialog(false)}
|
||||||
onConfirm={() => void handleOpenExternalBrowser()}
|
onConfirm={() => void handleOpenExternalBrowser()}
|
||||||
/>
|
/>
|
||||||
|
<VoiceUnlockOptionsDialog
|
||||||
|
open={showVoiceUnlockDialog}
|
||||||
|
onClose={() => setShowVoiceUnlockDialog(false)}
|
||||||
|
onBuyVoicePackage={() => navigateToSubscription("voice")}
|
||||||
|
onActivateVip={() => navigateToSubscription("vip")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface ChatAreaProps {
|
|||||||
unlockingPrivateMessageId?: string | null;
|
unlockingPrivateMessageId?: string | null;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockImagePaywall?: () => void;
|
onUnlockImagePaywall?: () => void;
|
||||||
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatArea({
|
export function ChatArea({
|
||||||
@@ -37,6 +38,7 @@ export function ChatArea({
|
|||||||
unlockingPrivateMessageId,
|
unlockingPrivateMessageId,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
|
onUnlockVoiceMessage,
|
||||||
}: ChatAreaProps) {
|
}: ChatAreaProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevLengthRef = useRef(messages.length);
|
const prevLengthRef = useRef(messages.length);
|
||||||
@@ -61,6 +63,7 @@ export function ChatArea({
|
|||||||
unlockingPrivateMessageId,
|
unlockingPrivateMessageId,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
|
onUnlockVoiceMessage,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReplyingAI && <LottieMessageBubble />}
|
{isReplyingAI && <LottieMessageBubble />}
|
||||||
@@ -74,6 +77,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
unlockingPrivateMessageId?: string | null,
|
unlockingPrivateMessageId?: string | null,
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||||
onUnlockImagePaywall?: () => void,
|
onUnlockImagePaywall?: () => void,
|
||||||
|
onUnlockVoiceMessage?: () => void,
|
||||||
) {
|
) {
|
||||||
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
}
|
}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||||
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ export * from "./pwa-install-overlay";
|
|||||||
export * from "./text-bubble";
|
export * from "./text-bubble";
|
||||||
export * from "./user-message-avatar";
|
export * from "./user-message-avatar";
|
||||||
export * from "./voice-bubble";
|
export * from "./voice-bubble";
|
||||||
|
export * from "./voice-unlock-options-dialog";
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface MessageBubbleProps {
|
|||||||
isUnlockingPrivate?: boolean;
|
isUnlockingPrivate?: boolean;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockImagePaywall?: () => void;
|
onUnlockImagePaywall?: () => void;
|
||||||
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
@@ -44,6 +45,7 @@ export function MessageBubble({
|
|||||||
isUnlockingPrivate,
|
isUnlockingPrivate,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
|
onUnlockVoiceMessage,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { avatarUrl } = useUserState();
|
const { avatarUrl } = useUserState();
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ export function MessageBubble({
|
|||||||
isUnlockingPrivate={isUnlockingPrivate}
|
isUnlockingPrivate={isUnlockingPrivate}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||||
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +92,7 @@ export function MessageBubble({
|
|||||||
isUnlockingPrivate={isUnlockingPrivate}
|
isUnlockingPrivate={isUnlockingPrivate}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||||
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: 8 }} />
|
<div style={{ width: 8 }} />
|
||||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface MessageContentProps {
|
|||||||
isUnlockingPrivate?: boolean;
|
isUnlockingPrivate?: boolean;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockImagePaywall?: () => void;
|
onUnlockImagePaywall?: () => void;
|
||||||
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_PLACEHOLDER = "[图片]";
|
const IMAGE_PLACEHOLDER = "[图片]";
|
||||||
@@ -36,6 +37,7 @@ export function MessageContent({
|
|||||||
isUnlockingPrivate = false,
|
isUnlockingPrivate = false,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
|
onUnlockVoiceMessage,
|
||||||
}: MessageContentProps) {
|
}: MessageContentProps) {
|
||||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||||
@@ -85,8 +87,8 @@ export function MessageContent({
|
|||||||
hint={privateMessageHint}
|
hint={privateMessageHint}
|
||||||
isUnlocking={isUnlockingPrivate}
|
isUnlocking={isUnlockingPrivate}
|
||||||
onUnlock={
|
onUnlock={
|
||||||
isLockedVoiceMessage && messageId
|
isLockedVoiceMessage
|
||||||
? () => onUnlockPrivateMessage?.(messageId)
|
? onUnlockVoiceMessage
|
||||||
: undefined
|
: 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