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.
- package/dist/index.js +8455 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/schemas/manifest.schema.json +107 -0
- package/schemas/registry-item.schema.json +336 -0
- package/schemas/saasaloy-lock.schema.json +86 -0
- package/schemas/saasaloy.schema.json +42 -0
- package/templates/base/AGENTS.md +450 -0
- package/templates/base/CLAUDE.md +1 -0
- package/templates/base/DESIGN.md +209 -0
- package/templates/base/README.md +71 -0
- package/templates/base/_agents/skills/saasaloy-design/SKILL.md +198 -0
- package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +395 -0
- package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +273 -0
- package/templates/base/_gitignore +32 -0
- package/templates/base/_husky/commit-msg +1 -0
- package/templates/base/_husky/pre-commit +1 -0
- package/templates/base/_prettierignore +31 -0
- package/templates/base/_saasaloy-base.json +9 -0
- package/templates/base/apps/web/astro.config.mjs +61 -0
- package/templates/base/apps/web/package.json +30 -0
- package/templates/base/apps/web/public/favicon.svg +4 -0
- package/templates/base/apps/web/src/layouts/Layout.astro +52 -0
- package/templates/base/apps/web/src/pages/404.astro +24 -0
- package/templates/base/apps/web/src/pages/500.astro +33 -0
- package/templates/base/apps/web/src/pages/index.astro +61 -0
- package/templates/base/apps/web/src/pages/privacy.astro +15 -0
- package/templates/base/apps/web/src/pages/terms.astro +14 -0
- package/templates/base/apps/web/tsconfig.json +11 -0
- package/templates/base/apps/web/wrangler.jsonc +27 -0
- package/templates/base/commitlint.config.js +9 -0
- package/templates/base/lint-staged.config.js +17 -0
- package/templates/base/oxlint.config.mjs +155 -0
- package/templates/base/package.json +44 -0
- package/templates/base/packages/tsconfig/base.json +17 -0
- package/templates/base/packages/tsconfig/package.json +18 -0
- package/templates/base/packages/ui/components.json +19 -0
- package/templates/base/packages/ui/package.json +39 -0
- package/templates/base/packages/ui/src/blocks/cta.tsx +82 -0
- package/templates/base/packages/ui/src/blocks/error-state.tsx +144 -0
- package/templates/base/packages/ui/src/blocks/faq.tsx +64 -0
- package/templates/base/packages/ui/src/blocks/feature-grid.tsx +185 -0
- package/templates/base/packages/ui/src/blocks/footer.tsx +99 -0
- package/templates/base/packages/ui/src/blocks/hero.tsx +84 -0
- package/templates/base/packages/ui/src/blocks/navbar.tsx +159 -0
- package/templates/base/packages/ui/src/blocks/pricing-table.tsx +175 -0
- package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +51 -0
- package/templates/base/packages/ui/src/components/accordion.tsx +78 -0
- package/templates/base/packages/ui/src/components/badge.tsx +53 -0
- package/templates/base/packages/ui/src/components/button.tsx +59 -0
- package/templates/base/packages/ui/src/components/card.tsx +103 -0
- package/templates/base/packages/ui/src/components/input.tsx +20 -0
- package/templates/base/packages/ui/src/components/label.tsx +18 -0
- package/templates/base/packages/ui/src/components/separator.tsx +23 -0
- package/templates/base/packages/ui/src/containers/README.md +11 -0
- package/templates/base/packages/ui/src/content/errors.ts +58 -0
- package/templates/base/packages/ui/src/content/landing.ts +304 -0
- package/templates/base/packages/ui/src/index.ts +8 -0
- package/templates/base/packages/ui/src/lib/interpolate.ts +31 -0
- package/templates/base/packages/ui/src/lib/sentinel.ts +11 -0
- package/templates/base/packages/ui/src/lib/theme.ts +167 -0
- package/templates/base/packages/ui/src/lib/utils.ts +11 -0
- package/templates/base/packages/ui/src/styles/globals.css +164 -0
- package/templates/base/packages/ui/tsconfig.json +7 -0
- package/templates/base/pnpm-workspace.yaml +23 -0
- package/templates/base/prettier.config.js +10 -0
- package/templates/base/saasaloy.json +8 -0
- package/templates/base/stylelint.config.js +46 -0
- 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
|
+
}
|