Shared pricing cards and comparison table for the KeeperHub app and the marketing landing. One source of truth for both the look and the pricing data.
- Components ship as a versioned React package (SSR-safe, bundled at build time).
- Data lives in
pricing-data.jsonand is published to a static URL, so both apps fetch it at runtime and a data change reflects on both with no app redeploy.
import {
PricingCards,
PricingComparisonTable,
defaultPricingData,
defaultPricingCards,
defaultComparisonColumns,
defaultComparisonRows,
} from "keeperhub-pricing-ui";
import "keeperhub-pricing-ui/styles.css"; // import once, near your rootBoth components are controlled and presentational: they render from data props and delegate every interaction to handlers you pass in. Pass nothing and they render the bundled defaults.
Zero-config (uses the bundled defaults, good for a quick render or offline fallback):
<PricingCards />
<PricingComparisonTable />Landing (link CTAs, data fetched at runtime so edits need no redeploy):
// server component: fetch with ISR
const data = await fetch(PRICING_DATA_URL, { next: { revalidate: 300 } })
.then((r) => r.json())
.catch(() => defaultPricingData); // fall back to the bundled copy
<PricingCards cards={data.cards} />
<PricingComparisonTable columns={data.comparison.columns} rows={data.comparison.rows} />App (override the free card with the live PAYG price, wire CTAs to checkout, highlight the current plan):
const cards = data.cards.map((c) =>
c.id === "free" ? { ...c, fixedPrice: `$${livePaygPrice}` } : c
);
<PricingCards
cards={cards.map((c) => ({
...c,
cta: { ...c.cta, href: undefined, onClick: (ctx) => startCheckout(ctx) },
}))}
onTierChange={(cardId, tierKey) => trackTier(cardId, tierKey)}
/>
<PricingComparisonTable currentColumnKey={currentPlan} />pricing-data.json holds cards and comparison. See src/types.ts for the full shapes. Cards support either a fixedPrice (free / enterprise) or tiers (the card renders a selector and derives price + metrics from the selected tier and the billing interval). CTAs are serializable in the JSON (label + href); consumers attach onClick at render time.
| Change | What to do | Redeploy apps? |
|---|---|---|
| Prices, tiers, rows, copy | edit pricing-data.json, push to main |
No. Pages republishes the JSON; both apps pick it up within the ISR window. |
| Component layout / styling / behavior | change src/**, bump version, publish |
Yes. Bump the dependency in each app and redeploy. |
The package publishes to GitHub Packages; the data publishes to GitHub Pages. Both run in CI, but the repo and Pages have to be set up once:
- Create the
KeeperHub/pricing-uirepo and push this folder tomain. - In the repo settings, enable Pages with source "GitHub Actions". The
deploy-data.ymlworkflow then servespricing-data.jsonathttps://keeperhub.github.io/pricing-ui/pricing-data.json. - Add an
NPM_TOKENsecret to this repo (an npm automation token for the account or org that owns the package name). - To publish a version: bump
versioninpackage.json, tag it (git tag v0.1.0 && git push --tags). Thepublish-package.ymlworkflow builds and publishes to the public npm registry. - Consumer repos (app, landing) install it like any public npm package (
pnpm add keeperhub-pricing-ui). No auth token, no.npmrc, and it installs unchanged inside Docker builds and CI.
npm install
npm run build # tsup -> dist (esm + cjs + d.ts)
npm run typecheck