feat(app): improve payment and paywall UX

This commit is contained in:
2026-06-24 10:02:22 +08:00
parent 6c25a24440
commit bfbf7ee91a
23 changed files with 344 additions and 129 deletions
+10 -11
View File
@@ -55,18 +55,12 @@ export function ChatScreen() {
? "The limit for free chat times\nhas been reached"
: undefined;
const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined;
const showPhotoPaywallBanner =
state.paywallTriggered && state.paywallReason === "photo_paywall";
const showPrivatePaywallBanner =
state.paywallTriggered && state.paywallReason === "private_paywall";
const showInlineUpgradeBanner =
showPhotoPaywallBanner || showPrivatePaywallBanner;
const inlineUpgradeTitle = showPrivatePaywallBanner
? "Unlock VIP to view private messages"
: "Unlock VIP to view Elio's photos";
const inlineUpgradeCta = showPrivatePaywallBanner
? "Activate VIP to view private messages"
: "Activate VIP to view photos";
const inlineUpgradeTitle = "Unlock VIP to view private messages";
const inlineUpgradeCta = "Activate VIP to view private messages";
const externalBrowserPromptShownRef = useRef(false);
@@ -143,6 +137,10 @@ export function ChatScreen() {
chatDispatch({ type: "ChatUnlockPrivateMessage", messageId });
}
function handleUnlockImagePaywall(): void {
router.push(`${ROUTES.subscription}?type=vip`);
}
function handleMessageLimitUnlock(): void {
if (isGuest) {
router.push(ROUTES.auth);
@@ -175,9 +173,10 @@ export function ChatScreen() {
isGuest={isGuest}
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockImagePaywall={handleUnlockImagePaywall}
/>
{showInlineUpgradeBanner ? (
{showPrivatePaywallBanner ? (
<ChatQuotaExhaustedBanner
title={inlineUpgradeTitle}
ctaLabel={inlineUpgradeCta}
+6
View File
@@ -30,6 +30,7 @@ export interface ChatAreaProps {
isGuest: boolean;
unlockingPrivateMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockImagePaywall?: () => void;
}
export function ChatArea({
@@ -37,6 +38,7 @@ export function ChatArea({
isReplyingAI,
unlockingPrivateMessageId,
onUnlockPrivateMessage,
onUnlockImagePaywall,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
@@ -60,6 +62,7 @@ export function ChatArea({
messages,
unlockingPrivateMessageId,
onUnlockPrivateMessage,
onUnlockImagePaywall,
)}
{isReplyingAI && <LottieMessageBubble />}
@@ -72,6 +75,7 @@ function renderMessagesWithDateHeaders(
messages: readonly UiMessage[],
unlockingPrivateMessageId?: string | null,
onUnlockPrivateMessage?: (messageId: string) => void,
onUnlockImagePaywall?: () => void,
) {
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
@@ -91,6 +95,7 @@ function renderMessagesWithDateHeaders(
id={item.message.id}
content={item.message.content}
imageUrl={item.message.imageUrl}
imagePaywalled={item.message.imagePaywalled}
isFromAI={item.message.isFromAI}
privateLocked={item.message.privateLocked}
privateHint={item.message.privateHint}
@@ -99,6 +104,7 @@ function renderMessagesWithDateHeaders(
item.message.id === unlockingPrivateMessageId
}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
/>
),
);
@@ -11,6 +11,11 @@
cursor: pointer;
}
.paywallViewer {
cursor: default;
overflow: hidden;
}
.viewer img {
max-width: 100%;
max-height: 100%;
@@ -19,6 +24,66 @@
-webkit-user-drag: none;
}
.paywallImage {
filter: blur(18px);
object-fit: cover;
transform: scale(1.08);
}
.paywallScrim {
position: absolute;
inset: 0;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.02) 48%,
rgba(255, 255, 255, 0.18) 100%
);
pointer-events: none;
}
.backButton {
position: absolute;
top: 22px;
left: 18px;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
color: #6d6d6d;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
.unlockButton {
position: absolute;
right: 18px;
bottom: max(24px, env(safe-area-inset-bottom));
left: 18px;
z-index: 1;
min-height: 64px;
border: 0;
border-radius: 999px;
background: linear-gradient(90deg, #e05ad7 0%, #ef66b0 100%);
box-shadow: 0 8px 16px rgba(130, 60, 90, 0.28);
color: #ffffff;
cursor: pointer;
font-family: var(--font-athelas), Athelas, serif;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.01em;
}
.unlockButton:active {
transform: translateY(1px);
}
.loading {
width: 32px;
height: 32px;
@@ -9,6 +9,7 @@
* - 点击空白 / 下滑手势 → 关闭
* - 双指缩放(CSS `touch-action: pinch-zoom`
*/
import { ChevronLeft } from "lucide-react";
import Image from "next/image";
import { useEffect } from "react";
@@ -16,10 +17,17 @@ import styles from "./fullscreen-image-viewer.module.css";
export interface FullscreenImageViewerProps {
imageUrl: string;
imagePaywalled?: boolean;
onUnlockImagePaywall?: () => void;
onClose: () => void;
}
export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageViewerProps) {
export function FullscreenImageViewer({
imageUrl,
imagePaywalled = false,
onUnlockImagePaywall,
onClose,
}: FullscreenImageViewerProps) {
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -28,6 +36,44 @@ export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageView
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
const src = imageUrl.startsWith("data:") || imageUrl.startsWith("http")
? imageUrl
: `data:image/png;base64,${imageUrl}`;
if (imagePaywalled) {
return (
<div
className={`${styles.viewer} ${styles.paywallViewer}`}
role="dialog"
aria-label="Locked fullscreen image"
>
<Image
src={src}
alt=""
fill
sizes="100vw"
className={styles.paywallImage}
/>
<div className={styles.paywallScrim} aria-hidden="true" />
<button
type="button"
className={styles.backButton}
onClick={onClose}
aria-label="Back"
>
<ChevronLeft size={34} strokeWidth={2.5} aria-hidden="true" />
</button>
<button
type="button"
className={styles.unlockButton}
onClick={onUnlockImagePaywall}
>
Unlock high-definition large image
</button>
</div>
);
}
return (
<div
className={styles.viewer}
@@ -36,7 +82,7 @@ export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageView
aria-label="Fullscreen image"
>
<Image
src={imageUrl.startsWith("data:") || imageUrl.startsWith("http") ? imageUrl : `data:image/png;base64,${imageUrl}`}
src={src}
alt=""
width={800}
height={800}
+15 -2
View File
@@ -17,9 +17,15 @@ import styles from "./image-bubble.module.css";
export interface ImageBubbleProps {
imageUrl: string; // base64 data URI 或 URL
imagePaywalled?: boolean;
onUnlockImagePaywall?: () => void;
}
export function ImageBubble({ imageUrl }: ImageBubbleProps) {
export function ImageBubble({
imageUrl,
imagePaywalled = false,
onUnlockImagePaywall,
}: ImageBubbleProps) {
const [open, setOpen] = useState(false);
const [error, setError] = useState(false);
@@ -53,7 +59,14 @@ export function ImageBubble({ imageUrl }: ImageBubbleProps) {
/>
)}
</div>
{open && <FullscreenImageViewer imageUrl={imageUrl} onClose={() => setOpen(false)} />}
{open && (
<FullscreenImageViewer
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
onUnlockImagePaywall={onUnlockImagePaywall}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
@@ -18,22 +18,26 @@ export interface MessageBubbleProps {
id?: string;
content: string;
imageUrl?: string | null;
imagePaywalled?: boolean;
isFromAI: boolean;
privateLocked?: boolean | null;
privateHint?: string | null;
isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockImagePaywall?: () => void;
}
export function MessageBubble({
id,
content,
imageUrl,
imagePaywalled,
isFromAI,
privateLocked,
privateHint,
isUnlockingPrivate,
onUnlockPrivateMessage,
onUnlockImagePaywall,
}: MessageBubbleProps) {
const { avatarUrl } = useUserState();
@@ -46,11 +50,13 @@ export function MessageBubble({
messageId={id}
content={content}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
isFromAI={true}
privateLocked={privateLocked}
privateHint={privateHint}
isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
/>
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
</div>
@@ -64,11 +70,13 @@ export function MessageBubble({
messageId={id}
content={content}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
isFromAI={false}
privateLocked={privateLocked}
privateHint={privateHint}
isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
/>
<div style={{ width: 8 }} />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
+11 -1
View File
@@ -17,11 +17,13 @@ export interface MessageContentProps {
messageId?: string;
content: string;
imageUrl?: string | null;
imagePaywalled?: boolean;
isFromAI: boolean;
privateLocked?: boolean | null;
privateHint?: string | null;
isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockImagePaywall?: () => void;
}
const IMAGE_PLACEHOLDER = "[图片]";
@@ -30,11 +32,13 @@ export function MessageContent({
messageId,
content,
imageUrl,
imagePaywalled,
isFromAI,
privateLocked,
privateHint,
isUnlockingPrivate = false,
onUnlockPrivateMessage,
onUnlockImagePaywall,
}: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0;
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
@@ -65,7 +69,13 @@ export function MessageContent({
/>
) : (
<>
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
{hasImage && imageUrl && (
<ImageBubble
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
onUnlockImagePaywall={onUnlockImagePaywall}
/>
)}
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
</>
)}