feat(site): pricing page (plan cards, billing toggle, coming-soon dialog)

Free/Pro live, Team/Enterprise as coming-soon cards; monthly/yearly price
toggle; CTAs open a coming-soon dialog pointing at the waitlist; Pricing
link in header nav + footer; header chip nowrap + earlier hamburger
breakpoint; CheckItem gains an optional dimmed note suffix.

Copy source of truth: docs/features/pricing/copy.md (root repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RQcUBzy8mpYXkup9eNY1z
This commit is contained in:
Istvan Matejcsok 2026-07-10 13:54:39 +02:00
commit 89fcdf24c4
4 changed files with 499 additions and 5 deletions

View file

@ -2,13 +2,15 @@
/** CheckItem — a ✓ capability row (§12). Render inside a <ul class="checklist">. */
interface Props {
label: string;
/** Optional dimmed suffix rendered as "(note)", e.g. "coming soon". */
note?: string;
}
const { label } = Astro.props;
const { label, note } = Astro.props;
---
<li class="check">
<span class="tick" aria-hidden="true">✓</span>
<span>{label}</span>
<span>{label}{note && <span class="note"> ({note})</span>}</span>
</li>
<style>
@ -23,4 +25,9 @@ const { label } = Astro.props;
font-weight: 700;
line-height: 1.5;
}
.note {
color: var(--fg-muted);
font-size: 0.9em;
white-space: nowrap;
}
</style>

View file

