feat(app): improve payment and paywall UX
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user