saasaloy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/index.js +8455 -0
  2. package/dist/index.js.map +1 -0
  3. package/package.json +69 -0
  4. package/schemas/manifest.schema.json +107 -0
  5. package/schemas/registry-item.schema.json +336 -0
  6. package/schemas/saasaloy-lock.schema.json +86 -0
  7. package/schemas/saasaloy.schema.json +42 -0
  8. package/templates/base/AGENTS.md +450 -0
  9. package/templates/base/CLAUDE.md +1 -0
  10. package/templates/base/DESIGN.md +209 -0
  11. package/templates/base/README.md +71 -0
  12. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +198 -0
  13. package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +395 -0
  14. package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +273 -0
  15. package/templates/base/_gitignore +32 -0
  16. package/templates/base/_husky/commit-msg +1 -0
  17. package/templates/base/_husky/pre-commit +1 -0
  18. package/templates/base/_prettierignore +31 -0
  19. package/templates/base/_saasaloy-base.json +9 -0
  20. package/templates/base/apps/web/astro.config.mjs +61 -0
  21. package/templates/base/apps/web/package.json +30 -0
  22. package/templates/base/apps/web/public/favicon.svg +4 -0
  23. package/templates/base/apps/web/src/layouts/Layout.astro +52 -0
  24. package/templates/base/apps/web/src/pages/404.astro +24 -0
  25. package/templates/base/apps/web/src/pages/500.astro +33 -0
  26. package/templates/base/apps/web/src/pages/index.astro +61 -0
  27. package/templates/base/apps/web/src/pages/privacy.astro +15 -0
  28. package/templates/base/apps/web/src/pages/terms.astro +14 -0
  29. package/templates/base/apps/web/tsconfig.json +11 -0
  30. package/templates/base/apps/web/wrangler.jsonc +27 -0
  31. package/templates/base/commitlint.config.js +9 -0
  32. package/templates/base/lint-staged.config.js +17 -0
  33. package/templates/base/oxlint.config.mjs +155 -0
  34. package/templates/base/package.json +44 -0
  35. package/templates/base/packages/tsconfig/base.json +17 -0
  36. package/templates/base/packages/tsconfig/package.json +18 -0
  37. package/templates/base/packages/ui/components.json +19 -0
  38. package/templates/base/packages/ui/package.json +39 -0
  39. package/templates/base/packages/ui/src/blocks/cta.tsx +82 -0
  40. package/templates/base/packages/ui/src/blocks/error-state.tsx +144 -0
  41. package/templates/base/packages/ui/src/blocks/faq.tsx +64 -0
  42. package/templates/base/packages/ui/src/blocks/feature-grid.tsx +185 -0
  43. package/templates/base/packages/ui/src/blocks/footer.tsx +99 -0
  44. package/templates/base/packages/ui/src/blocks/hero.tsx +84 -0
  45. package/templates/base/packages/ui/src/blocks/navbar.tsx +159 -0
  46. package/templates/base/packages/ui/src/blocks/pricing-table.tsx +175 -0
  47. package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +51 -0
  48. package/templates/base/packages/ui/src/components/accordion.tsx +78 -0
  49. package/templates/base/packages/ui/src/components/badge.tsx +53 -0
  50. package/templates/base/packages/ui/src/components/button.tsx +59 -0
  51. package/templates/base/packages/ui/src/components/card.tsx +103 -0
  52. package/templates/base/packages/ui/src/components/input.tsx +20 -0
  53. package/templates/base/packages/ui/src/components/label.tsx +18 -0
  54. package/templates/base/packages/ui/src/components/separator.tsx +23 -0
  55. package/templates/base/packages/ui/src/containers/README.md +11 -0
  56. package/templates/base/packages/ui/src/content/errors.ts +58 -0
  57. package/templates/base/packages/ui/src/content/landing.ts +304 -0
  58. package/templates/base/packages/ui/src/index.ts +8 -0
  59. package/templates/base/packages/ui/src/lib/interpolate.ts +31 -0
  60. package/templates/base/packages/ui/src/lib/sentinel.ts +11 -0
  61. package/templates/base/packages/ui/src/lib/theme.ts +167 -0
  62. package/templates/base/packages/ui/src/lib/utils.ts +11 -0
  63. package/templates/base/packages/ui/src/styles/globals.css +164 -0
  64. package/templates/base/packages/ui/tsconfig.json +7 -0
  65. package/templates/base/pnpm-workspace.yaml +23 -0
  66. package/templates/base/prettier.config.js +10 -0
  67. package/templates/base/saasaloy.json +8 -0
  68. package/templates/base/stylelint.config.js +46 -0
  69. package/templates/base/turbo.json +20 -0