@ -46,6 +46,7 @@ const BUILD_COMMIT_URL = `https://github.com/emergence-engineering/pcbjam/commit
<nav class="links stack" aria-label="Product">
<a href="/#how-it-works">How it works</a>
<a href="/#open">Open &amp; yours</a>
<a href="/pricing">Pricing</a>
<a href="https://demo.pcbjam.com" target="_blank" rel="noopener">Live demo</a>
<a href="/#waitlist">Join the waitlist</a>
</nav>

View file

@ -5,6 +5,7 @@
const links = [
{ href: '/#how-it-works', label: 'How it works' },
{ href: '/#open', label: 'Open & yours' },
{ href: '/pricing', label: 'Pricing' },
{ href: '/blog', label: 'Blog' },
{
href: 'https://emergence-engineering.com',
@ -87,6 +88,7 @@ const isActive = (href: string, external?: boolean) =>
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.1rem 0.4rem;
white-space: nowrap;
}
.nav {
display: flex;
@ -141,9 +143,9 @@ const isActive = (href: string, external?: boolean) =>
}
/* Collapse to the hamburger menu once the full one-line nav no longer fits the
1024px-capped bar (adding the demo link pushed the crowded desktop nav past
the point where it stays on a single row). */
@media (max-width: 1024px) {
1024px-capped bar (the demo link, then the Pricing link, pushed the crowded
desktop nav past the point where it stays on a single row). */
@media (max-width: 1120px) {
.brand-chip {
display: none;
}

View file

@ -0,0 +1,484 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import SectionBand from '../components/SectionBand.astro';
import CheckItem from '../components/CheckItem.astro';
// Copy source of truth: docs/features/pricing/copy.md (root repo). Team and
// Enterprise are v1/v2 - rendered as "coming soon" cards (no live CTA) so the
// launch page sells only Free + Pro while still telling teams what's ahead.
// TODO(launch): Free/Pro CTAs open the coming-soon dialog until signup ships;
// then point them at the real signup/billing flow.
const description =
'PCBJam pricing - everything public is free, for everyone, forever. Private work is free for three people and seven projects in your personal team, then one seat per person.';
type Price = { amount: string; period?: string };
type Feature = string | { label: string; note: string };
const plans: {
name: string;
tagline: string;
price: { monthly: Price; yearly?: Price };
features: Feature[];
cta: { label: string } | null;
soon: boolean;
featured: boolean;
}[] = [
{
name: 'Free',
price: { monthly: { amount: '$0' } },
tagline: 'For open hardware, learning, and side projects.',
features: [
'Unlimited public projects',
'7 private projects',
'3 people on private work, in your personal team (owner included)',
'1 GB storage',
'Unlimited free members on public projects - view, comment & edit',
],
cta: { label: 'Start free' },
soon: false,
featured: false,
},
{
name: 'Pro',
price: {
monthly: { amount: '$10', period: '/month' },
yearly: { amount: '$100', period: '/year' },
},
tagline: 'For the independent engineer.',
features: [
'Everything in the Free plan',
'Unlimited private projects in your personal team',
{
label: 'AI assistant + MCP, credits included monthly - on any project you can edit',
note: 'coming soon',
},
'10 GB storage',
{ label: 'Create teams beyond your default one', note: 'coming soon' },
],
cta: { label: 'Go Pro' },
soon: false,
featured: true,
},
{
name: 'Team',
price: {
monthly: { amount: '$30', period: '/seat/month' },
yearly: { amount: '$300', period: '/seat/year' },
},
tagline: 'For teams shipping hardware.',
features: [
'Every person with private access is a seat - any role, owner included',
'Unlimited private projects',
'AI + MCP for the whole team, on team projects',
'10 GB + 5 GB per seat storage',
'Roles, permissions, seat management',
],
cta: null,
soon: true,
featured: false,
},
{
name: 'Enterprise',
price: { monthly: { amount: 'Lets talk' } },
tagline: 'For organizations with requirements.',
features: [
'SSO/SAML and audit logs',
'Pay by invoice',
'Pooled AI credits with spend limits',
'Priority support',
],
cta: null,
soon: true,
featured: false,
},
];
const faqs = [
{
q: 'Who counts toward billing?',
a: 'Anyone with access to a teams private projects - any role, reading included, owner included. Your personal teams first three people are free - that allowance exists once, not per project and not per team: ten teams dont make thirty free people. Shared teams bill every person with private access. Public involvement never counts, for anyone.',
},
{
q: 'Do viewers and commenters pay?',
a: 'Anyone can view public projects. Commenting and editing need membership (the reader role is enough to comment) - free and unlimited on public projects. On private projects, members use one of your three free spots or a seat - the role doesnt matter, having access to private work is what counts.',
},
{
q: 'We stopped paying - what happens?',
a: 'Nothing is deleted. A shared team falls back to public-only: its private projects go read-only until seats return. Your personal team falls back to the free tier: 3 people and 7 private projects, extras read-only. Public projects are never touched.',
},
{
q: 'What happens when I add someone to private work?',
a: 'On your personal team the first three people are free. Beyond them - and for everyone on a shared team - private access needs a seat: with an unassigned seat theyre in immediately, otherwise the billing admin gets a seat request to confirm. We never charge silently.',
},
{
q: 'Can I move a team to someone else?',
a: 'Yes - ownership and billing transfer separately, and the recipient confirms before anything changes. Your projects and history stay put.',
},
{
q: 'How does yearly billing work?',
a: 'Pay for 10 months, get 12.',
},
{
q: 'What about tax and invoices?',
a: 'Checkout, VAT/sales tax, and receipts are handled by Paddle, our merchant of record. Businesses can enter a VAT ID at checkout for reverse-charge invoices.',
},
];
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'PCBJam',
applicationCategory: 'DesignApplication',
operatingSystem: 'Web browser',
description,
offers: [
{ '@type': 'Offer', name: 'Free', price: '0', priceCurrency: 'USD' },
{ '@type': 'Offer', name: 'Pro', price: '10', priceCurrency: 'USD' },
],
};
---
<BaseLayout title="Pricing - PCBJam" description={description} wide>
<script
slot="head"
type="application/ld+json"
is:inline
set:html={JSON.stringify(jsonLd)}
/>
<SectionBand labelledby="pricing-h1" grid>
<div class="section-head">
<span class="eyebrow">Pricing</span>
<h1 id="pricing-h1">Design boards together.</h1>
<p class="lead">
Everything public is <strong>free</strong> - for everyone, forever. Private work is free
for up to three people and seven projects in your personal team - beyond that, one seat
per person.
</p>
</div>
<div class="billing-toggle" role="group" aria-label="Billing period">
<button type="button" class="bt-opt active" data-period="monthly" aria-pressed="true">
Monthly
</button>
<button type="button" class="bt-opt" data-period="yearly" aria-pressed="false">
Yearly <span class="bt-hint">2 months free</span>
</button>
</div>
<div class="plans" data-period="monthly">
{
plans.map((p) => (
<div class:list={['card', 'plan', { featured: p.featured, soon: p.soon }]}>
<div class="plan-head">
<span class="plan-name">{p.name}</span>
{p.soon && <span class="soon-chip">coming soon</span>}
</div>
<div class="plan-price">
{p.price.yearly ? (
<>
<span class="price price-m">
<span class="amount">{p.price.monthly.amount}</span>
{p.price.monthly.period && <span class="period">{p.price.monthly.period}</span>}
</span>
<span class="price price-y" hidden>
<span class="amount">{p.price.yearly.amount}</span>
{p.price.yearly.period && <span class="period">{p.price.yearly.period}</span>}
</span>
</>
) : (
<span class="price">
<span class="amount">{p.price.monthly.amount}</span>
{p.price.monthly.period && <span class="period">{p.price.monthly.period}</span>}
</span>
)}
</div>
<p class="tagline">{p.tagline}</p>
<ul class="checklist">
{p.features.map((f) =>
typeof f === 'string' ? (
<CheckItem label={f} />
) : (
<CheckItem label={f.label} note={f.note} />
),
)}
</ul>
{p.cta && (
<button
type="button"
class:list={['btn', 'plan-cta', p.featured ? 'btn-primary' : 'btn-ghost']}
>
{p.cta.label}
</button>
)}
</div>
))
}
</div>
<p class="deal muted">
Public is free for everyone. Private collaboration is what you pay for - your personal
teams first three people are on us.
</p>
</SectionBand>
<SectionBand tone="soft" labelledby="faq-h2">
<div class="section-head">
<span class="eyebrow">FAQ</span>
<h2 id="faq-h2">Questions, answered plainly.</h2>
</div>
<dl class="faq">
{
faqs.map((f) => (
<div class="faq-item">
<dt>{f.q}</dt>
<dd class="muted">{f.a}</dd>
</div>
))
}
</dl>
</SectionBand>
<dialog id="coming-soon" class="coming-soon" aria-labelledby="coming-soon-h3">
<div class="dialog-body">
<h3 id="coming-soon-h3">Coming soon</h3>
<p class="muted">
Plans aren't live yet - we're getting signup and checkout ready. Join the waitlist and
we'll tell you the moment they open.
</p>
<div class="dialog-actions">
<a class="btn btn-primary" href="/#waitlist">Join the waitlist</a>
<button type="button" class="btn btn-ghost dialog-close">Close</button>
</div>
</div>
</dialog>
</BaseLayout>
<script>
// Monthly/yearly price toggle. Re-inits after ClientRouter page swaps; the
// swap replaces the DOM, so the bound-flag resets with it.
function initBillingToggle() {
const wrap = document.querySelector<HTMLElement>('.billing-toggle');
const plans = document.querySelector<HTMLElement>('.plans');
if (!wrap || !plans || wrap.dataset.bound === '1') return;
wrap.dataset.bound = '1';
wrap.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-period]');
if (!btn) return;
const yearly = btn.dataset.period === 'yearly';
plans.dataset.period = yearly ? 'yearly' : 'monthly';
// Visibility via the [hidden] attribute (global reset: display:none
// !important) - independent of scoped-CSS delivery.
plans.querySelectorAll<HTMLElement>('.price-m').forEach((el) => (el.hidden = yearly));
plans.querySelectorAll<HTMLElement>('.price-y').forEach((el) => (el.hidden = !yearly));
wrap.querySelectorAll('button').forEach((b) => {
b.classList.toggle('active', b === btn);
b.setAttribute('aria-pressed', String(b === btn));
});
});
}
document.addEventListener('astro:page-load', initBillingToggle);
// "Coming soon" dialog for the Free/Pro CTAs (no signup flow yet). Native
// <dialog>: Esc closes for free; backdrop clicks are detected because the
// padded .dialog-body swallows inside clicks, so e.target === dialog only
// on the backdrop.
function initComingSoon() {
const dialog = document.querySelector<HTMLDialogElement>('#coming-soon');
if (!dialog) return;
// A ClientRouter swap can re-insert the dialog with a stray `open`
// attribute (renders as an unstyled non-modal box) — always arrive closed.
if (dialog.open) dialog.close();
dialog.removeAttribute('open');
if (dialog.dataset.bound === '1') return;
dialog.dataset.bound = '1';
document
.querySelectorAll<HTMLButtonElement>('.plan-cta')
.forEach((b) => b.addEventListener('click', () => dialog.showModal()));
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
dialog.querySelector('.dialog-close')?.addEventListener('click', () => dialog.close());
}
document.addEventListener('astro:page-load', initComingSoon);
</script>
<style>
.billing-toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
box-shadow: 0 1px 2px rgb(16 23 41 / 0.05);
margin-bottom: 2rem;
}
.bt-opt {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: none;
background: transparent;
padding: 0.45rem 1.1rem;
border-radius: 999px;
font: inherit;
font-size: 0.95rem;
font-weight: 600;
color: var(--fg-muted);
cursor: pointer;
transition:
background-color var(--dur) var(--ease),
color var(--dur) var(--ease);
}
.bt-opt:hover {
color: var(--fg);
}
.bt-opt.active {
background: var(--brand-600);
color: #fff;
}
.bt-opt.active:hover {
color: #fff;
}
.bt-hint {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.01em;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--signal-600) 12%, transparent);
color: var(--signal-600);
white-space: nowrap;
}
.bt-opt.active .bt-hint {
background: rgb(255 255 255 / 0.2);
color: #fff;
}
.plans {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
align-items: stretch;
}
.plan {
display: flex;
flex-direction: column;
background: var(--bg);
}
.plan.featured {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.plan.soon {
background: var(--bg-soft);
}
.plan-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.plan-name {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--fg-muted);
text-transform: uppercase;
}
.soon-chip {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--fg-muted);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.1rem 0.4rem;
white-space: nowrap;
}
.plan-price {
margin-top: 0.5rem;
}
.price {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.price .amount {
font-size: 1.9rem;
font-weight: 700;
line-height: 1.2;
}
.price .period {
color: var(--fg-muted);
font-size: 0.9rem;
}
.tagline {
margin: 0.75rem 0 0;
font-weight: 600;
}
.checklist {
list-style: none;
padding: 0;
margin: 0.75rem 0 1.25rem;
font-size: 0.95rem;
flex: 1;
}
.plan .btn {
width: 100%;
}
.deal {
margin-top: 2rem;
max-width: 44rem;
}
.faq {
margin: 0;
display: grid;
gap: 1.5rem;
max-width: 48rem;
}
.faq-item dt {
font-weight: 600;
margin-bottom: 0.25rem;
}
.faq-item dd {
margin: 0;
}
</style>
<style is:global>
/* Dialog styles are global, keyed by the unique #coming-soon id: the modal
must render correctly even when a ClientRouter swap re-inserts it without
Astro's scoped-style attribute (observed in dev SPA navigation). */
#coming-soon {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0;
max-width: 24rem;
background: var(--bg);
color: var(--fg);
box-shadow: 0 20px 50px rgb(16 23 41 / 0.25);
}
#coming-soon::backdrop {
background: rgb(16 23 41 / 0.45);
backdrop-filter: blur(2px);
}
#coming-soon .dialog-body {
padding: 1.75rem;
}
#coming-soon .dialog-body h3 {
margin-bottom: 0.5rem;
}
#coming-soon .dialog-body p {
margin: 0 0 1.25rem;
}
#coming-soon .dialog-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
</style>