Skip to content

Commit da3f809

Browse files
moschclaude
andauthored
Extract pricing UI components into standalone, framework-agnostic module (#2433)
* Add pure pricing-ui subpath export Extract the pricing table components (PricingTable, PricingCard, PlanEntitlements, FeatureItem, QuotaItem) into a new src/pricing-ui module, exposed as @zuplo/zudoku-plugin-monetization/pricing-ui. The new module has no dependency on zudoku runtime (router, hooks, components). It uses lucide-react + a local clsx/tailwind-merge cn util and accepts the CTA button as a render prop, so consumers in other projects can wire up their own Button/Link. PricingPage now renders the pure PricingTable internally and injects the zudoku Button/Link via renderAction. * Document pricing-ui consumer requirements Spell out the shadcn-style Tailwind tokens the module relies on so consumers know which CSS variables / theme setup is required. Addresses Copilot review feedback on PR #2433. * Drop lucide-react peer and add renderCard slot Two pieces of consumer feedback from integrating pricing-ui into the portal monetization preview: * The lucide-react peer (>=1.0.0) was too narrow — portal is still on 0.546.x and had to fall back to --legacy-peer-deps. Since the module only uses a single icon, inline the Check SVG locally and drop the peer dep entirely. The module now has zero icon-library coupling. * No per-card slot on <PricingTable> for overlay UI (drag handles, kebab menus, etc.). Add a renderCard(plan, { isPopular, defaultCard }) render prop so consumers can wrap or replace individual cards while keeping the table's grid, empty state, and tax legend. * Derive monthly/yearly fallback in getPriceFromPlan Consumers that don't have server-computed monthlyPrice/yearlyPrice on their Plan objects were having to mirror a price derivation step locally (sum flat-fee rate cards on the last phase, multiply by a months-per-cadence factor). Move that into pricing-ui so it's the same logic on both sides: * getPriceFromPlan keeps preferring plan.monthlyPrice / yearlyPrice when set (zudoku metering API path is unchanged), and now falls back to a derivation from plan.phases when both are nullish. * Export derivePriceFromPlan(plan) from /pricing-ui for consumers that want to materialize the numbers upfront. * Cadence -> months conversion uses tinyduration so any ISO 8601 duration (P1Y, P3M, P1W, P30D, ...) is handled, with weeks/days using their average-month approximations (12/52, 1/30). * Defensive against partial Plan shapes (subscription.plan stub used in /subscriptions code paths has no phases / rateCards). * Enforce zudoku-free pricing-ui via biome rule Use noRestrictedImports with gitignore-style patterns in an override that targets pricing-ui plus the shared utils/types files it pulls in. Direct OR transitive zudoku imports now fail the biome ci check (which already runs in pnpm check), guaranteeing that the /pricing-ui subpath stays consumable in projects without zudoku installed. Replaces the ad-hoc dependency-graph trace test that was briefly added in the previous commit. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4ca788 commit da3f809

20 files changed

Lines changed: 688 additions & 93 deletions

biome.jsonc

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,34 @@
123123
}
124124
}
125125
}
126+
},
127+
{
128+
// Anything reachable from the pure /pricing-ui subpath export of
129+
// @zuplo/zudoku-plugin-monetization MUST stay zudoku-free so it can be
130+
// consumed in projects that don't have zudoku installed. This applies
131+
// to pricing-ui itself plus the shared utils/types it pulls in.
132+
"includes": [
133+
"packages/plugin-zuplo-monetization/src/pricing-ui/**",
134+
"packages/plugin-zuplo-monetization/src/utils/**",
135+
"packages/plugin-zuplo-monetization/src/types/**"
136+
],
137+
"linter": {
138+
"rules": {
139+
"style": {
140+
"noRestrictedImports": {
141+
"level": "error",
142+
"options": {
143+
"patterns": [
144+
{
145+
"group": ["zudoku", "zudoku/**"],
146+
"message": "pricing-ui (and its transitive utils/types) must remain zudoku-free — it ships as a standalone subpath consumed by external projects."
147+
}
148+
]
149+
}
150+
}
151+
}
152+
}
153+
}
126154
}
127155
],
128156
"assist": { "enabled": false }

