Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,53 @@ import { Button } from "zudoku/components";
import { Link } from "zudoku/router";
import { FeatureItem } from "../../components/FeatureItem";
import { QuotaItem } from "../../components/QuotaItem";
import type { Plan } from "../../types/PlanType";
import type { Plan, PlanPhase } from "../../types/PlanType";
import { categorizeRateCards } from "../../utils/categorizeRateCards";
import { formatDuration } from "../../utils/formatDuration";
import { formatPrice } from "../../utils/formatPrice";
import { getPriceFromPlan } from "../../utils/getPriceFromPlan";

const PhaseSection = ({
phase,
currency,
showName,
excludeKeys,
}: {
phase: PlanPhase;
currency?: string;
showName: boolean;
excludeKeys: Set<string>;
}) => {
const { quotas, features } = categorizeRateCards(phase.rateCards, currency);

const filteredQuotas = quotas.filter((q) => !excludeKeys.has(q.key));
const filteredFeatures = features.filter((f) => !excludeKeys.has(f.key));

if (filteredQuotas.length === 0 && filteredFeatures.length === 0) return null;

return (
<div className="space-y-2">
{showName && (
<div className="text-sm font-medium text-card-foreground">
{phase.name}
{phase.duration && (
<span className="text-muted-foreground font-normal">
{" "}
&mdash; {formatDuration(phase.duration)}
</span>
)}
</div>
)}
{filteredQuotas.map((quota) => (
<QuotaItem key={quota.key} quota={quota} />
))}
{filteredFeatures.map((feature) => (
<FeatureItem key={feature.key} feature={feature} />
))}
</div>
);
};

export const PricingCard = ({
plan,
isPopular = false,
Expand All @@ -17,17 +59,13 @@ export const PricingCard = ({
isPopular?: boolean;
isSubscribed?: boolean;
}) => {
const defaultPhase = plan.phases.at(-1);
if (!defaultPhase) return null;
if (plan.phases.length === 0) return null;

const { quotas, features } = categorizeRateCards(
defaultPhase.rateCards,
plan.currency,
);
const price = getPriceFromPlan(plan);
const isFree = price.monthly === 0;

const isCustom = plan.metadata?.isCustom === true;
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plan.metadata is typed as Record<string, unknown> and elsewhere in this plugin metadata flags are treated as strings (e.g. zuplo_most_popular === "true"). Checking plan.metadata?.isCustom === true will never match if the API sends string values, so the “Custom / Contact Sales” UI may not appear. Consider checking for a string value (e.g. === "true") and/or falling back to a plan key convention (like the enterprise check used in SwitchPlanModal).

Suggested change
const isCustom = plan.metadata?.isCustom === true;
const isCustom =
plan.metadata?.isCustom === true || plan.metadata?.isCustom === "true";

Copilot uses AI. Check for mistakes.
const hasMultiplePhases = plan.phases.length > 1;

return (
<div
Expand Down Expand Up @@ -81,22 +119,23 @@ export const PricingCard = ({
)}
</div>

<div className="space-y-4 mb-6 grow">
{quotas.length > 0 && (
<div className="space-y-2">
{quotas.map((quota) => (
<QuotaItem key={quota.key} quota={quota} />
))}
</div>
)}

{features.length > 0 && (
<div className="space-y-2">
{features.map((feature) => (
<FeatureItem key={feature.key} feature={feature} />
))}
</div>
)}
<div className="space-y-4 mb-6 grow">
{plan.phases.map((phase, index) => {
const laterKeys = new Set(
plan.phases
.slice(index + 1)
.flatMap((p) => p.rateCards.map((rc) => rc.featureKey ?? rc.key)),
);
Comment on lines +123 to +128
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new phase deduping logic builds laterKeys and filters quotas/features so repeated items only render once across phases. This is a behavior change in the pricing UI, but there’s currently no test exercising multi-phase plans to verify duplicates are hidden/retained as intended. Please add a test (likely in PricingPage.test.tsx) that renders a plan with 2+ phases and asserts a repeated feature/quota only appears once.

Copilot uses AI. Check for mistakes.
return (
<PhaseSection
key={phase.key}
phase={phase}
currency={plan.currency}
showName={hasMultiplePhases}
excludeKeys={laterKeys}
/>
);
})}
</div>

{isSubscribed ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export const categorizeRateCards = (
rc.price?.type === "tiered" &&
rc.price.tiers
) {
const overageTier = rc.price.tiers.find((t) => t.unitPrice?.amount);
const overageTier = rc.price.tiers.find(
(t) => t.unitPrice?.amount && parseFloat(t.unitPrice.amount) > 0,
);
Comment on lines +23 to +25
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overage-tier selection now explicitly ignores unitPrice.amount values that parse to 0 (parseFloat(...) > 0). There’s existing test coverage for overage pricing, but no test that asserts a 0 unit price is excluded (the new behavior this change introduces). Please add a test case to prevent regressions (e.g., tiers with unitPrice.amount: "0" should not set overagePrice).

Copilot uses AI. Check for mistakes.
if (overageTier?.unitPrice) {
const amount = parseFloat(overageTier.unitPrice.amount);
overagePrice = `${formatPrice(amount, currency)}/unit`;
Expand Down
26 changes: 26 additions & 0 deletions packages/plugin-zuplo-monetization/src/utils/formatPrice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { formatPrice } from "./formatPrice.js";

describe("formatPrice", () => {
it("formats whole numbers without decimals", () => {
expect(formatPrice(10, "USD")).toBe("$10");
});

it("formats two decimal places for standard amounts", () => {
expect(formatPrice(0.01, "USD")).toBe("$0.01");
expect(formatPrice(9.99, "USD")).toBe("$9.99");
});

it("preserves extra decimal places beyond two", () => {
expect(formatPrice(0.005, "USD")).toBe("$0.005");
expect(formatPrice(0.001, "USD")).toBe("$0.001");
});

it("formats zero without decimals", () => {
expect(formatPrice(0, "USD")).toBe("$0");
});

it("defaults to USD when no currency is provided", () => {
expect(formatPrice(10)).toBe("$10");
});
});
6 changes: 4 additions & 2 deletions packages/plugin-zuplo-monetization/src/utils/formatPrice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const formatPrice = (amount: number, currency?: string) =>
new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency || "USD",
minimumFractionDigits: 0,
currency: currency ?? "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 6,
trailingZeroDisplay: "stripIfInteger",
}).format(amount);
Loading