@@ -0,0 +1,185 @@
1
+ import type { ComponentType } from "react";
2
+ import {
3
+ ActivityIcon,
4
+ AwardIcon,
5
+ BellIcon,
6
+ BookOpenIcon,
7
+ BoxIcon,
8
+ CalendarIcon,
9
+ ClockIcon,
10
+ CloudIcon,
11
+ CodeIcon,
12
+ CreditCardIcon,
13
+ DatabaseIcon,
14
+ FileTextIcon,
15
+ GaugeIcon,
16
+ GlobeIcon,
17
+ GraduationCapIcon,
18
+ HandshakeIcon,
19
+ HeadphonesIcon,
20
+ KeyIcon,
21
+ LayersIcon,
22
+ LayoutGridIcon,
23
+ ListChecksIcon,
24
+ LockIcon,
25
+ MapPinIcon,
26
+ MessageCircleIcon,
27
+ PenLineIcon,
28
+ PlugIcon,
29
+ ReceiptIcon,
30
+ RefreshCwIcon,
31
+ RocketIcon,
32
+ SearchIcon,
33
+ ServerIcon,
34
+ ShieldCheckIcon,
35
+ SmartphoneIcon,
36
+ SparklesIcon,
37
+ TargetIcon,
38
+ TerminalIcon,
39
+ TrendingUpIcon,
40
+ UsersIcon,
41
+ WalletIcon,
42
+ WorkflowIcon,
43
+ ZapIcon,
44
+ } from "lucide-react";
45
+
46
+ import {
47
+ Card,
48
+ CardDescription,
49
+ CardHeader,
50
+ CardTitle,
51
+ } from "@repo/ui/components/card";
52
+ import { landing } from "@repo/ui/content/landing";
53
+
54
+ // Fully static — rendered without a `client:*` directive, so it ships zero JavaScript.
55
+ // That is also why the registry below can be long without costing anything: it is read at
56
+ // build time and nothing in it reaches the browser.
57
+ //
58
+ // The words come from ../content/landing.ts, and so does each feature's icon — but as a
59
+ // *name*, not a component. Astro serializes island props, so a component cannot cross the
60
+ // .astro boundary; the registry that turns `"zap"` into `ZapIcon` therefore lives here,
61
+ // beside the render. What that buys is the thing a copy rewrite actually needs: change
62
+ // what a feature is about, change its glyph, one file, no block edit.
63
+
64
+ export interface Feature {
65
+ /** Stable key — the translation key, never the array position (see the content file). */
66
+ id: string;
67
+ icon: ComponentType<{ className?: string }>;
68
+ title: string;
69
+ description: string;
70
+ }
71
+
72
+ // The names `landing.features.items[].icon` may use. Add a lucide import and a line here to
73
+ // widen it — these are ordinary source files you own, and the set below is a starting
74
+ // vocabulary, not a limit. Keep the keys kebab-case, matching lucide's own icon names, so
75
+ // picking one is a lookup rather than a guess.
76
+ const FEATURE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
77
+ activity: ActivityIcon,
78
+ award: AwardIcon,
79
+ bell: BellIcon,
80
+ "book-open": BookOpenIcon,
81
+ box: BoxIcon,
82
+ calendar: CalendarIcon,
83
+ clock: ClockIcon,
84
+ cloud: CloudIcon,
85
+ code: CodeIcon,
86
+ "credit-card": CreditCardIcon,
87
+ database: DatabaseIcon,
88
+ "file-text": FileTextIcon,
89
+ gauge: GaugeIcon,
90
+ globe: GlobeIcon,
91
+ "graduation-cap": GraduationCapIcon,
92
+ handshake: HandshakeIcon,
93
+ headphones: HeadphonesIcon,
94
+ key: KeyIcon,
95
+ layers: LayersIcon,
96
+ "layout-grid": LayoutGridIcon,
97
+ "list-checks": ListChecksIcon,
98
+ lock: LockIcon,
99
+ "map-pin": MapPinIcon,
100
+ "message-circle": MessageCircleIcon,
101
+ "pen-line": PenLineIcon,
102
+ plug: PlugIcon,
103
+ receipt: ReceiptIcon,
104
+ "refresh-cw": RefreshCwIcon,
105
+ rocket: RocketIcon,
106
+ search: SearchIcon,
107
+ server: ServerIcon,
108
+ "shield-check": ShieldCheckIcon,
109
+ smartphone: SmartphoneIcon,
110
+ sparkles: SparklesIcon,
111
+ target: TargetIcon,
112
+ terminal: TerminalIcon,
113
+ "trending-up": TrendingUpIcon,
114
+ users: UsersIcon,
115
+ wallet: WalletIcon,
116
+ workflow: WorkflowIcon,
117
+ zap: ZapIcon,
118
+ };
119
+
120
+ // `Object.hasOwn` first, not a bare index: `FEATURE_ICONS[name]` walks the prototype chain,
121
+ // so a name of `constructor` or `toString` resolves to an inherited function — not nullish,
122
+ // so a `?? SparklesIcon` fallback never fires and React is handed something that isn't a
123
+ // component. Icon names are author-controlled and the saasaloy-landing-copy skill writes
124
+ // them, so this is reachable. Same reasoning as interpolate() in ../lib/interpolate.ts.
125
+ function iconFor(name: string): ComponentType<{ className?: string }> {
126
+ return (
127
+ (Object.hasOwn(FEATURE_ICONS, name) ? FEATURE_ICONS[name] : undefined) ??
128
+ SparklesIcon
129
+ );
130
+ }
131
+
132
+ const defaultFeatures: Feature[] = landing.features.items.map((item) => ({
133
+ ...item,
134
+ icon: iconFor(item.icon),
135
+ }));
136
+
137
+ export interface FeatureGridProps {
138
+ id?: string;
139
+ title?: string;
140
+ description?: string;
141
+ features?: Feature[];
142
+ }
143
+
144
+ export function FeatureGrid({
145
+ id = "features",
146
+ title = landing.features.title,
147
+ description = landing.features.description,
148
+ features = defaultFeatures,
149
+ }: FeatureGridProps) {
150
+ return (
151
+ <section
152
+ id={id}
153
+ className="mx-auto w-full max-w-6xl scroll-mt-20 px-6 py-20"
154
+ >
155
+ <div className="mx-auto max-w-2xl text-center">
156
+ <h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
157
+ {title}
158
+ </h2>
159
+ <p className="text-muted-foreground mt-4 text-base text-pretty">
160
+ {description}
161
+ </p>
162
+ </div>
163
+
164
+ <div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
165
+ {features.map((feature) => {
166
+ const Icon = feature.icon;
167
+ return (
168
+ <Card key={feature.id} className="h-full">
169
+ <CardHeader>
170
+ <span
171
+ aria-hidden="true"
172
+ className="bg-muted text-foreground mb-3 flex size-9 items-center justify-center rounded-lg"
173
+ >
174
+ <Icon className="size-4" />
175
+ </span>
176
+ <CardTitle>{feature.title}</CardTitle>
177
+ <CardDescription>{feature.description}</CardDescription>
178
+ </CardHeader>
179
+ </Card>
180
+ );
181
+ })}
182
+ </div>
183
+ </section>
184
+ );
185
+ }
@@ -0,0 +1,99 @@
1
+ import { Separator } from "@repo/ui/components/separator";
2
+ import { landing, ui } from "@repo/ui/content/landing";
3
+ import { interpolate } from "@repo/ui/lib/interpolate";
4
+
5
+ // Fully static — rendered without a `client:*` directive, so it ships zero JavaScript.
6
+ // It carries the legal links the base template ships pages for (/terms, /privacy);
7
+ // the navbar keeps only same-page anchors.
8
+ //
9
+ // The words come from ../content/landing.ts; the hrefs stay here, because a URL is
10
+ // structure rather than copy. Blanking a label in the content file drops that link, and a
11
+ // group whose links are all blank disappears — that is how a removed section loses its
12
+ // footer entry without editing this file.
13
+
14
+ export interface FooterLink {
15
+ label: string;
16
+ href: string;
17
+ }
18
+
19
+ export interface FooterGroup {
20
+ heading: string;
21
+ links: FooterLink[];
22
+ }
23
+
24
+ const defaultGroups: FooterGroup[] = [
25
+ {
26
+ heading: landing.footer.groupProduct,
27
+ links: [
28
+ { label: landing.footer.linkFeatures, href: "#features" },
29
+ { label: landing.footer.linkPricing, href: "#pricing" },
30
+ { label: landing.footer.linkFaq, href: "#faq" },
31
+ ],
32
+ },
33
+ {
34
+ heading: landing.footer.groupLegal,
35
+ links: [
36
+ { label: landing.footer.linkTerms, href: "/terms" },
37
+ { label: landing.footer.linkPrivacy, href: "/privacy" },
38
+ ],
39
+ },
40
+ ]
41
+ .map((group) => ({
42
+ ...group,
43
+ links: group.links.filter((link) => link.label !== ""),
44
+ }))
45
+ .filter((group) => group.heading !== "" && group.links.length > 0);
46
+
47
+ export interface FooterProps {
48
+ siteName?: string;
49
+ tagline?: string;
50
+ groups?: FooterGroup[];
51
+ /** Defaults to the current year at render time. */
52
+ year?: number;
53
+ }
54
+
55
+ export function Footer({
56
+ siteName = "Acme",
57
+ tagline = landing.footer.tagline,
58
+ groups = defaultGroups,
59
+ year = new Date().getFullYear(),
60
+ }: FooterProps) {
61
+ return (
62
+ <footer className="border-border/60 mt-8 border-t">
63
+ <div className="mx-auto w-full max-w-6xl px-6 py-14">
64
+ <div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
65
+ <div className="lg:col-span-2">
66
+ <p className="text-sm font-semibold tracking-tight">{siteName}</p>
67
+ <p className="text-muted-foreground mt-2 max-w-xs text-sm text-pretty">
68
+ {tagline}
69
+ </p>
70
+ </div>
71
+
72
+ {groups.map((group) => (
73
+ <nav key={group.heading} aria-label={group.heading}>
74
+ <p className="text-sm font-medium">{group.heading}</p>
75
+ <ul className="mt-3 flex flex-col gap-2">
76
+ {group.links.map((link) => (
77
+ <li key={link.href}>
78
+ <a
79
+ href={link.href}
80
+ className="text-muted-foreground hover:text-foreground text-sm transition-colors"
81
+ >
82
+ {link.label}
83
+ </a>
84
+ </li>
85
+ ))}
86
+ </ul>
87
+ </nav>
88
+ ))}
89
+ </div>
90
+
91
+ <Separator className="my-10" />
92
+
93
+ <p className="text-muted-foreground text-sm">
94
+ {interpolate(ui.footer.copyright, { year, siteName })}
95
+ </p>
96
+ </div>
97
+ </footer>
98
+ );
99
+ }
@@ -0,0 +1,84 @@
1
+ import { ArrowRightIcon } from "lucide-react";
2
+
3
+ import { Badge } from "@repo/ui/components/badge";
4
+ import { buttonVariants } from "@repo/ui/components/button";
5
+ import { landing } from "@repo/ui/content/landing";
6
+ import { interpolate } from "@repo/ui/lib/interpolate";
7
+ import { cn } from "@repo/ui/lib/utils";
8
+
9
+ // Fully static — no state, no effects, so index.astro renders it with no `client:*`
10
+ // directive and it ships zero JavaScript. Keep it that way: adding a hook here forces
11
+ // the whole above-the-fold block into a hydrated island.
12
+ //
13
+ // Every word comes from ../content/landing.ts. Edit the copy there, not here.
14
+
15
+ export interface HeroAction {
16
+ label: string;
17
+ href: string;
18
+ }
19
+
20
+ export interface HeroProps {
21
+ siteName?: string;
22
+ eyebrow?: string;
23
+ title?: string;
24
+ description?: string;
25
+ primaryAction?: HeroAction;
26
+ /** Pass `null` to render a single call to action. */
27
+ secondaryAction?: HeroAction | null;
28
+ }
29
+
30
+ // Hoisted out of the parameter list: an object literal written as a default prop is a
31
+ // fresh object on every render, which defeats memoization downstream. The labels still
32
+ // come from the content module — only the wrapper object is stable.
33
+ const DEFAULT_PRIMARY_ACTION: HeroAction = {
34
+ label: landing.hero.primaryActionLabel,
35
+ href: "#cta",
36
+ };
37
+ const DEFAULT_SECONDARY_ACTION: HeroAction = {
38
+ label: landing.hero.secondaryActionLabel,
39
+ href: "#pricing",
40
+ };
41
+
42
+ export function Hero({
43
+ siteName = "Acme",
44
+ eyebrow = landing.hero.eyebrow,
45
+ title = landing.hero.title,
46
+ description = interpolate(landing.hero.description, { siteName }),
47
+ primaryAction = DEFAULT_PRIMARY_ACTION,
48
+ secondaryAction = DEFAULT_SECONDARY_ACTION,
49
+ }: HeroProps) {
50
+ return (
51
+ <section className="mx-auto w-full max-w-6xl px-6 py-24 sm:py-32">
52
+ <div className="mx-auto flex max-w-3xl flex-col items-center text-center">
53
+ {eyebrow && (
54
+ <Badge variant="secondary" className="mb-6">
55
+ {eyebrow}
56
+ </Badge>
57
+ )}
58
+ <h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl md:text-6xl">
59
+ {title}
60
+ </h1>
61
+ <p className="text-muted-foreground mt-6 max-w-2xl text-base text-pretty sm:text-lg">
62
+ {description}
63
+ </p>
64
+ <div className="mt-10 flex flex-col items-center gap-3 sm:flex-row">
65
+ <a
66
+ href={primaryAction.href}
67
+ className={cn(buttonVariants({ size: "lg" }))}
68
+ >
69
+ {primaryAction.label}
70
+ <ArrowRightIcon data-icon="inline-end" />
71
+ </a>
72
+ {secondaryAction && (
73
+ <a
74
+ href={secondaryAction.href}
75
+ className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
76
+ >
77
+ {secondaryAction.label}
78
+ </a>
79
+ )}
80
+ </div>
81
+ </div>
82
+ </section>
83
+ );
84
+ }
@@ -0,0 +1,159 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { MenuIcon, XIcon } from "lucide-react";
3
+
4
+ import { Button, buttonVariants } from "@repo/ui/components/button";
5
+ import { landing, ui } from "@repo/ui/content/landing";
6
+ import { cn } from "@repo/ui/lib/utils";
7
+
8
+ // Marketing blocks are self-contained on purpose: one file, one exported component, no
9
+ // shared block barrel — but the words all live in ../content/landing.ts. Astro renders
10
+ // each `client:*` component as its own React root, so a compound primitive split across
11
+ // the .astro boundary would throw "must be used within" — composing the whole block here
12
+ // is what avoids that.
13
+ //
14
+ // This is the only block above the fold that needs JS (the mobile menu), which is why
15
+ // index.astro hydrates it with `client:idle` while the static blocks ship none.
16
+
17
+ export interface NavbarLink {
18
+ label: string;
19
+ href: string;
20
+ }
21
+
22
+ // Same-page anchors, matching the ids the other blocks render. The anchor is structure and
23
+ // stays here; the label comes from content, and blanking it there drops the link — which
24
+ // is how a removed section loses its nav entry without editing this file.
25
+ //
26
+ // The CTA button is the exception, and its href comes from content too: it is the one
27
+ // control here that may point off the page, at a signup or a waitlist (content shape
28
+ // rule 5c).
29
+ const defaultLinks: NavbarLink[] = [
30
+ { label: landing.navbar.linkFeatures, href: "#features" },
31
+ { label: landing.navbar.linkPricing, href: "#pricing" },
32
+ { label: landing.navbar.linkFaq, href: "#faq" },
33
+ ].filter((link) => link.label !== "");
34
+
35
+ export interface NavbarProps {
36
+ siteName?: string;
37
+ links?: NavbarLink[];
38
+ ctaLabel?: string;
39
+ ctaHref?: string;
40
+ }
41
+
42
+ export function Navbar({
43
+ siteName = "Acme",
44
+ links = defaultLinks,
45
+ ctaLabel = landing.navbar.ctaLabel,
46
+ ctaHref = landing.navbar.ctaHref,
47
+ }: NavbarProps) {
48
+ const [open, setOpen] = useState(false);
49
+ const toggleRef = useRef<HTMLButtonElement>(null);
50
+
51
+ // Escape is the expected way out of an open menu, and focus has to go back to the
52
+ // toggle — the panel unmounts under the user's cursor otherwise, dropping focus to
53
+ // the document and sending the next Tab to the top of the page.
54
+ // The listener stays attached while the menu is closed and exits on the first line
55
+ // instead — one early `return` plus a cleanup `return` in the same effect is two
56
+ // return shapes, which `consistent-return` rejects.
57
+ useEffect(() => {
58
+ function handleKeyDown(event: KeyboardEvent) {
59
+ if (!open || event.key !== "Escape") {
60
+ return;
61
+ }
62
+ setOpen(false);
63
+ toggleRef.current?.focus();
64
+ }
65
+
66
+ document.addEventListener("keydown", handleKeyDown);
67
+ return () => {
68
+ document.removeEventListener("keydown", handleKeyDown);
69
+ };
70
+ }, [open]);
71
+
72
+ return (
73
+ <header className="border-border/60 bg-background/80 sticky top-0 z-50 w-full border-b backdrop-blur">
74
+ <div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-6 px-6">
75
+ <a href="/" className="text-sm font-semibold tracking-tight">
76
+ {siteName}
77
+ </a>
78
+
79
+ <nav
80
+ aria-label={ui.navbar.mainNavLabel}
81
+ className="hidden items-center gap-6 md:flex"
82
+ >
83
+ {links.map((link) => (
84
+ <a
85
+ key={link.href}
86
+ href={link.href}
87
+ className="text-muted-foreground hover:text-foreground text-sm transition-colors"
88
+ >
89
+ {link.label}
90
+ </a>
91
+ ))}
92
+ </nav>
93
+
94
+ <div className="flex items-center gap-2">
95
+ <a
96
+ href={ctaHref}
97
+ className={cn(
98
+ buttonVariants({ size: "sm" }),
99
+ "hidden md:inline-flex"
100
+ )}
101
+ >
102
+ {ctaLabel}
103
+ </a>
104
+ {/* The one interactive control in the header. The panel below is rendered
105
+ conditionally, so `aria-controls` is set only while it exists — an idref
106
+ pointing at nothing is invalid ARIA. */}
107
+ <Button
108
+ ref={toggleRef}
109
+ variant="ghost"
110
+ size="icon-sm"
111
+ className="md:hidden"
112
+ aria-controls={open ? "navbar-mobile-menu" : undefined}
113
+ aria-expanded={open}
114
+ aria-label={open ? ui.navbar.closeMenu : ui.navbar.openMenu}
115
+ onClick={() => {
116
+ setOpen((previous) => !previous);
117
+ }}
118
+ >
119
+ {open ? <XIcon /> : <MenuIcon />}
120
+ </Button>
121
+ </div>
122
+ </div>
123
+
124
+ {open && (
125
+ <div
126
+ id="navbar-mobile-menu"
127
+ className="border-border/60 border-t md:hidden"
128
+ >
129
+ <nav
130
+ aria-label={ui.navbar.mobileNavLabel}
131
+ className="mx-auto flex w-full max-w-6xl flex-col gap-1 px-6 py-4"
132
+ >
133
+ {links.map((link) => (
134
+ <a
135
+ key={link.href}
136
+ href={link.href}
137
+ onClick={() => {
138
+ setOpen(false);
139
+ }}
140
+ className="text-muted-foreground hover:bg-muted hover:text-foreground rounded-lg px-2 py-2 text-sm transition-colors"
141
+ >
142
+ {link.label}
143
+ </a>
144
+ ))}
145
+ <a
146
+ href={ctaHref}
147
+ onClick={() => {
148
+ setOpen(false);
149
+ }}
150
+ className={cn(buttonVariants({ size: "sm" }), "mt-2")}
151
+ >
152
+ {ctaLabel}
153
+ </a>
154
+ </nav>
155
+ </div>
156
+ )}
157
+ </header>
158
+ );
159
+ }
@@ -0,0 +1,175 @@
1
+ import { useState } from "react";
2
+ import { CheckIcon } from "lucide-react";
3
+
4
+ import { Badge } from "@repo/ui/components/badge";
5
+ import { Button, buttonVariants } from "@repo/ui/components/button";
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardFooter,
11
+ CardHeader,
12
+ CardTitle,
13
+ } from "@repo/ui/components/card";
14
+ import { landing, ui } from "@repo/ui/content/landing";
15
+ import { interpolate } from "@repo/ui/lib/interpolate";
16
+ import { cn } from "@repo/ui/lib/utils";
17
+
18
+ // The billing-period toggle is the only reason this block is hydrated. index.astro gives
19
+ // it `client:visible`, so the JavaScript is fetched when the section scrolls into view
20
+ // rather than on load. Delete the toggle and the block goes fully static again.
21
+ //
22
+ // This block holds no tier data at all: the whole list lives in ../content/landing.ts
23
+ // (`landing.pricing.tiers`), prices included, so pricing is rewritten in one file and
24
+ // never hunted for in markup.
25
+
26
+ export interface PricingTier {
27
+ /** Stable key, never the array position (see the content file). */
28
+ id: string;
29
+ name: string;
30
+ description: string;
31
+ /** Price per month, in whole currency units. `null` renders "Custom". */
32
+ monthlyPrice: number | null;
33
+ /** Effective per-month price when billed annually. `null` renders "Custom". */
34
+ annualPrice: number | null;
35
+ features: string[];
36
+ ctaLabel: string;
37
+ ctaHref: string;
38
+ /** Visually promotes one tier. Set it on at most one. */
39
+ featured?: boolean;
40
+ }
41
+
42
+ export interface PricingTableProps {
43
+ id?: string;
44
+ title?: string;
45
+ description?: string;
46
+ tiers?: PricingTier[];
47
+ /** Shown beside the annual option; pass an empty string to hide it. */
48
+ annualNote?: string;
49
+ currencySymbol?: string;
50
+ }
51
+
52
+ export function PricingTable({
53
+ id = "pricing",
54
+ title = landing.pricing.title,
55
+ description = landing.pricing.description,
56
+ tiers = landing.pricing.tiers,
57
+ annualNote = landing.pricing.annualNote,
58
+ currencySymbol = landing.pricing.currencySymbol,
59
+ }: PricingTableProps) {
60
+ const [annual, setAnnual] = useState(false);
61
+
62
+ return (
63
+ <section
64
+ id={id}
65
+ className="mx-auto w-full max-w-6xl scroll-mt-20 px-6 py-20"
66
+ >
67
+ <div className="mx-auto max-w-2xl text-center">
68
+ <h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
69
+ {title}
70
+ </h2>
71
+ <p className="text-muted-foreground mt-4 text-base text-pretty">
72
+ {description}
73
+ </p>
74
+
75
+ <div className="mt-8 flex items-center justify-center gap-3">
76
+ <div
77
+ role="group"
78
+ aria-label={ui.pricing.billingPeriodLabel}
79
+ className="border-border inline-flex items-center gap-1 rounded-xl border p-1"
80
+ >
81
+ <Button
82
+ size="sm"
83
+ variant={annual ? "ghost" : "secondary"}
84
+ aria-pressed={!annual}
85
+ onClick={() => {
86
+ setAnnual(false);
87
+ }}
88
+ >
89
+ {ui.pricing.monthly}
90
+ </Button>
91
+ <Button
92
+ size="sm"
93
+ variant={annual ? "secondary" : "ghost"}
94
+ aria-pressed={annual}
95
+ onClick={() => {
96
+ setAnnual(true);
97
+ }}
98
+ >
99
+ {ui.pricing.annual}
100
+ </Button>
101
+ </div>
102
+ {annualNote && <Badge variant="outline">{annualNote}</Badge>}
103
+ </div>
104
+ </div>
105
+
106
+ <div className="mt-14 grid items-start gap-4 lg:grid-cols-3">
107
+ {tiers.map((tier) => {
108
+ const price = annual ? tier.annualPrice : tier.monthlyPrice;
109
+ const featured = tier.featured ?? false;
110
+ return (
111
+ <Card
112
+ key={tier.id}
113
+ className={cn("h-full", featured && "ring-primary ring-2")}
114
+ >
115
+ <CardHeader>
116
+ <div className="flex items-center justify-between gap-2">
117
+ <CardTitle>{tier.name}</CardTitle>
118
+ {featured && <Badge>{ui.pricing.featuredBadge}</Badge>}
119
+ </div>
120
+ <CardDescription>{tier.description}</CardDescription>
121
+ </CardHeader>
122
+
123
+ <CardContent>
124
+ <p className="flex items-baseline gap-1">
125
+ <span className="text-3xl font-semibold tracking-tight">
126
+ {price === null
127
+ ? ui.pricing.customPrice
128
+ : interpolate(ui.pricing.price, {
129
+ currencySymbol,
130
+ price,
131
+ })}
132
+ </span>
133
+ {price !== null && (
134
+ <span className="text-muted-foreground text-sm">
135
+ {annual ? ui.pricing.perMonthAnnual : ui.pricing.perMonth}
136
+ </span>
137
+ )}
138
+ </p>
139
+
140
+ <ul className="mt-6 flex flex-col gap-2.5">
141
+ {tier.features.map((feature) => (
142
+ <li
143
+ key={feature}
144
+ className="flex items-start gap-2 text-sm"
145
+ >
146
+ <CheckIcon
147
+ aria-hidden="true"
148
+ className="mt-0.5 size-4 shrink-0"
149
+ />
150
+ <span className="text-muted-foreground">{feature}</span>
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ </CardContent>
155
+
156
+ <CardFooter>
157
+ <a
158
+ href={tier.ctaHref}
159
+ className={cn(
160
+ buttonVariants({
161
+ variant: featured ? "default" : "outline",
162
+ }),
163
+ "w-full"
164
+ )}
165
+ >
166
+ {tier.ctaLabel}
167
+ </a>
168
+ </CardFooter>
169
+ </Card>
170
+ );
171
+ })}
172
+ </div>
173
+ </section>
174
+ );
175
+ }