packages/plugin-zuplo-monetization/package.json

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"main": "./src/index.ts",
1111
"types": "./src/index.ts",
1212
"exports": {
13-
".": "./src/index.ts"
13+
".": "./src/index.ts",
14+
"./pricing-ui": "./src/pricing-ui/index.ts"
1415
},
1516
"files": [
1617
"dist"
@@ -26,12 +27,18 @@
2627
"types": "./dist/index.d.mts",
2728
"exports": {
2829
".": {
29-
"import": "./dist/index.mjs",
30-
"types": "./dist/index.d.mts"
30+
"types": "./dist/index.d.mts",
31+
"import": "./dist/index.mjs"
32+
},
33+
"./pricing-ui": {
34+
"types": "./dist/pricing-ui.d.mts",
35+
"import": "./dist/pricing-ui.mjs"
3136
}
3237
}
3338
},
3439
"dependencies": {
40+
"clsx": "2.1.1",
41+
"tailwind-merge": "3.5.0",
3542
"tinyduration": "3.4.1"
3643
},
3744
"devDependencies": {
@@ -50,5 +57,10 @@
5057
"react": ">=19.2.0",
5158
"react-dom": ">=19.2.0",
5259
"zudoku": "*"
60+
},
61+
"peerDependenciesMeta": {
62+
"zudoku": {
63+
"optional": true
64+
}
5365
}
5466
}

packages/plugin-zuplo-monetization/src/pages/CheckoutConfirmPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { Link, useNavigate, useSearchParams } from "zudoku/router";
66
import { Alert, AlertDescription, AlertTitle } from "zudoku/ui/Alert";
77
import { Card, CardContent, CardHeader, CardTitle } from "zudoku/ui/Card";
88
import { Separator } from "zudoku/ui/Separator";
9-
import { PlanEntitlements } from "../components/PlanEntitlements.js";
109
import { useDeploymentName } from "../hooks/useDeploymentName";
1110
import { usePurchaseDetails } from "../hooks/usePurchaseDetails";
1211
import { useMonetizationConfig } from "../MonetizationContext";
12+
import { PlanEntitlements } from "../pricing-ui/PlanEntitlements.js";
1313
import type { Subscription } from "../types/SubscriptionType.js";
1414
import { formatBillingCycle } from "../utils/formatBillingCycle";
1515
import { formatDuration } from "../utils/formatDuration";

packages/plugin-zuplo-monetization/src/pages/PricingPage.tsx

Lines changed: 25 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import { Head, Heading, Slot } from "zudoku/components";
1+
import { Button, Head, Heading, Slot } from "zudoku/components";
22
import { useAuth, useZudoku } from "zudoku/hooks";
33
import { useQuery } from "zudoku/react-query";
4+
import { Link } from "zudoku/router";
45
import { useDeploymentName } from "../hooks/useDeploymentName";
56
import { usePlans } from "../hooks/usePlans";
67
import type { SubscriptionsResponse } from "../hooks/useSubscriptions";
78
import { useMonetizationConfig } from "../MonetizationContext";
8-
import {
9-
collectDefaultTaxBehaviors,
10-
taxBehaviorLegendSentence,
11-
} from "../utils/pricingTaxLegend.js";
12-
import { PricingCard } from "./pricing/PricingCard";
9+
import { PricingTable } from "../pricing-ui/PricingTable.js";
1310

1411
const PricingPage = () => {
1512
const { pricing } = useMonetizationConfig();
@@ -19,10 +16,6 @@ const PricingPage = () => {
1916
const auth = useAuth();
2017

2118
const { data: pricingTable } = usePlans();
22-
const firstPlan = pricingTable.items[0];
23-
const taxLegendSentence = firstPlan
24-
? taxBehaviorLegendSentence(collectDefaultTaxBehaviors(firstPlan))
25-
: undefined;
2619

2720
const { data: subscriptions = { items: [] } } =
2821
useQuery<SubscriptionsResponse>({
@@ -33,6 +26,10 @@ const PricingPage = () => {
3326
enabled: auth.isAuthenticated,
3427
});
3528

29+
const isSubscribed = subscriptions.items.some((subscription) =>
30+
["active", "canceled"].includes(subscription.status),
31+
);
32+
3633
return (
3734
<div className="w-full px-4 pt-(--padding-content-top) pb-(--padding-content-bottom)">
3835
<Head>
@@ -54,42 +51,24 @@ const PricingPage = () => {
5451
"See our pricing options and choose the one that best suits your needs."}
5552
</p>
5653
</div>
57-
{pricingTable.items.length === 0 ? (
58-
<div className="text-center py-12 text-muted-foreground">
59-
<p>No plans are currently available.</p>
60-
<p className="text-sm mt-2">
61-
Make sure your plans are set up and published.
62-
</p>
63-
</div>
64-
) : (
65-
<>
66-
<div className="w-full grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(300px,max-content))] justify-center gap-6">
67-
{pricingTable.items.map((plan) => (
68-
<PricingCard
69-
key={plan.id}
70-
plan={plan}
71-
isPopular={plan.metadata?.zuplo_most_popular === "true"}
72-
isSubscribed={subscriptions.items.some((subscription) =>
73-
["active", "canceled"].includes(subscription.status),
74-
)}
75-
/>
76-
))}
77-
</div>
78-
{taxLegendSentence && (
79-
<div
80-
role="note"
81-
className="mt-10 pt-6 border-t border-border max-w-2xl mx-auto text-center space-y-2"
82-
>
83-
<p className="text-xs font-medium text-muted-foreground">
84-
Tax & Pricing
85-
</p>
86-
<p className="text-xs text-muted-foreground">
87-
{taxLegendSentence}
88-
</p>
89-
</div>
90-
)}
91-
</>
92-
)}
54+
<PricingTable
55+
plans={pricingTable.items}
56+
showYearlyPrice={pricing?.showYearlyPrice !== false}
57+
units={pricing?.units}
58+
renderAction={(plan, isPopular) =>
59+
isSubscribed ? (
60+
<Button variant={isPopular ? "default" : "outline"} asChild>
61+
<Link to={`/subscriptions#manage`}>Manage Subscriptions</Link>
62+
</Button>
63+
) : (
64+
<Button variant={isPopular ? "default" : "outline"} asChild>
65+
<Link to={`/checkout?planId=${encodeURIComponent(plan.id)}`}>
66+
Subscribe
67+
</Link>
68+
</Button>
69+
)
70+
}
71+
/>
9372
<Slot.Target name="pricing-page-after" />
9473
</div>
9574
);

packages/plugin-zuplo-monetization/src/pages/SubscriptionChangeConfirmPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { Link, useNavigate, useSearchParams } from "zudoku/router";
66
import { Alert, AlertDescription, AlertTitle } from "zudoku/ui/Alert";
77
import { Card, CardContent, CardHeader, CardTitle } from "zudoku/ui/Card";
88
import { Separator } from "zudoku/ui/Separator";
9-
import { PlanEntitlements } from "../components/PlanEntitlements.js";
109
import { useDeploymentName } from "../hooks/useDeploymentName";
1110
import { usePurchaseDetails } from "../hooks/usePurchaseDetails";
1211
import { useMonetizationConfig } from "../MonetizationContext";
12+
import { PlanEntitlements } from "../pricing-ui/PlanEntitlements.js";
1313
import type { Subscription } from "../types/SubscriptionType.js";
1414
import { formatBillingCycle } from "../utils/formatBillingCycle";
1515
import { formatDuration } from "../utils/formatDuration";
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { SVGProps } from "react";
2+
3+
/**
4+
* Inline `Check` icon, visually identical to `lucide-react`'s `CheckIcon`
5+
* (path: `M20 6 9 17l-5-5`). Kept local so the module has no icon-library
6+
* peer dep and consumers don't need a specific lucide-react version.
7+
*/
8+
export const CheckIcon = (props: SVGProps<SVGSVGElement>) => (
9+
<svg
10+
xmlns="http://www.w3.org/2000/svg"
11+
width={24}
12+
height={24}
13+
viewBox="0 0 24 24"
14+
fill="none"
15+
stroke="currentColor"
16+
strokeWidth={2}
17+
strokeLinecap="round"
18+
strokeLinejoin="round"
19+
aria-hidden="true"
20+
{...props}
21+
>
22+
<path d="M20 6 9 17l-5-5" />
23+
</svg>
24+
);

packages/plugin-zuplo-monetization/src/components/FeatureItem.test.tsx renamed to packages/plugin-zuplo-monetization/src/pricing-ui/FeatureItem.test.tsx

File renamed without changes.

packages/plugin-zuplo-monetization/src/components/FeatureItem.tsx renamed to packages/plugin-zuplo-monetization/src/pricing-ui/FeatureItem.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { cn } from "zudoku";
2-
import { CheckIcon } from "zudoku/icons";
3-
import type { Feature } from "../types/PlanType";
1+
import type { Feature } from "../types/PlanType.js";
2+
import { CheckIcon } from "./CheckIcon.js";
3+
import { cn } from "./cn.js";
44

55
export const FeatureItem = ({
66
feature,

packages/plugin-zuplo-monetization/src/components/PlanEntitlements.test.tsx renamed to packages/plugin-zuplo-monetization/src/pricing-ui/PlanEntitlements.test.tsx

File renamed without changes.

packages/plugin-zuplo-monetization/src/components/PlanEntitlements.tsx renamed to packages/plugin-zuplo-monetization/src/pricing-ui/PlanEntitlements.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { PlanPhase } from "../types/PlanType.js";
22
import { categorizeRateCards } from "../utils/categorizeRateCards.js";
33
import { formatDuration } from "../utils/formatDuration.js";
4-
import { FeatureItem } from "./FeatureItem";
5-
import { QuotaItem } from "./QuotaItem";
4+
import { FeatureItem } from "./FeatureItem.js";
5+
import { QuotaItem } from "./QuotaItem.js";
66

77
const PhaseSection = ({
88
phase,

0 commit comments

Comments
 (0)