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,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
|
+
}
|