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,11 @@
1
+ {
2
+ "extends": "astro/tsconfigs/strict",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "paths": {
6
+ "@web/*": ["./src/*"]
7
+ }
8
+ },
9
+ "include": [".astro/types.d.ts", "**/*"],
10
+ "exclude": ["dist"]
11
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "{{PROJECT_NAME}}-web",
4
+ "compatibility_date": "2026-07-22",
5
+ // Static assets, and still no Worker code — but the layout moved. Registering
6
+ // @astrojs/cloudflare in astro.config.mjs (for src/pages/500.astro) makes Astro build
7
+ // to dist/client + dist/server instead of a flat dist/. While every page is
8
+ // prerendered the adapter runs assets-only and leaves dist/server empty, so there is
9
+ // no `main` to point at here. Add one the day a page sets `prerender = false`, when
10
+ // the adapter writes the server entry.
11
+ //
12
+ // THIS FILE IS NOT THE CONFIG THAT DEPLOYS. The build writes
13
+ // .wrangler/deploy/config.json, which redirects wrangler to the adapter's own generated
14
+ // file at dist/client/wrangler.json — wrangler prints "Using redirected Wrangler
15
+ // configuration" when it does. The adapter merges what is below into that file and adds
16
+ // its own keys, so an edit here only reaches production through that merge. Read
17
+ // dist/client/wrangler.json off a real build to see what actually ships, and run
18
+ // `wrangler deploy --dry-run` after changing anything here.
19
+ "assets": {
20
+ "directory": "./dist/client",
21
+ // An address matching no asset gets dist/client/404.html with a real 404 status,
22
+ // instead of Cloudflare's empty miss. Do not change this to
23
+ // "single-page-application" — that is the admin app's setting, and here it would
24
+ // answer every typo with the home page at status 200.
25
+ "not_found_handling": "404-page",
26
+ },
27
+ }
@@ -0,0 +1,9 @@
1
+ // Conventional Commits (`feat:`, `fix(api):`, `chore(deps):` …), enforced by the
2
+ // `commit-msg` hook. Scopes are free-form — add a `scope-enum` rule here if you
3
+ // want to pin them down.
4
+ //
5
+ // Bypass for a genuine emergency: `git commit --no-verify`, or `HUSKY=0 git commit`
6
+ // to skip every hook at once (which is also how CI avoids installing them).
7
+ export default {
8
+ extends: ["@commitlint/config-conventional"],
9
+ };
@@ -0,0 +1,17 @@
1
+ // The pre-commit half of `pnpm lint`, scoped to staged files.
2
+ //
3
+ // It deliberately does NOT run the type-aware pass (`pnpm lint:types`): type-aware
4
+ // linting needs the whole project graph, which defeats the point of scoping to
5
+ // staged files. `pnpm lint` — in CI, or by hand — is what catches those.
6
+ //
7
+ // Keep these globs in step with the `lint` scripts in package.json. Markdown is
8
+ // absent on purpose: `.prettierignore` excludes it because Ultracite's Prettier
9
+ // sets `proseWrap: "never"`.
10
+ export default {
11
+ "*.{js,jsx,mjs,cjs,ts,tsx,astro}": [
12
+ "oxlint -c oxlint.config.mjs --fix --deny-warnings",
13
+ "prettier --write",
14
+ ],
15
+ "*.css": ["stylelint --fix --max-warnings 0", "prettier --write"],
16
+ "*.{json,jsonc,yaml,yml}": ["prettier --write"],
17
+ };
@@ -0,0 +1,155 @@
1
+ import { defineConfig } from "oxlint";
2
+ import astro from "ultracite/oxlint/astro";
3
+ import core from "ultracite/oxlint/core";
4
+ import react from "ultracite/oxlint/react";
5
+
6
+ // Run it as `oxlint -c oxlint.config.mjs` — the flag is not optional. oxlint only
7
+ // auto-discovers `.oxlintrc.json`, and a JSON config cannot `extends` Ultracite's
8
+ // presets: `extends` takes file paths, JSON only, while the presets are `.mjs`
9
+ // modules. The JS config format is experimental and needs Node.js, which this
10
+ // project already requires (>= 24).
11
+ //
12
+ // Add `ultracite/oxlint/tanstack` when an admin SPA lands, and
13
+ // `ultracite/oxlint/vitest` when the first test file does. The full preset list is
14
+ // angular, astro, core, jest, js-plugins, nestjs, next, qwik, react, remix, solid,
15
+ // svelte, tanstack, vitest, vue.
16
+
17
+ // DO NOT replace this with object spread. `{ ...core, ...react }` overwrites
18
+ // `plugins` with react's three entries and silently drops core's eight
19
+ // (eslint, typescript, unicorn, oxc, import, jsdoc, node, promise). The config
20
+ // still loads and still reports *some* violations, so it looks like it works.
21
+ // Arrays are unioned; `env` and `rules` are merged key-by-key, later preset wins.
22
+ function mergePresets(...presets) {
23
+ const merged = {
24
+ env: {},
25
+ ignorePatterns: [],
26
+ overrides: [],
27
+ plugins: [],
28
+ rules: {},
29
+ };
30
+ for (const preset of presets) {
31
+ Object.assign(merged.env, preset.env);
32
+ Object.assign(merged.rules, preset.rules);
33
+ merged.ignorePatterns.push(...(preset.ignorePatterns ?? []));
34
+ merged.overrides.push(...(preset.overrides ?? []));
35
+ merged.plugins.push(...(preset.plugins ?? []));
36
+ }
37
+ merged.ignorePatterns = [...new Set(merged.ignorePatterns)];
38
+ merged.plugins = [...new Set(merged.plugins)];
39
+ return merged;
40
+ }
41
+
42
+ const ultracite = mergePresets(core, astro, react);
43
+
44
+ // Rules turned off for the whole project, each with its reason. Ultracite enables
45
+ // ~470, including whole families oxlint itself files under `style`, `pedantic` and
46
+ // `restriction`. These are the ones that disagree with how the code Saasaloy ships
47
+ // you is written — the base template AND every module `saasaloy add` drops in, which
48
+ // is why the list is wider than the base alone needs. Turning one back on is fine;
49
+ // expect to fix the module files it lands on.
50
+ //
51
+ // This is your config now. Keep the reasons if you edit it — a rule turned off
52
+ // without one comes back as an argument later.
53
+ const suppressed = {
54
+ // --- Declaration shape ---------------------------------------------------
55
+ // Ultracite prefers `const Foo = () => {}`. The vendored shadcn primitives and
56
+ // every block here use `function` declarations, which is also what shadcn's own
57
+ // registry emits — so turning these on means diverging from upstream on the next
58
+ // `shadcn add`. Module files put helpers below their callers for the same reason,
59
+ // which is the only thing `no-use-before-define` is reacting to.
60
+ "func-style": "off",
61
+ "no-use-before-define": "off",
62
+ "react/function-component-definition": "off",
63
+
64
+ // --- Comment placement ---------------------------------------------------
65
+ // Trailing explanatory comments are the house style in everything Saasaloy ships.
66
+ "no-inline-comments": "off",
67
+
68
+ // --- Async shape ---------------------------------------------------------
69
+ // Sequential awaits are deliberate where ordering matters, and `async` is kept on
70
+ // some functions (e.g. an email provider's `send`) to satisfy an interface even
71
+ // when that implementation has nothing to await.
72
+ "no-await-in-loop": "off",
73
+ "require-await": "off",
74
+
75
+ // --- Regular expressions -------------------------------------------------
76
+ // Adding `u` changes escape semantics and named capture groups mean rewriting
77
+ // every index-based match access. Both are mechanical-looking edits with real
78
+ // behaviour risk, and the email module's HTML escaping is full of them.
79
+ "prefer-named-capture-group": "off",
80
+ "require-unicode-regexp": "off",
81
+
82
+ // --- Key ordering and small style ----------------------------------------
83
+ // Props and config objects are grouped by meaning (`id`, then `title`, then the
84
+ // data). Alphabetising them loses that ordering for no benefit. `no-plusplus` and
85
+ // `method-signature-style` are pure preference.
86
+ "no-plusplus": "off",
87
+ "sort-keys": "off",
88
+ "typescript/method-signature-style": "off",
89
+
90
+ // --- Accessibility shape, not accessibility ------------------------------
91
+ // Fires on `<div role="group">` and friends: the roles are correct, and swapping
92
+ // in `<fieldset>`/`<output>` changes the markup's layout defaults. That is a
93
+ // design decision, not a lint fix.
94
+ "jsx-a11y/prefer-tag-over-role": "off",
95
+ };
96
+
97
+ export default defineConfig({
98
+ ...ultracite,
99
+
100
+ rules: {
101
+ ...ultracite.rules,
102
+ ...suppressed,
103
+ // Ultracite ships `no-console: "off"`. Turn it on: a stray `console.log` in a
104
+ // deployed Worker or a client bundle is a leak, not a log line. Add an override
105
+ // below for any file where console output is genuinely the feature.
106
+ "no-console": "error",
107
+ },
108
+
109
+ overrides: [
110
+ ...ultracite.overrides,
111
+
112
+ // Ultracite's core preset sets `env: { browser: true }` and nothing else, so
113
+ // Node globals have to be added where they apply.
114
+ {
115
+ files: ["*.config.{js,mjs,ts}", "apps/*/*.config.{js,mjs,ts}"],
116
+ env: { node: true },
117
+ },
118
+
119
+ // oxlint parses `.astro` natively — no parser wiring, no plugin chain. Both the
120
+ // `---` frontmatter and client `<script>` blocks are linted.
121
+ //
122
+ // KEEP `.astro` OUT OF THE TYPE-AWARE PASS (`pnpm lint:types`). `apps/web/tsconfig.json`
123
+ // includes the build-generated `.astro/types.d.ts`, so type-aware linting here
124
+ // would require `astro sync` before every `pnpm lint`, including on a fresh
125
+ // clone. That is why `lint:types` is scoped to `packages/ui/src` and the plain
126
+ // `lint:code` pass covers everything else. Do not merge the two.
127
+ {
128
+ files: ["**/*.astro"],
129
+ env: { astro: true, browser: true },
130
+ },
131
+
132
+ // React and Astro components are PascalCase by convention; unicorn wants
133
+ // kebab-case for every filename.
134
+ {
135
+ files: ["**/*.astro", "**/*.tsx"],
136
+ rules: { "unicorn/filename-case": "off" },
137
+ },
138
+
139
+ // The design system is vendored from shadcn and re-synced with
140
+ // `pnpm --filter @repo/ui exec shadcn add …`. `Label` is a primitive: the control
141
+ // it labels is supplied by the call site, which this rule cannot see.
142
+ {
143
+ files: ["packages/ui/src/components/**"],
144
+ rules: { "jsx-a11y/label-has-associated-control": "off" },
145
+ },
146
+
147
+ // Console output is the whole implementation of a console-backed provider —
148
+ // `saasaloy add email-console` lands one at `packages/email/src/providers/console.ts`.
149
+ // Everything else that wants to log should go through a real logger.
150
+ {
151
+ files: ["packages/*/src/providers/console.ts", "packages/logger*/**"],
152
+ rules: { "no-console": "off" },
153
+ },
154
+ ],
155
+ });
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "packageManager": "pnpm@11.14.0",
7
+ "engines": {
8
+ "node": ">=24.13.0",
9
+ "pnpm": ">=11"
10
+ },
11
+ "scripts": {
12
+ "prepare": "husky",
13
+ "build": "turbo run build",
14
+ "clean": "turbo run clean && rimraf -g node_modules .turbo \"{apps,packages}/*/node_modules\" \"{apps,packages}/*/.turbo\"",
15
+ "dev": "turbo run dev",
16
+ "lint": "pnpm run lint:types && pnpm run lint:code && pnpm run lint:css && pnpm run format:check",
17
+ "lint:types": "oxlint -c oxlint.config.mjs --type-aware --deny-warnings packages/ui/src",
18
+ "lint:code": "oxlint -c oxlint.config.mjs --deny-warnings .",
19
+ "lint:css": "stylelint \"**/*.css\" --max-warnings 0",
20
+ "lint:fix": "oxlint -c oxlint.config.mjs --type-aware --fix packages/ui/src && oxlint -c oxlint.config.mjs --fix . && stylelint \"**/*.css\" --fix",
21
+ "format": "prettier --write .",
22
+ "format:check": "prettier --check .",
23
+ "typecheck": "turbo run typecheck"
24
+ },
25
+ "devDependencies": {
26
+ "@commitlint/cli": "21.2.2",
27
+ "@commitlint/config-conventional": "21.2.2",
28
+ "husky": "9.1.7",
29
+ "lint-staged": "17.4.1",
30
+ "oxlint": "1.81.0",
31
+ "oxlint-tsgolint": "7.0.2001",
32
+ "prettier": "3.9.6",
33
+ "prettier-plugin-astro": "0.14.1",
34
+ "prettier-plugin-tailwindcss": "0.8.1",
35
+ "rimraf": "6.1.3",
36
+ "stylelint": "17.14.1",
37
+ "stylelint-config-idiomatic-order": "10.0.0",
38
+ "stylelint-config-standard": "40.0.0",
39
+ "stylelint-prettier": "5.0.3",
40
+ "turbo": "2.10.12",
41
+ "typescript": "7.0.2",
42
+ "ultracite": "7.10.7"
43
+ }
44
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "target": "ES2023",
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "verbatimModuleSyntax": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx"
16
+ }
17
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@repo/tsconfig",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "files": [
7
+ "base.json"
8
+ ],
9
+ "exports": {
10
+ "./base.json": "./base.json"
11
+ },
12
+ "scripts": {
13
+ "clean": "rimraf -g dist \"*.tsbuildinfo\""
14
+ },
15
+ "devDependencies": {
16
+ "rimraf": "6.1.3"
17
+ }
18
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "base-nova",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/styles/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true
11
+ },
12
+ "iconLibrary": "lucide",
13
+ "aliases": {
14
+ "components": "@repo/ui/components",
15
+ "ui": "@repo/ui/components",
16
+ "lib": "@repo/ui/lib",
17
+ "utils": "@repo/ui/lib/utils"
18
+ }
19
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@repo/ui",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./globals.css": "./src/styles/globals.css",
9
+ "./lib/*": "./src/lib/*.ts",
10
+ "./content/*": "./src/content/*.ts",
11
+ "./components/*": "./src/components/*.tsx",
12
+ "./blocks/*": "./src/blocks/*.tsx",
13
+ "./containers/*": "./src/containers/*.tsx"
14
+ },
15
+ "scripts": {
16
+ "clean": "rimraf -g dist \"*.tsbuildinfo\"",
17
+ "typecheck": "tsc --noEmit -p tsconfig.json"
18
+ },
19
+ "dependencies": {
20
+ "@base-ui/react": "1.7.0",
21
+ "class-variance-authority": "0.7.1",
22
+ "clsx": "2.1.1",
23
+ "lucide-react": "1.39.0",
24
+ "shadcn": "4.20.1",
25
+ "tailwind-merge": "3.6.0",
26
+ "tailwindcss": "4.3.3",
27
+ "tw-animate-css": "1.4.0"
28
+ },
29
+ "peerDependencies": {
30
+ "react": "19.2.8"
31
+ },
32
+ "devDependencies": {
33
+ "@repo/tsconfig": "workspace:*",
34
+ "@types/react": "19.2.18",
35
+ "react": "19.2.8",
36
+ "rimraf": "6.1.3",
37
+ "typescript": "7.0.2"
38
+ }
39
+ }
@@ -0,0 +1,82 @@
1
+ import { ArrowRightIcon } from "lucide-react";
2
+
3
+ import { buttonVariants } from "@repo/ui/components/button";
4
+ import { landing } from "@repo/ui/content/landing";
5
+ import { interpolate } from "@repo/ui/lib/interpolate";
6
+ import { cn } from "@repo/ui/lib/utils";
7
+
8
+ // Fully static — rendered without a `client:*` directive, so it ships zero JavaScript.
9
+ // It is also the anchor (`#cta`) every other block's call to action points at.
10
+ //
11
+ // Every word comes from ../content/landing.ts, and so do both destinations: these two
12
+ // buttons leave the page, and where they go is a fact about the product rather than about
13
+ // the layout (content shape rule 5c). Edit them there, not here.
14
+
15
+ export interface CtaAction {
16
+ label: string;
17
+ href: string;
18
+ }
19
+
20
+ export interface CtaProps {
21
+ id?: string;
22
+ siteName?: string;
23
+ title?: string;
24
+ description?: string;
25
+ primaryAction?: CtaAction;
26
+ /** Pass `null` to render a single call to action. */
27
+ secondaryAction?: CtaAction | 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. Both labels and
32
+ // both destinations still come from the content module — only the wrapper is stable.
33
+ const DEFAULT_PRIMARY_ACTION: CtaAction = {
34
+ label: landing.cta.primaryActionLabel,
35
+ href: landing.cta.primaryActionHref,
36
+ };
37
+ const DEFAULT_SECONDARY_ACTION: CtaAction = {
38
+ label: landing.cta.secondaryActionLabel,
39
+ href: landing.cta.secondaryActionHref,
40
+ };
41
+
42
+ export function Cta({
43
+ id = "cta",
44
+ siteName = "Acme",
45
+ title = landing.cta.title,
46
+ description = interpolate(landing.cta.description, { siteName }),
47
+ primaryAction = DEFAULT_PRIMARY_ACTION,
48
+ secondaryAction = DEFAULT_SECONDARY_ACTION,
49
+ }: CtaProps) {
50
+ return (
51
+ <section
52
+ id={id}
53
+ className="mx-auto w-full max-w-6xl scroll-mt-20 px-6 py-20"
54
+ >
55
+ <div className="bg-muted ring-foreground/10 flex flex-col items-center gap-6 rounded-2xl px-6 py-16 text-center ring-1">
56
+ <h2 className="max-w-2xl text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
57
+ {title}
58
+ </h2>
59
+ <p className="text-muted-foreground max-w-xl text-base text-pretty">
60
+ {description}
61
+ </p>
62
+ <div className="mt-2 flex flex-col items-center gap-3 sm:flex-row">
63
+ <a
64
+ href={primaryAction.href}
65
+ className={cn(buttonVariants({ size: "lg" }))}
66
+ >
67
+ {primaryAction.label}
68
+ <ArrowRightIcon data-icon="inline-end" />
69
+ </a>
70
+ {secondaryAction && (
71
+ <a
72
+ href={secondaryAction.href}
73
+ className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
74
+ >
75
+ {secondaryAction.label}
76
+ </a>
77
+ )}
78
+ </div>
79
+ </div>
80
+ </section>
81
+ );
82
+ }
@@ -0,0 +1,144 @@
1
+ import { CircleAlertIcon } from "lucide-react";
2
+
3
+ import { Button, buttonVariants } from "@repo/ui/components/button";
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from "@repo/ui/components/card";
11
+ import { errors } from "@repo/ui/content/errors";
12
+ import { cn } from "@repo/ui/lib/utils";
13
+
14
+ // The one error screen every app in this repo renders: the web app's 404 and 500 pages,
15
+ // the admin app's not-found screen, and the admin app's error boundary. No app writes its
16
+ // own error markup — a second copy is a second thing to restyle when the theme moves.
17
+ //
18
+ // Fully static by default. No state, no effects, no `client:*` directive: an Astro page
19
+ // renders `<ErrorState code="404" />` and ships zero JavaScript for it. The only thing
20
+ // that can pull the browser in is a caller passing an action with `onClick` instead of
21
+ // `href`, which is what the admin error boundary does for its retry. An .astro page must
22
+ // never do that — Astro serializes island props, and a function does not survive the
23
+ // crossing.
24
+ //
25
+ // Every word comes from ../content/errors.ts. The defaults are the not-found case, since
26
+ // it is the one screen with no second sensible reading; the render-failure and
27
+ // server-failure callers pass their own strings from the same module. Edit the words
28
+ // there, not here.
29
+
30
+ /** A destination. Renders as an anchor, so it costs no JavaScript. */
31
+ export interface ErrorStateLinkAction {
32
+ label: string;
33
+ href: string;
34
+ }
35
+
36
+ /** An in-place action, such as the router's `reset`. Renders as a button, so it hydrates. */
37
+ export interface ErrorStateButtonAction {
38
+ label: string;
39
+ onClick: () => void;
40
+ }
41
+
42
+ export type ErrorStateAction = ErrorStateLinkAction | ErrorStateButtonAction;
43
+
44
+ export interface ErrorStateProps {
45
+ /**
46
+ * The status label above the title — "404", "500". Required, and deliberately not
47
+ * defaulted: a screen that shows the wrong number is worse than one that shows none,
48
+ * and every caller already knows which case it is rendering.
49
+ *
50
+ * It is a display label, not a claim about the response status. The admin not-found
51
+ * screen shows "404" over a soft 200, by decision.
52
+ */
53
+ code: string;
54
+ title?: string;
55
+ description?: string;
56
+ /** The main way out. Pass `null` to render no action at all. */
57
+ primaryAction?: ErrorStateAction | null;
58
+ /** An optional second way out, shown after the primary one. */
59
+ secondaryAction?: ErrorStateAction | null;
60
+ /** Merged onto the wrapping `<main>`, for a caller that supplies its own page frame. */
61
+ className?: string;
62
+ }
63
+
64
+ // Hoisted out of the parameter list: an object literal written as a default prop is a
65
+ // fresh object on every render, which defeats memoization downstream. The label and the
66
+ // destination still come from the content module — only the wrapper is stable.
67
+ const DEFAULT_PRIMARY_ACTION: ErrorStateLinkAction = {
68
+ label: errors.notFound.homeLabel,
69
+ href: "/",
70
+ };
71
+
72
+ function isLinkAction(
73
+ action: ErrorStateAction
74
+ ): action is ErrorStateLinkAction {
75
+ return "href" in action;
76
+ }
77
+
78
+ export function ErrorState({
79
+ code,
80
+ title = errors.notFound.title,
81
+ description = errors.notFound.description,
82
+ primaryAction = DEFAULT_PRIMARY_ACTION,
83
+ secondaryAction = null,
84
+ className,
85
+ }: ErrorStateProps) {
86
+ const actions = [primaryAction, secondaryAction].filter(
87
+ (action) => action !== null
88
+ );
89
+
90
+ return (
91
+ <main
92
+ className={cn(
93
+ "mx-auto flex min-h-dvh max-w-lg items-center px-6",
94
+ className
95
+ )}
96
+ >
97
+ <Card className="w-full">
98
+ <CardHeader>
99
+ <CircleAlertIcon className="text-muted-foreground size-5" />
100
+ <p className="text-muted-foreground font-mono text-xs tracking-widest">
101
+ {code}
102
+ </p>
103
+ <CardTitle className="text-lg">{title}</CardTitle>
104
+ <CardDescription className="text-pretty">
105
+ {description}
106
+ </CardDescription>
107
+ </CardHeader>
108
+ {actions.length > 0 && (
109
+ <CardContent className="flex flex-col gap-3 sm:flex-row">
110
+ {actions.map((action, index) => {
111
+ const variant = index === 0 ? "default" : "outline";
112
+
113
+ if (isLinkAction(action)) {
114
+ return (
115
+ <a
116
+ key={action.label}
117
+ href={action.href}
118
+ className={cn(buttonVariants({ variant }))}
119
+ >
120
+ {action.label}
121
+ </a>
122
+ );
123
+ }
124
+
125
+ // Bound to a local first: the linter reads the JSX attribute value, and it
126
+ // wants a `handle*` name there rather than a bare property access.
127
+ const handleClick = action.onClick;
128
+
129
+ return (
130
+ <Button
131
+ key={action.label}
132
+ onClick={handleClick}
133
+ variant={variant}
134
+ >
135
+ {action.label}
136
+ </Button>
137
+ );
138
+ })}
139
+ </CardContent>
140
+ )}
141
+ </Card>
142
+ </main>
143
+ );
144
+ }
@@ -0,0 +1,64 @@
1
+ import {
2
+ Accordion,
3
+ AccordionContent,
4
+ AccordionItem,
5
+ AccordionTrigger,
6
+ } from "@repo/ui/components/accordion";
7
+ import { landing } from "@repo/ui/content/landing";
8
+
9
+ // Accordion is a compound primitive: trigger and panel talk to a context the root
10
+ // provides. Astro gives every `client:*` component its own React root, so splitting the
11
+ // pieces across an .astro file would break that context at runtime — the whole block is
12
+ // one island, hydrated with `client:visible` from index.astro.
13
+ //
14
+ // Every word comes from ../content/landing.ts. Edit the copy there, not here.
15
+
16
+ export interface FaqItem {
17
+ /** Stable key, never the array position (see the content file). */
18
+ id: string;
19
+ question: string;
20
+ answer: string;
21
+ }
22
+
23
+ export interface FaqProps {
24
+ id?: string;
25
+ title?: string;
26
+ description?: string;
27
+ items?: FaqItem[];
28
+ }
29
+
30
+ export function Faq({
31
+ id = "faq",
32
+ title = landing.faq.title,
33
+ description = landing.faq.description,
34
+ items = landing.faq.items,
35
+ }: FaqProps) {
36
+ return (
37
+ <section
38
+ id={id}
39
+ className="mx-auto w-full max-w-3xl scroll-mt-20 px-6 py-20"
40
+ >
41
+ <div className="text-center">
42
+ <h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
43
+ {title}
44
+ </h2>
45
+ <p className="text-muted-foreground mt-4 text-base text-pretty">
46
+ {description}
47
+ </p>
48
+ </div>
49
+
50
+ <Accordion className="mt-12">
51
+ {items.map((item) => (
52
+ <AccordionItem key={item.id}>
53
+ <AccordionTrigger className="text-base">
54
+ {item.question}
55
+ </AccordionTrigger>
56
+ <AccordionContent className="text-muted-foreground">
57
+ {item.answer}
58
+ </AccordionContent>
59
+ </AccordionItem>
60
+ ))}
61
+ </Accordion>
62
+ </section>
63
+ );
64
+ }