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:
parent
4ea400a70d
commit
89fcdf24c4
4 changed files with 499 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
484
site/src/pages/pricing.astro
Normal file
484
site/src/pages/pricing.astro
Normal 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: 'Let’s 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 team’s private projects - any role, reading included, owner included. Your personal team’s first three people are free - that allowance exists once, not per project and not per team: ten teams don’t 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 doesn’t 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 they’re 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
|
||||
team’s 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>
|
||||
Loading…
Reference in a new issue