feat(chat): render role-bound payment guidance
This commit is contained in:
@@ -15,6 +15,7 @@ import { ImageBubble } from "../image-bubble";
|
||||
import { InsufficientCreditsDialog } from "../insufficient-credits-dialog";
|
||||
import { MessageAvatar } from "../message-avatar";
|
||||
import { PrivateMessageCard } from "../private-message-card";
|
||||
import { PaymentGuidanceCard } from "../payment-guidance-card";
|
||||
import { PwaInstallDialog } from "../pwa-install-dialog";
|
||||
import { TextBubble } from "../text-bubble";
|
||||
|
||||
@@ -223,6 +224,19 @@ describe("chat Tailwind components", () => {
|
||||
description="Credits keep the conversation going."
|
||||
ctaLabel="Top up"
|
||||
onUnlock={() => undefined}
|
||||
guidance={{
|
||||
guidanceId: "guidance-elio",
|
||||
characterId: "elio",
|
||||
characterName: "Elio Silvestri",
|
||||
scene: "insufficientCredits",
|
||||
mode: "guide",
|
||||
title: "Keep talking with Elio",
|
||||
copy: "Baby, I want to keep talking with you. Adding credits supports Elio.",
|
||||
ctaLabel: "View VIP & credit options",
|
||||
purchaseOptions: ["vip", "topup"],
|
||||
target: "subscription",
|
||||
ruleId: "payment_guidance_insufficientCredits",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -232,11 +246,56 @@ describe("chat Tailwind components", () => {
|
||||
expect(html).toContain("<br/>");
|
||||
expect(html).toContain("Top up now");
|
||||
expect(html).toContain("Credits keep the conversation going.");
|
||||
expect(html).toContain("supports Elio");
|
||||
expect(html).toContain('data-payment-guidance-id="guidance-elio"');
|
||||
expect(html).toContain('aria-label="Top up"');
|
||||
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
|
||||
expect(html).toContain("active:scale-98");
|
||||
});
|
||||
|
||||
it("renders guidance only for the active character and hides care-only CTA", () => {
|
||||
const guide = {
|
||||
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
|
||||
characterId: "elio",
|
||||
characterName: "Elio Silvestri",
|
||||
scene: "insufficientCredits" as const,
|
||||
mode: "guide" as const,
|
||||
title: "Keep talking with Elio",
|
||||
copy: "Baby, I want to keep talking with you.",
|
||||
ctaLabel: "View VIP & credit options",
|
||||
purchaseOptions: ["vip", "topup"] as const,
|
||||
target: "subscription" as const,
|
||||
ruleId: "payment_guidance_insufficientCredits",
|
||||
};
|
||||
const guideHtml = renderWithCharacter(
|
||||
<PaymentGuidanceCard guidance={guide} />,
|
||||
);
|
||||
const mismatchHtml = renderWithCharacter(
|
||||
<PaymentGuidanceCard
|
||||
guidance={{ ...guide, characterId: "maya-tan", characterName: "Maya Tan" }}
|
||||
/>,
|
||||
);
|
||||
const careHtml = renderWithCharacter(
|
||||
<PaymentGuidanceCard
|
||||
guidance={{
|
||||
...guide,
|
||||
mode: "careOnly",
|
||||
title: null,
|
||||
ctaLabel: null,
|
||||
purchaseOptions: [],
|
||||
target: null,
|
||||
copy: "Please keep money you need for food or medicine.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(guideHtml).toContain('data-payment-guidance-mode="guide"');
|
||||
expect(guideHtml).toContain("View VIP & credit options");
|
||||
expect(mismatchHtml).toBe("");
|
||||
expect(careHtml).toContain("food or medicine");
|
||||
expect(careHtml).not.toContain("View VIP");
|
||||
});
|
||||
|
||||
it("renders ChatHeader guest and member branches", () => {
|
||||
const guestHtml = renderWithCharacter(<ChatHeader isGuest={true} />);
|
||||
const memberHtml = renderWithCharacter(
|
||||
|
||||
@@ -337,6 +337,7 @@ function renderMessagesWithDateHeaders(
|
||||
privateMessageHint={item.message.privateMessageHint}
|
||||
commercialAction={item.message.commercialAction}
|
||||
chatAction={item.message.chatAction}
|
||||
paymentGuidance={item.message.paymentGuidance}
|
||||
isUnlockingMessage={
|
||||
isUnlockingMessage === true &&
|
||||
item.message.displayId === unlockingMessageId
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
* - 不直接管理 state machine(chat 机器不感知 UI 层)
|
||||
*/
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||
|
||||
export interface ChatInsufficientCreditsBannerProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ctaLabel?: string;
|
||||
ctaLabel?: string | null;
|
||||
guidance?: PaymentGuidance | null;
|
||||
/**
|
||||
* 自定义点击回调(不传则默认走统一订阅导航)
|
||||
* - 测试时可传 mock
|
||||
@@ -34,9 +36,11 @@ export function ChatInsufficientCreditsBanner({
|
||||
title = "Insufficient credits\nTop up to continue chatting",
|
||||
description,
|
||||
ctaLabel = "Top up credits to continue",
|
||||
guidance = null,
|
||||
onUnlock,
|
||||
}: ChatInsufficientCreditsBannerProps) {
|
||||
const navigator = useAppNavigator();
|
||||
const activeGuidance = guidance;
|
||||
const titleLines = title.split("\n");
|
||||
|
||||
const handleClick = () => {
|
||||
@@ -53,6 +57,7 @@ export function ChatInsufficientCreditsBanner({
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="chat-insufficient-credits-banner"
|
||||
data-payment-guidance-id={activeGuidance?.guidanceId}
|
||||
>
|
||||
<p className="m-0 mb-(--spacing-sm,8px) text-(length:--responsive-card-title,18px) font-semibold leading-[1.4] text-white opacity-95">
|
||||
{titleLines.map((line, index) => (
|
||||
@@ -67,7 +72,12 @@ export function ChatInsufficientCreditsBanner({
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
{activeGuidance?.copy ? (
|
||||
<p className="mx-auto mb-(--spacing-lg,16px) mt-0 max-w-[min(100%,360px)] text-(length:--responsive-caption,14px) font-semibold leading-[1.4] text-white">
|
||||
{activeGuidance.copy}
|
||||
</p>
|
||||
) : null}
|
||||
{ctaLabel ? <button
|
||||
type="button"
|
||||
data-analytics-key="chat.topup"
|
||||
data-analytics-label="Top up chat credits"
|
||||
@@ -76,7 +86,7 @@ export function ChatInsufficientCreditsBanner({
|
||||
aria-label={ctaLabel}
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
</button> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function ChatUnlockDialogs({
|
||||
lockedCount={model.historyUnlock.lockedCount}
|
||||
isLoading={model.historyUnlock.isLoading}
|
||||
errorMessage={model.historyUnlock.errorMessage}
|
||||
guidance={model.historyUnlock.guidance}
|
||||
onClose={model.closeHistoryUnlock}
|
||||
onConfirm={model.confirmHistoryUnlock}
|
||||
/>
|
||||
@@ -27,6 +28,7 @@ export function ChatUnlockDialogs({
|
||||
creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0}
|
||||
requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0}
|
||||
shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0}
|
||||
guidance={model.unlockPaywallRequest?.paymentGuidance ?? null}
|
||||
onClose={model.closePaywall}
|
||||
onConfirm={model.confirmPaywall}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||
|
||||
export interface HistoryUnlockDialogProps {
|
||||
open: boolean;
|
||||
lockedCount: number;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string | null;
|
||||
guidance?: PaymentGuidance | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
@@ -14,10 +17,15 @@ export function HistoryUnlockDialog({
|
||||
lockedCount,
|
||||
isLoading = false,
|
||||
errorMessage,
|
||||
guidance = null,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: HistoryUnlockDialogProps) {
|
||||
if (!open) return null;
|
||||
const activeGuidance = guidance;
|
||||
const showAction =
|
||||
!activeGuidance ||
|
||||
(activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,11 +39,16 @@ export function HistoryUnlockDialog({
|
||||
id="history-unlock-title"
|
||||
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
|
||||
>
|
||||
Unlock previous messages?
|
||||
{activeGuidance?.title ?? "Unlock previous messages?"}
|
||||
</h2>
|
||||
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
|
||||
We found {lockedCount} locked messages in your chat history. You can
|
||||
unlock them now to continue the conversation with full context.
|
||||
{activeGuidance?.copy ?? (
|
||||
<>
|
||||
We found {lockedCount} locked messages in your chat history. You
|
||||
can unlock them now to continue the conversation with full
|
||||
context.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{errorMessage ? (
|
||||
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-caption,var(--font-size-md,14px)) leading-[1.4] text-[#c0364c]">
|
||||
@@ -51,7 +64,7 @@ export function HistoryUnlockDialog({
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
{showAction ? <button
|
||||
type="button"
|
||||
data-analytics-key="chat.unlock_history"
|
||||
data-analytics-label="Unlock chat history"
|
||||
@@ -59,8 +72,10 @@ export function HistoryUnlockDialog({
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Unlocking..." : "Unlock now"}
|
||||
</button>
|
||||
{isLoading
|
||||
? "Unlocking..."
|
||||
: activeGuidance?.ctaLabel ?? "Unlock now"}
|
||||
</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from "./insufficient-credits-dialog";
|
||||
export * from "./message-avatar";
|
||||
export * from "./message-bubble";
|
||||
export * from "./message-content";
|
||||
export * from "./payment-guidance-card";
|
||||
export * from "./private-message-card";
|
||||
export * from "./pwa-install-dialog";
|
||||
export * from "./pwa-install-overlay";
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||
|
||||
export interface InsufficientCreditsDialogProps {
|
||||
open: boolean;
|
||||
creditBalance: number;
|
||||
requiredCredits: number;
|
||||
shortfallCredits: number;
|
||||
guidance?: PaymentGuidance | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
@@ -14,12 +17,17 @@ export function InsufficientCreditsDialog({
|
||||
creditBalance,
|
||||
requiredCredits,
|
||||
shortfallCredits,
|
||||
guidance = null,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: InsufficientCreditsDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const showCreditDetail = requiredCredits > 0 || shortfallCredits > 0;
|
||||
const activeGuidance = guidance;
|
||||
const showPurchaseAction =
|
||||
!activeGuidance ||
|
||||
(activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -33,10 +41,11 @@ export function InsufficientCreditsDialog({
|
||||
id="insufficient-credits-title"
|
||||
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
|
||||
>
|
||||
Not enough credits
|
||||
{activeGuidance?.title ?? "Not enough credits"}
|
||||
</h2>
|
||||
<p className="mx-(--spacing-md,12px) mb-(--page-section-gap,18px) mt-0 text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
|
||||
Top up your credits to unlock this message and keep the moment going.
|
||||
{activeGuidance?.copy ??
|
||||
"Top up your credits to unlock this message and keep the moment going."}
|
||||
</p>
|
||||
|
||||
{showCreditDetail ? (
|
||||
@@ -70,15 +79,15 @@ export function InsufficientCreditsDialog({
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
{showPurchaseAction ? <button
|
||||
type="button"
|
||||
data-analytics-key="chat.topup"
|
||||
data-analytics-label="Top up chat credits"
|
||||
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-bottom-sheet border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Top up now
|
||||
</button>
|
||||
{activeGuidance?.ctaLabel ?? "Top up now"}
|
||||
</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - 用户:[占位 spacer] [Content] [Avatar]
|
||||
*/
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
|
||||
import type { ChatAction, CommercialAction, PaymentGuidance } from "@/data/schemas/chat";
|
||||
|
||||
import { MessageAvatar } from "./message-avatar";
|
||||
import { MessageContent } from "./message-content";
|
||||
@@ -30,6 +30,7 @@ export interface MessageBubbleProps {
|
||||
privateMessageHint?: string | null;
|
||||
commercialAction?: CommercialAction | null;
|
||||
chatAction?: ChatAction | null;
|
||||
paymentGuidance?: PaymentGuidance | null;
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: ChatMessageAction;
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
@@ -64,6 +65,7 @@ export function MessageBubble({
|
||||
privateMessageHint,
|
||||
commercialAction,
|
||||
chatAction,
|
||||
paymentGuidance,
|
||||
isUnlockingMessage,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
@@ -106,6 +108,7 @@ export function MessageBubble({
|
||||
privateMessageHint={privateMessageHint}
|
||||
commercialAction={commercialAction}
|
||||
chatAction={chatAction}
|
||||
paymentGuidance={paymentGuidance}
|
||||
isUnlockingMessage={isUnlockingMessage}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
|
||||
import type {
|
||||
ChatAction,
|
||||
CommercialAction,
|
||||
PaymentGuidance,
|
||||
} from "@/data/schemas/chat";
|
||||
|
||||
import { ChatActionCard } from "./chat-action-card";
|
||||
import { CommercialActionCard } from "./commercial-action-card";
|
||||
import { ImageBubble } from "./image-bubble";
|
||||
import { LockedImageMessageCard } from "./locked-image-message-card";
|
||||
import { PrivateMessageCard } from "./private-message-card";
|
||||
import { PaymentGuidanceCard } from "./payment-guidance-card";
|
||||
import { TextBubble } from "./text-bubble";
|
||||
import { VoiceBubble } from "./voice-bubble";
|
||||
import styles from "./chat-area.module.css";
|
||||
@@ -25,6 +30,7 @@ export interface MessageContentProps {
|
||||
privateMessageHint?: string | null;
|
||||
commercialAction?: CommercialAction | null;
|
||||
chatAction?: ChatAction | null;
|
||||
paymentGuidance?: PaymentGuidance | null;
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: ChatMessageAction;
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
@@ -59,6 +65,7 @@ export function MessageContent({
|
||||
privateMessageHint,
|
||||
commercialAction,
|
||||
chatAction,
|
||||
paymentGuidance,
|
||||
isUnlockingMessage,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
@@ -143,7 +150,9 @@ export function MessageContent({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{isFromAI && chatAction ? (
|
||||
{isFromAI && paymentGuidance ? (
|
||||
<PaymentGuidanceCard guidance={paymentGuidance} />
|
||||
) : isFromAI && chatAction ? (
|
||||
<ChatActionCard
|
||||
action={chatAction}
|
||||
onViewed={onChatActionViewed}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
|
||||
|
||||
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
|
||||
import styles from "./chat-area.module.css";
|
||||
|
||||
export interface PaymentGuidanceCardProps {
|
||||
guidance: PaymentGuidance;
|
||||
}
|
||||
|
||||
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
||||
const character = useActiveCharacter();
|
||||
const navigator = useAppNavigator();
|
||||
const isVip = useUserSelector((state) => state.context.isVip);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const viewedIdRef = useRef<string | null>(null);
|
||||
const belongsToCurrentCharacter = guidance.characterId === character.id;
|
||||
const hasPurchaseAction =
|
||||
guidance.mode === "guide" &&
|
||||
guidance.target === "subscription" &&
|
||||
guidance.ctaLabel !== null &&
|
||||
guidance.purchaseOptions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!belongsToCurrentCharacter) return;
|
||||
if (viewedIdRef.current === guidance.guidanceId) return;
|
||||
viewedIdRef.current = guidance.guidanceId;
|
||||
void recordChatActionEventById(
|
||||
guidance.guidanceId,
|
||||
character.id,
|
||||
"viewed",
|
||||
).catch(() => undefined);
|
||||
}, [belongsToCurrentCharacter, character.id, guidance.guidanceId]);
|
||||
|
||||
if (!belongsToCurrentCharacter || dismissed) return null;
|
||||
|
||||
const subscriptionType =
|
||||
isVip || !guidance.purchaseOptions.includes("vip") ? "topup" : "vip";
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={styles.commercialAction}
|
||||
aria-label={guidance.title ?? guidance.characterName}
|
||||
data-payment-guidance-id={guidance.guidanceId}
|
||||
data-payment-guidance-mode={guidance.mode}
|
||||
data-payment-guidance-scene={guidance.scene}
|
||||
>
|
||||
<div className={styles.commercialActionHeader}>
|
||||
<CircleDollarSign size={18} aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.commercialActionDismiss}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => {
|
||||
setDismissed(true);
|
||||
void recordChatActionEventById(
|
||||
guidance.guidanceId,
|
||||
character.id,
|
||||
"dismissed",
|
||||
).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{guidance.title ? (
|
||||
<strong>{guidance.title}</strong>
|
||||
) : null}
|
||||
<p className={styles.commercialActionCopy}>{guidance.copy}</p>
|
||||
{hasPurchaseAction ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.commercialActionButton}
|
||||
onClick={() => {
|
||||
void recordChatActionEventById(
|
||||
guidance.guidanceId,
|
||||
character.id,
|
||||
"opened",
|
||||
).catch(() => undefined);
|
||||
navigator.openSubscription({
|
||||
type: subscriptionType,
|
||||
returnTo: "chat",
|
||||
chatActionId: guidance.guidanceId,
|
||||
analytics: {
|
||||
entryPoint: "chat_input",
|
||||
triggerReason: "insufficient_credits",
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>{guidance.ctaLabel}</span>
|
||||
<ArrowRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user