Skip to content

Commit dafa78a

Browse files
committed
show unique features only
1 parent 78326d2 commit dafa78a

File tree

4 files changed

+100
-27
lines changed

4 files changed

+100
-27
lines changed

packages/plugin-zuplo-monetization/src/pages/pricing/PricingCard.tsx

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,53 @@ import { Button } from "zudoku/components";
33
import { Link } from "zudoku/router";
44
import { FeatureItem } from "../../components/FeatureItem";
55
import { QuotaItem } from "../../components/QuotaItem";
6-
import type { Plan } from "../../types/PlanType";
6+
import type { Plan, PlanPhase } from "../../types/PlanType";
77
import { categorizeRateCards } from "../../utils/categorizeRateCards";
8+
import { formatDuration } from "../../utils/formatDuration";
89
import { formatPrice } from "../../utils/formatPrice";
910
import { getPriceFromPlan } from "../../utils/getPriceFromPlan";
1011

12+
const PhaseSection = ({
13+
phase,
14+
currency,
15+
showName,
16+
excludeKeys,
17+
}: {
18+
phase: PlanPhase;
19+
currency?: string;
20+
showName: boolean;
21+
excludeKeys: Set<string>;
22+
}) => {
23+
const { quotas, features } = categorizeRateCards(phase.rateCards, currency);
24+
25+
const filteredQuotas = quotas.filter((q) => !excludeKeys.has(q.key));
26+
const filteredFeatures = features.filter((f) => !excludeKeys.has(f.key));
27+
28+
if (filteredQuotas.length === 0 && filteredFeatures.length === 0) return null;
29+
30+
return (
31+
<div className="space-y-2">
32+
{showName && (
33+
<div className="text-sm font-medium text-card-foreground">
34+
{phase.name}
35+
{phase.duration && (
36+
<span className="text-muted-foreground font-normal">
37+
{" "}
38+
&mdash; {formatDuration(phase.duration)}
39+
</span>
40+
)}
41+
</div>
42+
)}
43+
{filteredQuotas.map((quota) => (
44+
<QuotaItem key={quota.key} quota={quota} />
45+
))}
46+
{filteredFeatures.map((feature) => (
47+
<FeatureItem key={feature.key} feature={feature} />
48+
))}
49+
</div>
50+
);
51+
};
52+
1153
export const PricingCard = ({
1254
plan,
1355
isPopular = false,
@@ -17,17 +59,13 @@ export const PricingCard = ({
1759
isPopular?: boolean;
1860
isSubscribed?: boolean;
1961
}) => {
20-
const defaultPhase = plan.phases.at(-1);
21-
if (!defaultPhase) return null;
62+
if (plan.phases.length === 0) return null;
2263

23-
const { quotas, features } = categorizeRateCards(
24-
defaultPhase.rateCards,
25-
plan.currency,
26-
);
2764
const price = getPriceFromPlan(plan);
2865
const isFree = price.monthly === 0;
2966

3067
const isCustom = plan.metadata?.isCustom === true;
68+
const hasMultiplePhases = plan.phases.length > 1;
3169

3270
return (
3371
<div
@@ -81,22 +119,23 @@ export const PricingCard = ({
81119
)}
82120
</div>
83121

84-
<div className="space-y-4 mb-6 grow">
85-
{quotas.length > 0 && (
86-
<div className="space-y-2">
87-
{quotas.map((quota) => (
88-
<QuotaItem key={quota.key} quota={quota} />
89-
))}
90-
</div>
91-
)}
92-
93-
{features.length > 0 && (
94-
<div className="space-y-2">
95-
{features.map((feature) => (
96-
<FeatureItem key={feature.key} feature={feature} />
97-
))}
98-
</div>
99-
)}
122+
<div className="space-y-4 mb-6 grow">
123+
{plan.phases.map((phase, index) => {
124+
const laterKeys = new Set(
125+
plan.phases
126+
.slice(index + 1)
127+
.flatMap((p) => p.rateCards.map((rc) => rc.featureKey ?? rc.key)),
128+
);
129+
return (
130+
<PhaseSection
131+
key={phase.key}
132+
phase={phase}
133+
currency={plan.currency}
134+
showName={hasMultiplePhases}
135+
excludeKeys={laterKeys}
136+
/>
137+
);
138+
})}
100139
</div>
101140

102141
{isSubscribed ? (

packages/plugin-zuplo-monetization/src/utils/categorizeRateCards.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ export const categorizeRateCards = (
2020
rc.price?.type === "tiered" &&
2121
rc.price.tiers
2222
) {
23-
const overageTier = rc.price.tiers.find((t) => t.unitPrice?.amount);
23+
const overageTier = rc.price.tiers.find(
24+
(t) => t.unitPrice?.amount && parseFloat(t.unitPrice.amount) > 0,
25+
);
2426
if (overageTier?.unitPrice) {
2527
const amount = parseFloat(overageTier.unitPrice.amount);
2628
overagePrice = `${formatPrice(amount, currency)}/unit`;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vitest";
2+
import { formatPrice } from "./formatPrice.js";
3+
4+
describe("formatPrice", () => {
5+
it("formats whole numbers without decimals", () => {
6+
expect(formatPrice(10, "USD")).toBe("$10");
7+
});
8+
9+
it("formats two decimal places for standard amounts", () => {
10+
expect(formatPrice(0.01, "USD")).toBe("$0.01");
11+
expect(formatPrice(9.99, "USD")).toBe("$9.99");
12+
});
13+
14+
it("preserves extra decimal places beyond two", () => {
15+
expect(formatPrice(0.005, "USD")).toBe("$0.005");
16+
expect(formatPrice(0.001, "USD")).toBe("$0.001");
17+
});
18+
19+
it("formats zero without decimals", () => {
20+
expect(formatPrice(0, "USD")).toBe("$0");
21+
});
22+
23+
it("defaults to USD when no currency is provided", () => {
24+
expect(formatPrice(10)).toBe("$10");
25+
});
26+
});
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
export const formatPrice = (amount: number, currency?: string) =>
2-
new Intl.NumberFormat(undefined, {
1+
export const formatPrice = (amount: number, currency?: string) => {
2+
const decimalPlaces = Number.isInteger(amount)
3+
? 0
4+
: Math.max(2, (amount.toString().split(".")[1] ?? "").length);
5+
6+
return new Intl.NumberFormat(undefined, {
37
style: "currency",
48
currency: currency || "USD",
5-
minimumFractionDigits: 0,
9+
minimumFractionDigits: decimalPlaces,
10+
maximumFractionDigits: decimalPlaces,
611
}).format(amount);
12+
};

0 commit comments

Comments
 (0)