feat(payment): support user-bound seven-discount offers

This commit is contained in:
Codex
2026-07-23 16:11:20 +08:00
parent 64ba720121
commit 6721b6eb43
39 changed files with 418 additions and 28 deletions
@@ -23,4 +23,23 @@ describe("CommercialActionCard", () => {
expect(html).toContain("Open private zone");
expect(html).toContain('aria-label="Dismiss offer"');
});
it("renders a consent-first discount action without a checkout URL", () => {
const html = renderToStaticMarkup(
<CommercialActionCard
action={{
actionId: "offer-1",
type: "discountOffer",
copy: "Want me to ask for my best private offer?",
ctaLabel: "Yes, ask for me",
target: "discountConsent",
ruleId: "discount_after_price_objection",
}}
/>,
);
expect(html).toContain('data-commercial-action="discountOffer"');
expect(html).toContain("Yes, ask for me");
expect(html).not.toContain("/subscription");
});
});
@@ -189,6 +189,26 @@
overflow-wrap: anywhere;
}
.commercialActionButton:disabled {
cursor: wait;
opacity: 0.72;
}
.commercialActionError {
margin: -4px 0 10px;
color: #ffb4ab;
font-size: 12px;
line-height: 1.4;
}
.commercialActionSpinner {
animation: commercial-action-spin 0.8s linear infinite;
}
@keyframes commercial-action-spin {
to { transform: rotate(360deg); }
}
.bubbleAi {
max-width: var(--chat-bubble-max-width, 75%);
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { ArrowRight, Gift, Images, X } from "lucide-react";
import { ArrowRight, BadgePercent, Gift, Images, LoaderCircle, X } from "lucide-react";
import type { CommercialAction } from "@/data/schemas/chat";
@@ -9,7 +9,7 @@ import styles from "./chat-area.module.css";
export interface CommercialActionCardProps {
action: CommercialAction;
onActivate?: (action: CommercialAction) => void;
onActivate?: (action: CommercialAction) => void | Promise<void>;
onDismiss?: (action: CommercialAction) => void;
}
@@ -19,9 +19,16 @@ export function CommercialActionCard({
onDismiss,
}: CommercialActionCardProps) {
const [dismissed, setDismissed] = useState(false);
const [activating, setActivating] = useState(false);
const [activationError, setActivationError] = useState<string | null>(null);
if (dismissed) return null;
const Icon = action.type === "giftOffer" ? Gift : Images;
const Icon =
action.type === "giftOffer"
? Gift
: action.type === "discountOffer"
? BadgePercent
: Images;
return (
<aside
@@ -45,13 +52,29 @@ export function CommercialActionCard({
</button>
</div>
<p className={styles.commercialActionCopy}>{action.copy}</p>
{activationError ? (
<p className={styles.commercialActionError} role="alert">
{activationError}
</p>
) : null}
<button
type="button"
className={styles.commercialActionButton}
onClick={() => onActivate?.(action)}
disabled={activating}
onClick={() => {
setActivating(true);
setActivationError(null);
void Promise.resolve(onActivate?.(action))
.catch(() => setActivationError("This private offer is unavailable right now. Please try again."))
.finally(() => setActivating(false));
}}
>
<span>{action.ctaLabel}</span>
<ArrowRight size={17} aria-hidden="true" />
{activating ? (
<LoaderCircle className={styles.commercialActionSpinner} size={17} aria-hidden="true" />
) : (
<ArrowRight size={17} aria-hidden="true" />
)}
</button>
</aside>
);