Cennik
Przełącznik okresu rozliczeniowego, trzy plany, tabela porównania funkcji i najczęstsze pytania.
Dodaj ten przykład do projektu z zainstalowanym prfct. Polecenie zapisuje poniższe pliki i instaluje komponenty, których używają.
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/pricing.json"use client"
import * as React from "react"
import { CheckIcon, MinusIcon } from "lucide-react"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"
type Period = "monthly" | "yearly"
type PlanId = "hobby" | "pro" | "enterprise"
type Plan = {
id: PlanId
name: string
description: string
/** Euros per user and month when billed monthly; null is quoted by sales. */
monthly: number | null
cta: string
includes: string
features: string[]
}
const plans: Plan[] = [
{
id: "hobby",
name: "Hobby",
description: "For personal projects and experiments.",
monthly: 0,
cta: "Start for free",
includes: "Includes",
features: [
"3 projects",
"100 build minutes a month",
"100 GB bandwidth",
"A preview deployment for every push",
"Automatic HTTPS and custom domains",
"Community support",
],
},
{
id: "pro",
name: "Pro",
description: "For teams that ship to production every week.",
monthly: 24,
cta: "Start a trial",
includes: "Everything in Hobby, plus",
features: [
"Unlimited projects",
"6,000 build minutes a month",
"1 TB bandwidth",
"4 concurrent builds",
"Roles and shared environment variables",
"Email support within one business day",
],
},
{
id: "enterprise",
name: "Enterprise",
description: "For organizations with security and compliance needs.",
monthly: null,
cta: "Contact sales",
includes: "Everything in Pro, plus",
features: [
"SAML single sign-on and SCIM",
"Custom build capacity and bandwidth",
"Deployment approvals and audit log export",
"Data residency in the EU or US",
"99.99% uptime SLA",
"A dedicated support engineer",
],
},
]
/** One value per plan, in the order of `plans`: a count, text, or included / not included. */
type Value = number | string | boolean
const comparison: {
group: string
rows: { feature: string; values: [Value, Value, Value] }[]
}[] = [
{
group: "Usage",
rows: [
{ feature: "Projects", values: [3, "Unlimited", "Unlimited"] },
{ feature: "Build minutes per month", values: [100, 6000, "Custom"] },
{ feature: "Bandwidth per month", values: ["100 GB", "1 TB", "Custom"] },
{ feature: "Concurrent builds", values: [1, 4, "Custom"] },
{ feature: "Preview deployments", values: [true, true, true] },
{ feature: "Log retention", values: ["1 day", "30 days", "1 year"] },
],
},
{
group: "Collaboration",
rows: [
{ feature: "Team members", values: [1, "Unlimited", "Unlimited"] },
{ feature: "Comments on previews", values: [true, true, true] },
{
feature: "Roles and permissions",
values: [false, true, "Custom roles"],
},
{ feature: "Shared environment variables", values: [false, true, true] },
{ feature: "Deployment approvals", values: [false, false, true] },
],
},
{
group: "Security",
rows: [
{ feature: "Two-factor authentication", values: [true, true, true] },
{
feature: "Audit log",
values: [false, "90 days", "1 year, with export"],
},
{ feature: "SAML single sign-on", values: [false, false, true] },
{ feature: "SCIM provisioning", values: [false, false, true] },
{ feature: "Data residency", values: [false, false, "EU or US"] },
{ feature: "Uptime SLA", values: [false, false, "99.99%"] },
],
},
{
group: "Support",
rows: [
{ feature: "Community forum", values: [true, true, true] },
{ feature: "Email support", values: [false, true, true] },
{
feature: "First response",
values: [false, "1 business day", "1 hour"],
},
{ feature: "Dedicated support engineer", values: [false, false, true] },
{ feature: "Migration help", values: [false, false, true] },
],
},
]
const questions = [
{
value: "change",
question: "Can I change plans later?",
answer:
"Yes, at any time from Billing. Upgrades apply immediately and you pay the prorated difference for the rest of the period. Downgrades take effect when the period ends.",
},
{
value: "users",
question: "What counts as a user?",
answer:
"Anyone in your team who can deploy, change project settings or manage environment variables. People who only view and comment on preview deployments are free.",
},
{
value: "usage",
question: "What happens if we use more build minutes?",
answer:
"Builds keep running. Extra minutes cost €10 per 1,000 on Pro, team owners get an email at 80% and 100% of the allowance, and you can set a spending limit under Billing.",
},
{
value: "vat",
question: "Do invoices include VAT?",
answer:
"Prices exclude VAT. Add your company details and VAT ID under Billing and every invoice will include them; EU businesses with a valid VAT ID are invoiced under the reverse charge.",
},
{
value: "cancel",
question: "How do I cancel?",
answer:
"Downgrade to Hobby under Billing. Pro features stay on until the end of the period you paid for, and projects above the Hobby limits are paused, not deleted.",
},
{
value: "education",
question: "Do you offer education or open-source discounts?",
answer:
"Students and teachers get Pro free for a year with a school email address. Open-source projects with an OSI-approved license can apply for free Pro seats for their maintainers.",
},
]
const euro = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
})
// Counts stay numbers so each language groups digits its own way.
const count = new Intl.NumberFormat("en-US")
// Yearly billing charges ten months for twelve, the "2 months free".
function pricing(plan: Plan, period: Period) {
if (plan.monthly === null) {
return {
amount: "Custom",
unit: null,
terms: "Volume pricing on an annual contract",
}
}
if (plan.monthly === 0) {
return {
amount: euro.format(0),
unit: null,
terms: "No credit card required",
}
}
const yearly = plan.monthly * 10
return period === "monthly"
? {
amount: euro.format(plan.monthly),
unit: "per user / month",
terms: "Billed monthly",
}
: {
amount: euro.format(yearly / 12),
unit: "per user / month",
terms: `${euro.format(yearly)} per user, billed yearly`,
}
}
function PlanCard({ plan, period }: { plan: Plan; period: Period }) {
const { amount, unit, terms } = pricing(plan, period)
const recommended = plan.id === "pro"
return (
// Mid-width containers put the features beside the price. In three
// columns the cards share four rows (subgrid), so prices, buttons and
// feature lists line up whatever the copy length.
<Card
className={cn(
"@2xl:grid @2xl:grid-cols-2",
"@4xl:row-span-4 @4xl:grid-cols-1 @4xl:grid-rows-subgrid",
recommended && "border-foreground"
)}
>
{/* In three columns `contents` hands these rows to the subgrid. */}
<div className="flex flex-col gap-(--card-gap) @4xl:contents">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<h3>{plan.name}</h3>
{recommended && <Badge variant="outline">Recommended</Badge>}
</CardTitle>
<CardDescription className="text-pretty">
{plan.description}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-1">
<p className="flex flex-wrap items-baseline gap-x-1.5">
<span className="text-display-sm numeric">{amount}</span>
{unit && (
<span className="text-sm text-muted-foreground">{unit}</span>
)}
</p>
<p className="text-xs text-muted-foreground numeric">{terms}</p>
</CardContent>
<CardContent className="mt-auto">
<Button
variant={recommended ? "default" : "outline"}
className="w-full"
>
{plan.cta}
</Button>
</CardContent>
</div>
<CardContent className="flex flex-col gap-3">
<p className="text-sm font-medium">{plan.includes}</p>
<ul className="flex flex-col gap-2.5">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-2.5 text-sm">
<CheckIcon
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
{feature}
</li>
))}
</ul>
</CardContent>
</Card>
)
}
function ComparisonValue({ value }: { value: Value }) {
if (value === true) {
return (
<>
<CheckIcon className="size-4" aria-hidden="true" />
<span className="sr-only">Included</span>
</>
)
}
if (value === false) {
return (
<>
<MinusIcon
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
<span className="sr-only">Not included</span>
</>
)
}
return typeof value === "number" ? count.format(value) : value
}
// Header cells stick below the page header: prfct.dev sets
// --page-header-height to its header's height, and elsewhere it is 0. They
// draw their own bottom line, because a row border would stay behind when
// they stick.
const stickyHead =
"sticky top-(--page-header-height,0px) z-raised h-auto bg-background py-3 align-top after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border"
function ComparisonTable({
period,
shown,
labelledBy,
}: {
period: Period
/** The one plan narrow containers show. */
shown: PlanId
labelledBy: string
}) {
// Edge columns sit flush with the section heading.
const planColumn = (plan: Plan) =>
cn("last:pr-0", plan.id !== shown && "@max-2xl:hidden")
return (
// Table scrolls sideways by default, and a scroll container would trap
// the sticky header. This table never overflows, so it doesn't scroll.
<Table aria-labelledby={labelledBy} containerClassName="overflow-visible">
<TableHeader className="[&_tr]:border-b-0">
<TableRow>
<TableHead scope="col" className={cn(stickyHead, "first:pl-0")}>
<span className="sr-only">Feature</span>
</TableHead>
{plans.map((plan) => {
const { amount, unit } = pricing(plan, period)
return (
<TableHead
key={plan.id}
scope="col"
className={cn(
stickyHead,
"w-2/5 whitespace-normal @2xl:w-1/5",
planColumn(plan)
)}
>
<span className="block text-sm font-medium text-foreground">
{plan.name}
</span>
<span className="block font-normal numeric">
{amount} {unit}
</span>
</TableHead>
)
})}
</TableRow>
</TableHeader>
{comparison.map(({ group, rows }) => (
<TableBody key={group}>
<TableRow className="border-b-0 hover:bg-transparent">
<TableHead
scope="rowgroup"
className="h-auto pt-8 pb-2 text-sm text-foreground first:pl-0"
>
{group}
</TableHead>
{plans.map((plan) => (
<TableCell
key={plan.id}
className={cn("h-auto", planColumn(plan))}
/>
))}
</TableRow>
{rows.map(({ feature, values }) => (
<TableRow key={feature} className="hover:bg-transparent">
<TableHead
scope="row"
className="h-auto py-3 text-sm font-normal whitespace-normal text-foreground first:pl-0"
>
{feature}
</TableHead>
{plans.map((plan, index) => (
<TableCell
key={plan.id}
className={cn(
"h-auto py-3 whitespace-normal",
planColumn(plan)
)}
>
<ComparisonValue value={values[index]} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
))}
</Table>
)
}
export default function PricingExample() {
const [period, setPeriod] = React.useState<Period>("monthly")
// Narrow containers compare one plan at a time instead of scrolling sideways.
const [shown, setShown] = React.useState<PlanId>("pro")
const id = React.useId()
return (
<div className="@container">
<div className="overflow-clip rounded-xl border bg-background">
<div className="mx-auto flex max-w-6xl flex-col gap-16 px-5 py-10 @3xl:gap-24 @3xl:px-10 @3xl:py-14">
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-6 @3xl:flex-row @3xl:items-end @3xl:justify-between">
<div className="flex max-w-xl flex-col gap-3">
<h2 className="text-heading-xl text-balance @3xl:text-display-sm">
Plans and pricing
</h2>
<p className="text-body-md text-pretty text-muted-foreground">
Deploy personal projects on Northwind for free, and pay per
member once your team ships to production.
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2">
<ToggleGroup
variant="outline"
spacing={0}
value={[period]}
// Pressing the active period again would leave none chosen.
onValueChange={([next]) => next && setPeriod(next as Period)}
aria-label="Billing period"
>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
<ToggleGroupItem
value="yearly"
aria-describedby={`${id}-saving`}
>
Yearly
</ToggleGroupItem>
</ToggleGroup>
<span
id={`${id}-saving`}
className="text-sm text-muted-foreground"
>
2 months free
</span>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="grid gap-4 @4xl:grid-cols-3">
{plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} period={period} />
))}
</div>
<p className="text-xs text-muted-foreground">
Prices in euros, excluding VAT.
</p>
</div>
</div>
<section
aria-labelledby={`${id}-compare`}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-4">
<h2 id={`${id}-compare`} className="text-heading-lg">
Compare plans
</h2>
<ToggleGroup
variant="outline"
spacing={0}
value={[shown]}
onValueChange={([next]) => next && setShown(next as PlanId)}
aria-label="Plan to compare"
className="w-full @2xl:hidden"
>
{plans.map((plan) => (
<ToggleGroupItem
key={plan.id}
value={plan.id}
className="flex-1"
>
{plan.name}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<ComparisonTable
period={period}
shown={shown}
labelledBy={`${id}-compare`}
/>
</section>
<section
aria-labelledby={`${id}-faq`}
className="grid gap-6 @4xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @4xl:gap-16"
>
<div className="flex flex-col gap-2">
<h2 id={`${id}-faq`} className="text-heading-lg">
Frequently asked questions
</h2>
<p className="text-sm text-pretty text-muted-foreground">
Billing, seats and invoices. Anything else goes to our support
team, who reply within one business day.
</p>
</div>
<Accordion>
{questions.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</section>
</div>
<div className="border-t bg-surface">
<div className="mx-auto flex max-w-6xl flex-col items-start gap-4 px-5 py-8 @3xl:flex-row @3xl:items-center @3xl:justify-between @3xl:px-10">
<p className="text-sm text-pretty">
For custom contracts, security reviews or invoicing by purchase
order, talk to our team.
</p>
<Button variant="outline">Contact sales</Button>
</div>
</div>
</div>
</div>
)
}import type { Metadata } from "next"
import Pricing from "@/components/pricing/pricing"
export const metadata: Metadata = {
title: "Pricing",
description: "Three plans with a monthly and yearly switch, a feature comparison and answers to common questions.",
}
export default function PricingPage() {
return (
<main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
<Pricing />
</main>
)
}