feat(subscription): highlight popular plans
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const PaymentPlanSchema = z.object({
|
||||
dailyPriceCents: numberOrNull,
|
||||
currency: z.string(),
|
||||
isFirstRechargeOffer: booleanOrFalse,
|
||||
mostPopular: booleanOrFalse,
|
||||
firstRechargeDiscountPercent: numberOrNull,
|
||||
promotionType: stringOrNull,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user