feat(subscription): highlight popular plans

This commit is contained in:
2026-07-15 18:30:18 +08:00
parent c37a2f9040
commit 7dce1b5d4c
10 changed files with 157 additions and 14 deletions
@@ -232,6 +232,24 @@ describe("subscription screen helpers", () => {
).toBeNull();
});
it("maps the backend most popular flag to VIP and coin views", () => {
const popularVip = toVipOfferPlanViews([
makePlan({ planId: "vip_monthly", mostPopular: true }),
]);
const popularCoins = toCoinsOfferPlanViews([
makePlan({
planId: "coin_1000",
orderType: "coins_1000",
vipDays: null,
dolAmount: 1000,
mostPopular: true,
}),
]);
expect(popularVip[0]?.mostPopular).toBe(true);
expect(popularCoins[0]?.mostPopular).toBe(true);
});
it("maps normal VIP original price outside first recharge activity", () => {
expect(
toVipOfferPlanViews([
@@ -6,6 +6,8 @@ import { StripePaymentDialog } from "@/app/_components/payment/stripe-payment-di
import { SubscriptionCtaButton } from "../subscription-cta-button";
import { SubscriptionPaymentMethod } from "../subscription-payment-method";
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
import { SubscriptionCoinsOfferSection } from "../subscription-coins-offer-section";
import { SubscriptionVipOfferSection } from "../subscription-vip-offer-section";
describe("subscription Tailwind components", () => {
it("renders SubscriptionCtaButton with loading state", () => {
@@ -93,4 +95,44 @@ describe("subscription Tailwind components", () => {
expect(html).toContain("Payment unavailable");
expect(html).toContain("bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0)");
});
it("renders most popular badges for VIP and coin plans", () => {
const vipHtml = renderToStaticMarkup(
<SubscriptionVipOfferSection
plans={[
{
id: "vip_monthly",
title: "Monthly",
price: "9.99",
currency: "usd",
originalPrice: "19.99",
mostPopular: true,
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
},
]}
selectedPlanId="vip_monthly"
onSelectPlan={() => undefined}
/>,
);
const coinsHtml = renderToStaticMarkup(
<SubscriptionCoinsOfferSection
plans={[
{
id: "coin_1000",
coins: 1000,
price: "9.99",
currency: "US$",
mostPopular: true,
},
]}
selectedPlanId={null}
onSelectPlan={() => undefined}
/>,
);
expect(vipHtml).toContain("Most Popular");
expect(vipHtml).toContain("50% OFF");
expect(coinsHtml).toContain("Most Popular");
});
});
@@ -118,16 +118,33 @@
line-height: 1.2;
}
.badgeGroup {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
justify-self: end;
gap: 4px;
}
.popularBadge,
.offerBadge {
justify-self: end;
padding: var(--subscription-offer-badge-padding, 5px 9px);
border-radius: 999px;
background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%);
color: #ffffff;
font-size: clamp(10px, 2.593vw, 14px);
font-weight: 900;
line-height: 1;
white-space: nowrap;
}
.popularBadge {
background: linear-gradient(135deg, #6b3a12 0%, #d99a27 55%, #f4c75a 100%);
box-shadow: 0 7px 16px rgba(164, 101, 21, 0.24);
}
.offerBadge {
background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%);
box-shadow: 0 7px 16px rgba(255, 95, 174, 0.24);
}
@@ -175,6 +192,7 @@
auto;
}
.popularBadge,
.offerBadge {
padding-right: clamp(6px, 1.296vw, 7px);
padding-left: clamp(6px, 1.296vw, 7px);
@@ -11,6 +11,7 @@ export interface CoinsOfferPlanView {
currency: string;
originalPrice?: string;
isFirstRechargeOffer?: boolean;
mostPopular?: boolean;
firstRechargeDiscountPercent?: number | null;
promotionType?: string | null;
}
@@ -51,11 +52,16 @@ export function SubscriptionCoinsOfferSection({
<FaCoins />
</span>
<span className={styles.coins}>{plan.coins} Coins</span>
{plan.isFirstRechargeOffer ? (
<span className={styles.offerBadge}>
{plan.firstRechargeDiscountPercent ?? 50}% OFF
</span>
) : null}
<span className={styles.badgeGroup}>
{plan.mostPopular ? (
<span className={styles.popularBadge}>Most Popular</span>
) : null}
{plan.isFirstRechargeOffer ? (
<span className={styles.offerBadge}>
{plan.firstRechargeDiscountPercent ?? 50}% OFF
</span>
) : null}
</span>
<span className={styles.priceGroup}>
<span className={styles.price}>
{plan.currency}
@@ -111,21 +111,37 @@
transform: translateY(-3px);
}
.offerBadge {
.badgeStack {
position: absolute;
top: clamp(6px, 1.481vw, 8px);
left: 50%;
display: flex;
max-width: calc(100% - 12px);
flex-direction: column;
align-items: center;
gap: 4px;
transform: translateX(-50%);
}
.popularBadge,
.offerBadge {
padding: var(--subscription-offer-badge-padding, 4px 8px);
border-radius: 999px;
background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%);
color: #ffffff;
font-size: clamp(12px, 2.963vw, 16px);
font-size: clamp(10px, 2.407vw, 13px);
font-weight: 900;
line-height: 1;
white-space: nowrap;
}
.popularBadge {
background: linear-gradient(135deg, #6b3a12 0%, #d99a27 55%, #f4c75a 100%);
box-shadow: 0 7px 16px rgba(164, 101, 21, 0.28);
}
.offerBadge {
background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%);
box-shadow: 0 7px 16px rgba(255, 95, 174, 0.28);
transform: translateX(-50%);
}
.planTitle {
@@ -136,6 +152,14 @@
white-space: nowrap;
}
.planCardWithBadges .planTitle {
margin-top: 30px;
}
.planCardWithTwoBadges .planTitle {
margin-top: 50px;
}
.priceLine {
display: inline-flex;
align-items: flex-end;
@@ -9,6 +9,7 @@ export interface VipOfferPlanView {
currency: string;
originalPrice: string;
isFirstRechargeOffer?: boolean;
mostPopular?: boolean;
firstRechargeDiscountPercent?: number | null;
promotionType?: string | null;
}
@@ -42,20 +43,30 @@ export function SubscriptionVipOfferSection({
<div className={styles.planGrid}>
{plans.map((plan) => {
const selected = selectedPlanId === plan.id;
const badgeCount =
Number(plan.mostPopular === true) +
Number(plan.isFirstRechargeOffer === true);
return (
<button
key={plan.id}
type="button"
data-analytics-key="subscription.plan_select"
data-analytics-label="Select VIP plan"
className={`${styles.planCard} ${selected ? styles.selected : ""}`}
className={`${styles.planCard} ${selected ? styles.selected : ""} ${badgeCount > 0 ? styles.planCardWithBadges : ""} ${badgeCount > 1 ? styles.planCardWithTwoBadges : ""}`}
aria-pressed={selected}
aria-label={`${plan.title}, ${plan.price} ${plan.currency}`}
onClick={() => onSelectPlan(plan.id)}
>
{plan.isFirstRechargeOffer ? (
<span className={styles.offerBadge}>
{plan.firstRechargeDiscountPercent ?? 50}% OFF
{badgeCount > 0 ? (
<span className={styles.badgeStack}>
{plan.mostPopular ? (
<span className={styles.popularBadge}>Most Popular</span>
) : null}
{plan.isFirstRechargeOffer ? (
<span className={styles.offerBadge}>
{plan.firstRechargeDiscountPercent ?? 50}% OFF
</span>
) : null}
</span>
) : null}
<span className={styles.planTitle}>{plan.title}</span>
@@ -82,6 +82,7 @@ export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView {
currency: formatOfferCurrency(plan.currency),
originalPrice: formatOfferAmount(plan.originalAmountCents),
isFirstRechargeOffer: plan.isFirstRechargeOffer,
mostPopular: plan.mostPopular,
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
promotionType: plan.promotionType,
};
@@ -95,6 +96,7 @@ export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView {
currency: formatCoinCurrency(plan.currency),
originalPrice: formatOfferAmount(plan.originalAmountCents),
isFirstRechargeOffer: plan.isFirstRechargeOffer,
mostPopular: plan.mostPopular,
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
promotionType: plan.promotionType,
};
@@ -20,10 +20,12 @@ describe("PaymentPlan", () => {
originalAmountCents: 2499,
dailyPriceCents: 66,
currency: "JPY",
mostPopular: true,
});
expect(plan.planId).toBe("vip_monthly");
expect(plan.creditBalance).toBe(3000);
expect(plan.mostPopular).toBe(true);
expect(plan.toJson()).toEqual({
planId: "vip_monthly",
planName: "VIP Monthly",
@@ -36,6 +38,7 @@ describe("PaymentPlan", () => {
dailyPriceCents: 66,
currency: "JPY",
isFirstRechargeOffer: false,
mostPopular: true,
firstRechargeDiscountPercent: null,
promotionType: null,
});
@@ -56,6 +59,23 @@ describe("PaymentPlan", () => {
}),
).toThrow(z.ZodError);
});
it("defaults mostPopular to false when the backend omits it", () => {
const plan = PaymentPlan.from({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 1999,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
});
expect(plan.mostPopular).toBe(false);
});
});
describe("PaymentPlansResponse", () => {
+1
View File
@@ -18,6 +18,7 @@ export class PaymentPlan {
declare readonly originalAmountCents: number | null;
declare readonly currency: string;
declare readonly isFirstRechargeOffer: boolean;
declare readonly mostPopular: boolean;
declare readonly firstRechargeDiscountPercent: number | null;
declare readonly promotionType: string | null;
+1
View File
@@ -21,6 +21,7 @@ export const PaymentPlanSchema = z.object({
dailyPriceCents: numberOrNull,
currency: z.string(),
isFirstRechargeOffer: booleanOrFalse,
mostPopular: booleanOrFalse,
firstRechargeDiscountPercent: numberOrNull,
promotionType: stringOrNull,
});