feat(app): improve payment and paywall UX
This commit is contained in:
+2
-2
@@ -6,8 +6,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--auth-back-button-size);
|
||||
height: var(--auth-back-button-size);
|
||||
width: var(--back-button-size);
|
||||
height: var(--back-button-size);
|
||||
padding: 0 2px 0 0;
|
||||
color: var(--color-auth-text-primary);
|
||||
cursor: pointer;
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
import styles from "./back-button.module.css";
|
||||
|
||||
export interface BackButtonProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function BackButton({
|
||||
onClick,
|
||||
className,
|
||||
ariaLabel = "Back",
|
||||
}: BackButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.backButton, className].filter(Boolean).join(" ")}
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<ChevronLeft size={20} strokeWidth={2.5} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: var(--border-medium) solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* 通用返回按钮
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/back_button.dart(被 auth/chat/sidebar 共用)。
|
||||
*/
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type MouseEvent } from "react";
|
||||
|
||||
import styles from "./auth-back-button.module.css";
|
||||
|
||||
export interface AuthBackButtonProps {
|
||||
/** 自定义跳转目标(默认 history.back)。 */
|
||||
href?: string;
|
||||
/** 自定义点击处理(覆盖默认跳转)。 */
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function AuthBackButton({
|
||||
href,
|
||||
onClick,
|
||||
className,
|
||||
ariaLabel = "Back",
|
||||
}: AuthBackButtonProps) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
className={[styles.button, className].filter(Boolean).join(" ")}
|
||||
onClick={(e) => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
return;
|
||||
}
|
||||
if (href) router.push(href);
|
||||
else router.back();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M15 18l-6-6 6-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./auth-back-button";
|
||||
export * from "./bottom-sheet";
|
||||
export * from "./checkbox";
|
||||
export * from "./dialog";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @file Shared app component barrel.
|
||||
*/
|
||||
|
||||
export * from "./back-button";
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
import styles from "./auth-back-button.module.css";
|
||||
|
||||
export interface AuthBackButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AuthBackButton({ onClick }: AuthBackButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={onClick}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft size={20} strokeWidth={2.5} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { BackButton } from "@/app/_components";
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
|
||||
import { AuthBackButton } from "./auth-back-button";
|
||||
import { AuthEmailPanel } from "./auth-email-panel";
|
||||
import { AuthFacebookPanel } from "./auth-facebook-panel";
|
||||
import styles from "./auth-panel.module.css";
|
||||
@@ -31,7 +31,7 @@ export function AuthPanel() {
|
||||
|
||||
return (
|
||||
<div className={styles.panelShell}>
|
||||
<AuthBackButton onClick={handleBack} />
|
||||
<BackButton onClick={handleBack} />
|
||||
|
||||
{state.authPanelMode === "facebook" ? (
|
||||
<AuthFacebookPanel onSwitchToEmail={switchToEmail} />
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from "./auth-background";
|
||||
export * from "./auth-back-button";
|
||||
export * from "./auth-divider";
|
||||
export * from "./auth-email-panel";
|
||||
export * from "./auth-error-message";
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from "./subscription-benefits-card";
|
||||
export * from "./subscription-checkout-button";
|
||||
export * from "./subscription-cta-button";
|
||||
export * from "./subscription-payment-method";
|
||||
export * from "./subscription-payment-success-dialog";
|
||||
export * from "./subscription-plan-card";
|
||||
export * from "./subscription-user-row";
|
||||
export * from "./stripe-payment-dialog";
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
border-radius: 32px;
|
||||
background: var(--color-page-background, #ffffff);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
|
||||
padding: 28px 20px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #ff67e0 0%, #f657a0 100%);
|
||||
color: #ffffff;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
box-shadow: 0 8px 18px rgba(247, 89, 168, 0.28);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0;
|
||||
color: #393939;
|
||||
font-size: var(--font-size-md, 14px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
margin-top: 22px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(269deg, #ff67e0 0%, rgba(254, 104, 224, 0.5) 20%, rgba(252, 105, 223, 0.79) 66%, #f96ade 100%),
|
||||
linear-gradient(#f657a0, #f657a0);
|
||||
background-blend-mode: normal, normal;
|
||||
box-shadow: 0 5px 7px rgba(247, 89, 168, 0.31);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-lg, 16px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import styles from "./subscription-payment-success-dialog.module.css";
|
||||
|
||||
export interface SubscriptionPaymentSuccessDialogProps {
|
||||
open: boolean;
|
||||
subscriptionType: "vip" | "voice";
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SubscriptionPaymentSuccessDialog({
|
||||
open,
|
||||
subscriptionType,
|
||||
onClose,
|
||||
}: SubscriptionPaymentSuccessDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const content =
|
||||
subscriptionType === "voice"
|
||||
? "Your voice package purchase was completed successfully."
|
||||
: "Your VIP membership has been activated successfully.";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="subscription-payment-success-title"
|
||||
>
|
||||
<div className={styles.dialog}>
|
||||
<div className={styles.badge} aria-hidden="true">
|
||||
✓
|
||||
</div>
|
||||
<h2 id="subscription-payment-success-title" className={styles.title}>
|
||||
Payment successful
|
||||
</h2>
|
||||
<p className={styles.content}>{content}</p>
|
||||
<button type="button" className={styles.button} onClick={onClose}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* 7. 主操作按钮
|
||||
* 8. 协议复选框
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Checkbox, MobileShell } from "@/app/_components/core";
|
||||
import { AppConstants } from "@/core/app_constants";
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SubscriptionBenefitsCard,
|
||||
SubscriptionCheckoutButton,
|
||||
SubscriptionPaymentMethod,
|
||||
SubscriptionPaymentSuccessDialog,
|
||||
SubscriptionPlanCard,
|
||||
SubscriptionUserRow,
|
||||
} from "./components";
|
||||
@@ -61,6 +62,9 @@ export function SubscriptionScreen({
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const refreshedPaidOrderRef = useRef<string | null>(null);
|
||||
const resumedPendingOrderRef = useRef<string | null>(null);
|
||||
const successDialogShownOrderRef = useRef<string | null>(null);
|
||||
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (payment.status === "idle") {
|
||||
@@ -75,6 +79,14 @@ export function SubscriptionScreen({
|
||||
userDispatch({ type: "UserFetch" });
|
||||
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payment.isPaid || !payment.currentOrderId) return;
|
||||
if (successDialogShownOrderRef.current === payment.currentOrderId) return;
|
||||
|
||||
successDialogShownOrderRef.current = payment.currentOrderId;
|
||||
setShowPaymentSuccessDialog(true);
|
||||
}, [payment.currentOrderId, payment.isPaid]);
|
||||
|
||||
useEffect(() => {
|
||||
const canInspectPendingOrder =
|
||||
payment.status === "ready" ||
|
||||
@@ -296,6 +308,12 @@ export function SubscriptionScreen({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubscriptionPaymentSuccessDialog
|
||||
open={showPaymentSuccessDialog}
|
||||
subscriptionType={subscriptionType}
|
||||
onClose={() => setShowPaymentSuccessDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ export const UiMessageSchema = z.object({
|
||||
date: z.string(),
|
||||
/** 图片 URL(base64 data URL 或 http URL) */
|
||||
imageUrl: z.string().optional(),
|
||||
/** true = 图片可在列表展示,但全屏查看时需要会员解锁 */
|
||||
imagePaywalled: z.boolean().optional(),
|
||||
/** 语音 URL */
|
||||
voiceUrl: z.string().optional(),
|
||||
isPrivate: z.boolean().nullable().optional(),
|
||||
|
||||
@@ -89,6 +89,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
isFromAI: true,
|
||||
date: todayString(new Date(response.timestamp)),
|
||||
...(response.imageUrl ? { imageUrl: response.imageUrl } : {}),
|
||||
...(response.showUpgrade && response.imageUrl
|
||||
? { imagePaywalled: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,8 +150,8 @@ export function applyHttpSendOutput(
|
||||
return {
|
||||
messages: [...context.messages, reply],
|
||||
isReplyingAI: false,
|
||||
paywallTriggered: true,
|
||||
paywallReason: "photo_paywall",
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
--auth-legal-checkbox-inner-size: 10px;
|
||||
|
||||
/* 悬浮返回按钮(40×40 圆形) */
|
||||
--auth-back-button-size: 40px;
|
||||
--back-button-size: 40px;
|
||||
|
||||
/* 底部弹层圆角(28px = Dart AppRadius.radius28) */
|
||||
--auth-bottom-sheet-radius: 28px;
|
||||
|
||||
Reference in New Issue
Block a user