feat(payment): support user-bound seven-discount offers
Docker Image / Build and Push Docker Image (push) Successful in 1m55s

This commit is contained in:
Codex
2026-07-23 16:11:20 +08:00
parent 02f6964484
commit 4dae805a88
39 changed files with 418 additions and 28 deletions
+5
View File
@@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput {
characterId?: string;
initialCategory?: string | null;
initialPlanId?: string | null;
commercialOfferId?: string | null;
}
export interface PaymentRouteFlow {
@@ -41,6 +42,7 @@ export function usePaymentRouteFlow({
characterId,
initialCategory = null,
initialPlanId = null,
commercialOfferId = null,
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
@@ -50,6 +52,7 @@ export function usePaymentRouteFlow({
characterId ?? "",
initialCategory ?? "",
initialPlanId ?? "",
commercialOfferId ?? "",
].join(":");
usePendingPaymentOrderLifecycle({
@@ -69,11 +72,13 @@ export function usePaymentRouteFlow({
...(characterId ? { characterId } : {}),
...(initialCategory ? { category: initialCategory } : {}),
...(initialPlanId ? { planId: initialPlanId } : {}),
...(commercialOfferId ? { commercialOfferId } : {}),
});
}, [
catalog,
catalogKey,
characterId,
commercialOfferId,
initialCategory,
initialPlanId,
initialPayChannel,
+26 -2
View File
@@ -17,8 +17,9 @@ import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
import { buildGlobalPageUrl } from "@/router/global-route-context";
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
import { getCharacterRoutes, ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import type { CommercialAction } from "@/data/schemas/chat";
import { paymentApi } from "@/data/services/api";
import { behaviorAnalytics } from "@/lib/analytics";
import { MobileShell } from "@/app/_components/core";
@@ -252,7 +253,7 @@ export function ChatScreen() {
router.push(characterRoutes.privateZone);
}
function handleCommercialAction(action: CommercialAction): void {
async function handleCommercialAction(action: CommercialAction): Promise<void> {
behaviorAnalytics.elementClick(
"chat.commercial_action.open",
action.ctaLabel,
@@ -264,6 +265,29 @@ export function ChatScreen() {
target: action.target,
},
);
if (action.target === "discountConsent") {
const offer = await paymentApi.acceptCommercialOffer(action.actionId);
behaviorAnalytics.elementClick(
"chat.commercial_action.accepted",
action.ctaLabel,
{
actionId: action.actionId,
actionType: action.type,
characterId: state.characterId,
planId: offer.planId,
discountPercent: offer.discountPercent,
},
);
router.push(
ROUTE_BUILDERS.subscription(offer.subscriptionType, {
sourceCharacterSlug: character.slug,
returnTo: "chat",
planId: offer.planId,
commercialOfferId: offer.commercialOfferId,
}),
);
return;
}
router.push(
action.target === "giftCatalog"
? characterRoutes.tip
@@ -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>
);
+6
View File
@@ -32,6 +32,10 @@ export default async function SubscriptionPage({
const sourceCharacterSlug =
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
DEFAULT_CHARACTER_SLUG;
const initialPlanId = getFirstPaymentSearchParam(query.planId);
const commercialOfferId = getFirstPaymentSearchParam(
query.commercialOfferId,
);
const analyticsContext = parsePaymentAnalyticsContext({
entryPoint: getFirstPaymentSearchParam(
query[PAYMENT_ANALYTICS_ENTRY_PARAM],
@@ -50,6 +54,8 @@ export default async function SubscriptionPage({
initialPayChannel={paymentReturn.initialPayChannel}
analyticsContext={analyticsContext}
sourceCharacterSlug={sourceCharacterSlug}
initialPlanId={initialPlanId}
commercialOfferId={commercialOfferId}
/>
);
}
@@ -45,6 +45,8 @@ export interface SubscriptionScreenProps {
initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext;
sourceCharacterSlug?: string;
initialPlanId?: string | null;
commercialOfferId?: string | null;
}
export function SubscriptionScreen({
@@ -54,6 +56,8 @@ export function SubscriptionScreen({
initialPayChannel = null,
analyticsContext: providedAnalyticsContext,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null,
commercialOfferId = null,
}: SubscriptionScreenProps) {
const userState = useUserState();
const hasHydrated = useHasHydrated();
@@ -77,6 +81,8 @@ export function SubscriptionScreen({
returnTo,
sourceCharacterSlug,
initialPayChannel: paymentMethodConfig.initialPayChannel,
initialPlanId,
commercialOfferId,
});
const canSubscribeVip = subscriptionType === "vip";
const analyticsContext =
@@ -214,6 +220,22 @@ export function SubscriptionScreen({
</section>
) : null}
{payment.commercialOffer && !firstRechargeOffer ? (
<section
className={styles.firstRechargeBanner}
aria-label="Elio private offer"
>
<span className={styles.firstRechargeBadge}>Private offer</span>
<div className={styles.firstRechargeCopy}>
<h2 className={styles.firstRechargeTitle}>Elio got this price for you</h2>
<p className={styles.firstRechargeSubtitle}>
{payment.commercialOffer.message ||
`Your ${payment.commercialOffer.discountPercent}% discount is already applied below.`}
</p>
</div>
</section>
) : null}
<div className={styles.offerStack}>
{canSubscribeVip ? (
<SubscriptionVipOfferSection
@@ -20,6 +20,8 @@ export interface UseSubscriptionPaymentFlowInput {
returnTo: SubscriptionReturnTo;
initialPayChannel: PayChannel;
sourceCharacterSlug?: string;
initialPlanId?: string | null;
commercialOfferId?: string | null;
}
export function useSubscriptionPaymentFlow({
@@ -28,12 +30,16 @@ export function useSubscriptionPaymentFlow({
returnTo,
initialPayChannel,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null,
commercialOfferId = null,
}: UseSubscriptionPaymentFlowInput) {
const router = useRouter();
const { payment, paymentDispatch } = usePaymentRouteFlow({
initialPayChannel,
paymentType: subscriptionType,
shouldResumePendingOrder,
initialPlanId,
commercialOfferId,
});
const successDialogShownOrderRef = useRef<string | null>(null);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
@@ -358,6 +358,7 @@ function makePaymentState(
giftProducts: [giftProduct],
selectedGiftCategory: giftCategory.category,
isFirstRecharge: false,
commercialOffer: null,
selectedPlanId: giftPlan.planId,
payChannel: "stripe",
autoRenew: false,