feat(subscription): highlight popular plans
This commit is contained in:
@@ -232,6 +232,24 @@ describe("subscription screen helpers", () => {
|
|||||||
).toBeNull();
|
).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", () => {
|
it("maps normal VIP original price outside first recharge activity", () => {
|
||||||
expect(
|
expect(
|
||||||
toVipOfferPlanViews([
|
toVipOfferPlanViews([
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { StripePaymentDialog } from "@/app/_components/payment/stripe-payment-di
|
|||||||
import { SubscriptionCtaButton } from "../subscription-cta-button";
|
import { SubscriptionCtaButton } from "../subscription-cta-button";
|
||||||
import { SubscriptionPaymentMethod } from "../subscription-payment-method";
|
import { SubscriptionPaymentMethod } from "../subscription-payment-method";
|
||||||
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
|
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", () => {
|
describe("subscription Tailwind components", () => {
|
||||||
it("renders SubscriptionCtaButton with loading state", () => {
|
it("renders SubscriptionCtaButton with loading state", () => {
|
||||||
@@ -93,4 +95,44 @@ describe("subscription Tailwind components", () => {
|
|||||||
expect(html).toContain("Payment unavailable");
|
expect(html).toContain("Payment unavailable");
|
||||||
expect(html).toContain("bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0)");
|
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;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badgeGroup {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-self: end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularBadge,
|
||||||
.offerBadge {
|
.offerBadge {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
padding: var(--subscription-offer-badge-padding, 5px 9px);
|
padding: var(--subscription-offer-badge-padding, 5px 9px);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%);
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: clamp(10px, 2.593vw, 14px);
|
font-size: clamp(10px, 2.593vw, 14px);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
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);
|
box-shadow: 0 7px 16px rgba(255, 95, 174, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +192,7 @@
|
|||||||
auto;
|
auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popularBadge,
|
||||||
.offerBadge {
|
.offerBadge {
|
||||||
padding-right: clamp(6px, 1.296vw, 7px);
|
padding-right: clamp(6px, 1.296vw, 7px);
|
||||||
padding-left: clamp(6px, 1.296vw, 7px);
|
padding-left: clamp(6px, 1.296vw, 7px);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface CoinsOfferPlanView {
|
|||||||
currency: string;
|
currency: string;
|
||||||
originalPrice?: string;
|
originalPrice?: string;
|
||||||
isFirstRechargeOffer?: boolean;
|
isFirstRechargeOffer?: boolean;
|
||||||
|
mostPopular?: boolean;
|
||||||
firstRechargeDiscountPercent?: number | null;
|
firstRechargeDiscountPercent?: number | null;
|
||||||
promotionType?: string | null;
|
promotionType?: string | null;
|
||||||
}
|
}
|
||||||
@@ -51,11 +52,16 @@ export function SubscriptionCoinsOfferSection({
|
|||||||
<FaCoins />
|
<FaCoins />
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.coins}>{plan.coins} Coins</span>
|
<span className={styles.coins}>{plan.coins} Coins</span>
|
||||||
{plan.isFirstRechargeOffer ? (
|
<span className={styles.badgeGroup}>
|
||||||
<span className={styles.offerBadge}>
|
{plan.mostPopular ? (
|
||||||
{plan.firstRechargeDiscountPercent ?? 50}% OFF
|
<span className={styles.popularBadge}>Most Popular</span>
|
||||||
</span>
|
) : null}
|
||||||
) : null}
|
{plan.isFirstRechargeOffer ? (
|
||||||
|
<span className={styles.offerBadge}>
|
||||||
|
{plan.firstRechargeDiscountPercent ?? 50}% OFF
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<span className={styles.priceGroup}>
|
<span className={styles.priceGroup}>
|
||||||
<span className={styles.price}>
|
<span className={styles.price}>
|
||||||
{plan.currency}
|
{plan.currency}
|
||||||
|
|||||||
@@ -111,21 +111,37 @@
|
|||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.offerBadge {
|
.badgeStack {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: clamp(6px, 1.481vw, 8px);
|
top: clamp(6px, 1.481vw, 8px);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
max-width: calc(100% - 12px);
|
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);
|
padding: var(--subscription-offer-badge-padding, 4px 8px);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%);
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: clamp(12px, 2.963vw, 16px);
|
font-size: clamp(10px, 2.407vw, 13px);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
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);
|
box-shadow: 0 7px 16px rgba(255, 95, 174, 0.28);
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.planTitle {
|
.planTitle {
|
||||||
@@ -136,6 +152,14 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planCardWithBadges .planTitle {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planCardWithTwoBadges .planTitle {
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.priceLine {
|
.priceLine {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface VipOfferPlanView {
|
|||||||
currency: string;
|
currency: string;
|
||||||
originalPrice: string;
|
originalPrice: string;
|
||||||
isFirstRechargeOffer?: boolean;
|
isFirstRechargeOffer?: boolean;
|
||||||
|
mostPopular?: boolean;
|
||||||
firstRechargeDiscountPercent?: number | null;
|
firstRechargeDiscountPercent?: number | null;
|
||||||
promotionType?: string | null;
|
promotionType?: string | null;
|
||||||
}
|
}
|
||||||
@@ -42,20 +43,30 @@ export function SubscriptionVipOfferSection({
|
|||||||
<div className={styles.planGrid}>
|
<div className={styles.planGrid}>
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => {
|
||||||
const selected = selectedPlanId === plan.id;
|
const selected = selectedPlanId === plan.id;
|
||||||
|
const badgeCount =
|
||||||
|
Number(plan.mostPopular === true) +
|
||||||
|
Number(plan.isFirstRechargeOffer === true);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="subscription.plan_select"
|
data-analytics-key="subscription.plan_select"
|
||||||
data-analytics-label="Select VIP plan"
|
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-pressed={selected}
|
||||||
aria-label={`${plan.title}, ${plan.price} ${plan.currency}`}
|
aria-label={`${plan.title}, ${plan.price} ${plan.currency}`}
|
||||||
onClick={() => onSelectPlan(plan.id)}
|
onClick={() => onSelectPlan(plan.id)}
|
||||||
>
|
>
|
||||||
{plan.isFirstRechargeOffer ? (
|
{badgeCount > 0 ? (
|
||||||
<span className={styles.offerBadge}>
|
<span className={styles.badgeStack}>
|
||||||
{plan.firstRechargeDiscountPercent ?? 50}% OFF
|
{plan.mostPopular ? (
|
||||||
|
<span className={styles.popularBadge}>Most Popular</span>
|
||||||
|
) : null}
|
||||||
|
{plan.isFirstRechargeOffer ? (
|
||||||
|
<span className={styles.offerBadge}>
|
||||||
|
{plan.firstRechargeDiscountPercent ?? 50}% OFF
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className={styles.planTitle}>{plan.title}</span>
|
<span className={styles.planTitle}>{plan.title}</span>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView {
|
|||||||
currency: formatOfferCurrency(plan.currency),
|
currency: formatOfferCurrency(plan.currency),
|
||||||
originalPrice: formatOfferAmount(plan.originalAmountCents),
|
originalPrice: formatOfferAmount(plan.originalAmountCents),
|
||||||
isFirstRechargeOffer: plan.isFirstRechargeOffer,
|
isFirstRechargeOffer: plan.isFirstRechargeOffer,
|
||||||
|
mostPopular: plan.mostPopular,
|
||||||
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
|
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
|
||||||
promotionType: plan.promotionType,
|
promotionType: plan.promotionType,
|
||||||
};
|
};
|
||||||
@@ -95,6 +96,7 @@ export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView {
|
|||||||
currency: formatCoinCurrency(plan.currency),
|
currency: formatCoinCurrency(plan.currency),
|
||||||
originalPrice: formatOfferAmount(plan.originalAmountCents),
|
originalPrice: formatOfferAmount(plan.originalAmountCents),
|
||||||
isFirstRechargeOffer: plan.isFirstRechargeOffer,
|
isFirstRechargeOffer: plan.isFirstRechargeOffer,
|
||||||
|
mostPopular: plan.mostPopular,
|
||||||
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
|
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
|
||||||
promotionType: plan.promotionType,
|
promotionType: plan.promotionType,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ describe("PaymentPlan", () => {
|
|||||||
originalAmountCents: 2499,
|
originalAmountCents: 2499,
|
||||||
dailyPriceCents: 66,
|
dailyPriceCents: 66,
|
||||||
currency: "JPY",
|
currency: "JPY",
|
||||||
|
mostPopular: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(plan.planId).toBe("vip_monthly");
|
expect(plan.planId).toBe("vip_monthly");
|
||||||
expect(plan.creditBalance).toBe(3000);
|
expect(plan.creditBalance).toBe(3000);
|
||||||
|
expect(plan.mostPopular).toBe(true);
|
||||||
expect(plan.toJson()).toEqual({
|
expect(plan.toJson()).toEqual({
|
||||||
planId: "vip_monthly",
|
planId: "vip_monthly",
|
||||||
planName: "VIP Monthly",
|
planName: "VIP Monthly",
|
||||||
@@ -36,6 +38,7 @@ describe("PaymentPlan", () => {
|
|||||||
dailyPriceCents: 66,
|
dailyPriceCents: 66,
|
||||||
currency: "JPY",
|
currency: "JPY",
|
||||||
isFirstRechargeOffer: false,
|
isFirstRechargeOffer: false,
|
||||||
|
mostPopular: true,
|
||||||
firstRechargeDiscountPercent: null,
|
firstRechargeDiscountPercent: null,
|
||||||
promotionType: null,
|
promotionType: null,
|
||||||
});
|
});
|
||||||
@@ -56,6 +59,23 @@ describe("PaymentPlan", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(z.ZodError);
|
).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", () => {
|
describe("PaymentPlansResponse", () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class PaymentPlan {
|
|||||||
declare readonly originalAmountCents: number | null;
|
declare readonly originalAmountCents: number | null;
|
||||||
declare readonly currency: string;
|
declare readonly currency: string;
|
||||||
declare readonly isFirstRechargeOffer: boolean;
|
declare readonly isFirstRechargeOffer: boolean;
|
||||||
|
declare readonly mostPopular: boolean;
|
||||||
declare readonly firstRechargeDiscountPercent: number | null;
|
declare readonly firstRechargeDiscountPercent: number | null;
|
||||||
declare readonly promotionType: string | null;
|
declare readonly promotionType: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const PaymentPlanSchema = z.object({
|
|||||||
dailyPriceCents: numberOrNull,
|
dailyPriceCents: numberOrNull,
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
isFirstRechargeOffer: booleanOrFalse,
|
isFirstRechargeOffer: booleanOrFalse,
|
||||||
|
mostPopular: booleanOrFalse,
|
||||||
firstRechargeDiscountPercent: numberOrNull,
|
firstRechargeDiscountPercent: numberOrNull,
|
||||||
promotionType: stringOrNull,
|
promotionType: stringOrNull,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user