-
Notifications
You must be signed in to change notification settings - Fork 0
feat(marketing): 3-card options comparator replaces table #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ec06a02
b123010
b89e009
b06556f
fffeeaf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,189 +1,169 @@ | ||
| import { cn } from '@workspace/ui/lib/utils' | ||
| import { TooltipProvider, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' | ||
| import { Check, X } from "lucide-react" | ||
|
|
||
| const plans = ['rollYourOwn', 'hostedSaas', 'helpbase'] as const | ||
| import { cn } from "@workspace/ui/lib/utils" | ||
|
|
||
| type Plan = (typeof plans)[number] | ||
|
|
||
| type Cell = boolean | string | ||
|
|
||
| type Feature = { | ||
| type Option = { | ||
| key: "roll-your-own" | "hosted-saas" | "helpbase" | ||
| name: string | ||
| description?: string | ||
| plans: Record<Plan, Cell> | ||
| } | ||
|
|
||
| const planLabels: Record<Plan, string> = { | ||
| rollYourOwn: 'Roll your own', | ||
| hostedSaas: 'Hosted SaaS', | ||
| helpbase: 'Helpbase', | ||
| metric: string | ||
| metricSubtitle: string | ||
| body: string | ||
| signals: Array<{ positive: boolean; text: string }> | ||
| cost: string | ||
| costNote?: string | ||
| emphasized?: boolean | ||
| } | ||
|
|
||
| const features: Feature[] = [ | ||
| { | ||
| name: 'Time to first site', | ||
| description: 'From zero to a live docs site on your domain.', | ||
| plans: { rollYourOwn: '2-3 days', hostedSaas: '1 day', helpbase: '3 min' }, | ||
| }, | ||
| const options: Option[] = [ | ||
| { | ||
| name: 'You own every file', | ||
| description: 'Code lives in your repo, commits land in your git history.', | ||
| plans: { rollYourOwn: true, hostedSaas: false, helpbase: true }, | ||
| key: "roll-your-own", | ||
| name: "Roll your own", | ||
| metric: "2–3 days", | ||
| metricSubtitle: "to first site", | ||
| body: "Build a docs site in Next.js from scratch. Burn the weekend writing the framework, then another maintaining it.", | ||
| signals: [ | ||
| { positive: true, text: "You own every file" }, | ||
| { positive: false, text: "No MCP, no llms.txt" }, | ||
| { positive: false, text: "No hosted option" }, | ||
| ], | ||
| cost: "Your weekend", | ||
| }, | ||
| { | ||
| name: 'MCP server', | ||
| description: 'Model Context Protocol endpoint Claude / Cursor / ChatGPT can query.', | ||
| plans: { rollYourOwn: 'DIY', hostedSaas: 'Limited', helpbase: 'Built in' }, | ||
| key: "hosted-saas", | ||
| name: "Hosted docs SaaS", | ||
| metric: "1 day", | ||
| metricSubtitle: "to first site", | ||
| body: "Pay a hosted vendor to run your docs. Fast to start. Your content lives in their database. Migrating off is a project.", | ||
| signals: [ | ||
| { positive: false, text: "Your docs, their database" }, | ||
| { positive: false, text: "MCP is a Pro-tier add-on" }, | ||
| { positive: false, text: "Vendor-locked hosting" }, | ||
| ], | ||
| cost: "$200–1k", | ||
| costNote: "/ month", | ||
| }, | ||
| { | ||
| name: 'llms.txt + typed output', | ||
| description: 'Discoverability manifest + machine-readable content, by default.', | ||
| plans: { rollYourOwn: false, hostedSaas: false, helpbase: true }, | ||
| }, | ||
| { | ||
| name: 'Deploy anywhere', | ||
| description: 'No platform lock-in. Ship your docs on whatever infra you already use \u2014 Vercel, Fly, self-host, your call.', | ||
| plans: { rollYourOwn: true, hostedSaas: false, helpbase: true }, | ||
| }, | ||
| { | ||
| name: 'Cost', | ||
| description: 'Sticker price for a working help center at seed stage.', | ||
| plans: { rollYourOwn: 'Your weekend', hostedSaas: '$200-1k/mo', helpbase: 'Free or hosted' }, | ||
| }, | ||
| { | ||
| name: 'Maintained without you', | ||
| description: 'Hosted tier handles updates, caching, MCP scaling.', | ||
| plans: { rollYourOwn: false, hostedSaas: true, helpbase: 'Hosted tier' }, | ||
| }, | ||
| { | ||
| name: 'Agents can read it', | ||
| description: 'Your editor autocompletes from your real docs instead of guessing.', | ||
| plans: { rollYourOwn: 'DIY', hostedSaas: 'Limited', helpbase: true }, | ||
| key: "helpbase", | ||
| name: "Helpbase", | ||
| metric: "3 min", | ||
| metricSubtitle: "to first site", | ||
| body: "Open source. One command drops a Next.js help center into your repo. MCP server and llms.txt built in. Host it yourself, or with us.", | ||
| signals: [ | ||
| { positive: true, text: "Own every file" }, | ||
| { positive: true, text: "MCP + llms.txt built in" }, | ||
| { positive: true, text: "Host anywhere, or with us" }, | ||
| ], | ||
| cost: "Free or hosted", | ||
| emphasized: true, | ||
| }, | ||
| ] | ||
|
|
||
| const renderPlanColumn = (plan: Plan) => { | ||
| const isPrimary = plan === 'helpbase' | ||
| const header = ( | ||
| <div className={cn('sticky top-0 flex h-14 flex-col items-center justify-center gap-1.5 px-4 text-center lg:px-6', isPrimary && 'rounded-t-xl')}> | ||
| <span className={cn('text-foreground text-sm font-semibold', isPrimary && 'text-primary')}>{planLabels[plan]}</span> | ||
| </div> | ||
| ) | ||
|
|
||
| return ( | ||
| <div | ||
| data-plan={plan} | ||
| className={cn(isPrimary && 'ring-border bg-card/50 shadow-black/6.5 relative z-10 rounded-xl shadow-xl ring-1')}> | ||
| {header} | ||
|
|
||
| <div> | ||
| {features.map((feature, index) => { | ||
| const value = feature.plans[plan] | ||
| return ( | ||
| <div | ||
| key={index} | ||
| className="odd:bg-card flex h-14 items-center justify-center px-4 text-sm last:h-[calc(3.5rem+1px)] last:border-b group-last:odd:rounded-r-lg"> | ||
| <div className="text-center"> | ||
| {value === true ? ( | ||
| <Indicator checked /> | ||
| ) : value === false ? ( | ||
| <Indicator /> | ||
| ) : ( | ||
| <span className={cn('text-muted-foreground text-xs font-medium', isPrimary && 'text-foreground')}>{value}</span> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| })} | ||
| <div className="h-6"></div> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default function Comparator() { | ||
| return ( | ||
| <section | ||
| aria-labelledby="comparator-heading" | ||
| className="bg-background py-16 md:py-24"> | ||
| <div className="mx-auto max-w-5xl px-6"> | ||
| <div className="grid gap-12 lg:grid-cols-[1fr_2fr]"> | ||
| <div className="max-w-lg max-md:px-6"> | ||
| <div className="text-balance lg:max-w-xs"> | ||
| <h2 | ||
| id="comparator-heading" | ||
| className="text-foreground text-3xl font-semibold md:text-4xl"> | ||
| Two options today. Both are compromises. | ||
| </h2> | ||
| <p className="text-muted-foreground mt-4 text-balance lg:mt-6"> | ||
| Build your own in Next.js and burn a weekend. Pay a hosted docs SaaS and get locked in. Helpbase is the third option: free as open source, paid only when you want us to host it. | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="grid grid-cols-[1fr_1fr_1fr_1.1fr]"> | ||
| <div> | ||
| <div className="z-1 sticky top-0 flex h-14 items-end gap-1.5 px-4 py-2 lg:px-6"> | ||
| <div className="text-muted-foreground text-sm font-medium">Feature</div> | ||
| </div> | ||
|
|
||
| {features.map((feature, index) => ( | ||
| <div | ||
| key={index} | ||
| className="text-muted-foreground md:nth-2:rounded-tl-xl even:bg-card flex h-14 items-center gap-1 rounded-l-lg px-4 last:h-[calc(3.5rem+1px)] md:last:rounded-bl-xl lg:px-6"> | ||
| <div className="text-sm">{feature.name}</div> | ||
| {feature.description && ( | ||
| <TooltipProvider> | ||
| <Tooltip> | ||
| <TooltipTrigger className="flex size-7"> | ||
| <span className="bg-foreground/10 text-foreground/65 m-auto flex size-4 items-center justify-center rounded-full text-sm">?</span> | ||
| </TooltipTrigger> | ||
| <TooltipContent className="max-w-56 text-sm">{feature.description}</TooltipContent> | ||
| </Tooltip> | ||
| </TooltipProvider> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| <div className="mx-auto mb-12 max-w-2xl text-center"> | ||
| <h2 | ||
| id="comparator-heading" | ||
| className="text-foreground text-3xl font-semibold md:text-4xl"> | ||
| Two options today. Both are compromises. | ||
| </h2> | ||
| <p className="text-muted-foreground mx-auto mt-4 max-w-xl text-balance text-lg"> | ||
| Helpbase is the third option. | ||
| </p> | ||
| </div> | ||
|
|
||
| {plans.map((plan) => ( | ||
| <div | ||
| key={plan} | ||
| className="group"> | ||
| {renderPlanColumn(plan)} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| <div className="grid gap-4 md:grid-cols-3"> | ||
| {options.map((option) => ( | ||
| <OptionCard | ||
| key={option.key} | ||
| option={option} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </section> | ||
| ) | ||
| } | ||
|
|
||
| const Indicator = ({ checked = false }: { checked?: boolean }) => { | ||
| function OptionCard({ option }: { option: Option }) { | ||
| return ( | ||
| <span | ||
| <div | ||
| className={cn( | ||
| 'mx-auto flex size-4 items-center justify-center rounded-full bg-rose-500 font-sans text-xs font-semibold text-white', | ||
| checked && 'bg-emerald-600 text-white', | ||
| "ring-border relative flex flex-col rounded-2xl border border-transparent p-6 ring-1", | ||
| option.emphasized | ||
| ? "bg-card shadow-black/6.5 z-10 shadow-xl" | ||
| : "bg-card/40", | ||
| )}> | ||
| {checked ? <CheckIcon /> : '✗'} | ||
| </span> | ||
| ) | ||
| } | ||
| {option.emphasized && ( | ||
| <span className="bg-foreground text-background absolute -top-3 right-6 rounded-full px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-wider"> | ||
| Recommended | ||
| </span> | ||
| )} | ||
|
|
||
| const CheckIcon = () => { | ||
| return ( | ||
| <svg | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| width="16" | ||
| height="16" | ||
| viewBox="0 0 512 512" | ||
| className="size-2.5"> | ||
| <path | ||
| fill="currentColor" | ||
| d="M17.47 250.9C88.82 328.1 158 397.6 224.5 485.5c72.3-143.8 146.3-288.1 268.4-444.37L460 26.06C356.9 135.4 276.8 238.9 207.2 361.9c-48.4-43.6-126.62-105.3-174.38-137z" | ||
| /> | ||
| </svg> | ||
| <div className="text-muted-foreground text-sm font-medium"> | ||
| {option.name} | ||
| </div> | ||
|
|
||
| <div className="mt-2"> | ||
| <div className="text-foreground text-3xl font-semibold tabular-nums"> | ||
| {option.metric} | ||
| </div> | ||
| <div className="text-muted-foreground text-xs"> | ||
| {option.metricSubtitle} | ||
| </div> | ||
| </div> | ||
|
|
||
| <p className="text-muted-foreground mt-4 text-sm leading-relaxed"> | ||
| {option.body} | ||
| </p> | ||
|
|
||
| <ul | ||
| role="list" | ||
| className="mt-5 space-y-2.5 text-sm"> | ||
| {option.signals.map((signal, i) => ( | ||
| <li | ||
| key={i} | ||
| className="flex items-start gap-2"> | ||
| {signal.positive ? ( | ||
| <span className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 mt-px flex size-4 shrink-0 items-center justify-center rounded-full"> | ||
| <Check | ||
| className="size-2.5" | ||
| strokeWidth={3} | ||
| /> | ||
| </span> | ||
| ) : ( | ||
| <span className="bg-foreground/10 text-muted-foreground mt-px flex size-4 shrink-0 items-center justify-center rounded-full"> | ||
| <X | ||
| className="size-2.5" | ||
| strokeWidth={3} | ||
| /> | ||
| </span> | ||
| )} | ||
| <span | ||
| className={cn( | ||
| signal.positive | ||
| ? "text-foreground" | ||
| : "text-muted-foreground", | ||
| )}> | ||
| {signal.text} | ||
|
Comment on lines
+126
to
+151
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Expose the check/X meaning to assistive tech. The positive/tradeoff state is currently visual-only. Add hidden text for the state and mark decorative icons as hidden. ♿ Proposed fix {signal.positive ? (
<span className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 mt-px flex size-4 shrink-0 items-center justify-center rounded-full">
<Check
+ aria-hidden="true"
className="size-2.5"
strokeWidth={3}
/>
</span>
) : (
<span className="bg-foreground/10 text-muted-foreground mt-px flex size-4 shrink-0 items-center justify-center rounded-full">
<X
+ aria-hidden="true"
className="size-2.5"
strokeWidth={3}
/>
</span>
)}
<span
className={cn(
signal.positive
? "text-foreground"
: "text-muted-foreground",
)}>
+ <span className="sr-only">
+ {signal.positive ? "Advantage: " : "Tradeoff: "}
+ </span>
{signal.text}
</span>🤖 Prompt for AI Agents |
||
| </span> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
|
|
||
| <div className="border-border/60 mt-6 flex items-baseline gap-1 border-t pt-4"> | ||
| <span className="text-foreground text-sm font-medium"> | ||
| {option.cost} | ||
| </span> | ||
| {option.costNote && ( | ||
| <span className="text-muted-foreground text-xs"> | ||
| {option.costNote} | ||
| </span> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,5 @@ | ||
| 'use client' | ||
|
|
||
| import { LogoIcon } from "@/components/logo" | ||
| import { Vercel } from '@/components/ui/svgs/vercel' | ||
| import { Supabase } from '@/components/ui/svgs/supabase' | ||
| import { Firebase } from '@/components/ui/svgs/firebase' | ||
|
|
@@ -184,8 +183,10 @@ export const FlowIllustration = () => { | |
| </div> | ||
|
|
||
| <div className="relative z-10 mb-20 mt-24"> | ||
| <div className="dark:bg-illustration/75 dark:ring-border-illustration relative flex size-14 items-center justify-center rounded-full bg-black/75 shadow-xl shadow-black/20 ring-1 ring-black backdrop-blur"> | ||
| <LogoIcon className="size-6" /> | ||
| <div className="dark:bg-illustration/75 dark:ring-border-illustration relative flex size-14 items-center justify-center rounded-full bg-black/85 shadow-xl shadow-black/20 ring-1 ring-black backdrop-blur"> | ||
| <span className="text-background text-lg font-semibold leading-none tracking-tight"> | ||
| h | ||
| </span> | ||
| </div> | ||
|
Comment on lines
+186
to
190
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verify dark-mode contrast of the "h" glyph. The badge background switches to The whole illustration is 🎨 Suggested tweak- <span className="text-background text-lg font-semibold leading-none tracking-tight">
+ <span className="text-background dark:text-foreground text-lg font-semibold leading-none tracking-tight">
h
</span>🤖 Prompt for AI Agents |
||
| </div> | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 384
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 808
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 15361
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 44
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 94
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 157
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 14166
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 44
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 109
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 202
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 44
🏁 Script executed:
# Check one of the CSS files to understand the theme setup cat ./packages/ui/src/styles/globals.cssRepository: Codehagen/Helpbase
Length of output: 12037
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 4902
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 795
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 296
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 180
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 1133
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 316
🏁 Script executed:
# Check the postcss config cat apps/web/postcss.config.mjsRepository: Codehagen/Helpbase
Length of output: 117
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 8015
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 64
🏁 Script executed:
# Check the workspace UI postcss config to see if there's any custom opacity definition cat packages/ui/postcss.config.mjsRepository: Codehagen/Helpbase
Length of output: 202
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 63
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 44
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 44
🏁 Script executed:
Repository: Codehagen/Helpbase
Length of output: 420
Use valid Tailwind opacity syntax for the emphasized shadow.
shadow-black/6.5is not a standard Tailwind opacity value. Tailwind's opacity scale includes 5, 10, 15... 95 (increments of 5). Arbitrary opacity values must use bracket notation.🎨 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents