fimo 0.11.0-staging.6 → 0.11.0-staging.8
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/assets/skills/fimo-cli/SKILL.md +4 -1
- package/assets/skills/fimo-cli/references/init.md +47 -14
- package/assets/starters/fimo-site/manifest.json +64 -0
- package/assets/starters/fimo-site/template/.fimo/agents/content-guide/GOAL.md +8 -0
- package/assets/starters/fimo-site/template/.fimo/agents/content-guide/capabilities.yaml +2 -0
- package/assets/starters/fimo-site/template/.fimo/agents/content-guide/config.yaml +9 -0
- package/assets/starters/fimo-site/template/.fimo/agents/site-review/GOAL.md +14 -0
- package/assets/starters/fimo-site/template/.fimo/agents/site-review/capabilities.yaml +4 -0
- package/assets/starters/fimo-site/template/.fimo/agents/site-review/config.yaml +9 -0
- package/assets/starters/fimo-site/template/src/forms/contact.json +14 -0
- package/assets/starters/fimo-site/template/src/forms/contact.ts +46 -0
- package/assets/starters/fimo-site/template/src/pages/Contact.tsx +193 -0
- package/assets/starters/fimo-site/template/src/pages/Index.tsx +27 -0
- package/assets/starters/fimo-site/template/src/pages/SiteContent.tsx +191 -0
- package/assets/starters/fimo-site/template/src/schemas/HomePage.json +28 -0
- package/assets/starters/fimo-site/template/src/schemas/HomePage.ts +29 -0
- package/assets/starters/fimo-site/template/src/schemas/HomePage.types.ts +64 -0
- package/dist/build/vite/index.js +12 -3
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +72 -71
- package/dist/runtime/content/client/internal.js +3 -3
- package/dist/runtime/paths/get-fimo-paths.js +3 -3
- package/package.json +1 -1
- package/templates/react-router/package.json +1 -1
- package/templates/react-router/src/vite-env.d.ts +1 -1
- package/templates/react-router/vite.config.ts +1 -1
|
@@ -58,6 +58,7 @@ A project can have multiple **envs** — each is a git branch _plus_ its own DB,
|
|
|
58
58
|
npx fimo@latest login # authenticate via browser
|
|
59
59
|
npx fimo@latest skills install # install the global Fimo skill for your AI tools
|
|
60
60
|
npx fimo@latest init my-site --org <id> # one-stop setup: sign in, create/link a project, install deps + skills. Use the org **id** from `fimo orgs list`, NOT the slug.
|
|
61
|
+
npx fimo@latest init my-site --starter fimo-site@1 --org <id> --yes --no-skills # opt-in content-led starter for a new project
|
|
61
62
|
npx fimo@latest init --project-id <id> my-site # link an EXISTING project instead of creating one
|
|
62
63
|
npx fimo@latest clone # interactively choose an existing project to clone locally
|
|
63
64
|
cd my-site # then load skills on demand: npx fimo@latest skills get fimo-code
|
|
@@ -77,7 +78,7 @@ The working loop is local: run the app with the project's package-manager `dev`
|
|
|
77
78
|
|
|
78
79
|
Load the matching reference when the user's request fires its trigger. Recipes that involve writing code also need the `fimo-code` skill's package references — they're called out where relevant.
|
|
79
80
|
|
|
80
|
-
- "Create a new Fimo project / scaffold a site / start a new website / link an existing project locally" → `references/init.md`
|
|
81
|
+
- "Create a new Fimo project / scaffold a site / start a new website / add the opt-in content starter / link an existing project locally" → `references/init.md`
|
|
81
82
|
- "Add a content type / blog / products / pages / CMS" → `references/content.md` (workflow) **+ load `fimo-code/references/content.md` for the JSON shape and React hooks**
|
|
82
83
|
- "Add a contact / signup / feedback / newsletter form" → `references/forms.md` (workflow) **+ load `fimo-code/references/forms.md` for the JSON spec and generated client**
|
|
83
84
|
- "View form submissions / who filled out the contact form / inspect what users sent / how many signups" → `references/forms.md` § Reading submissions
|
|
@@ -136,6 +137,8 @@ These fire at specific moments in the project lifecycle. **Surface them proactiv
|
|
|
136
137
|
- **Running `fimo deploy` just to get a fresh preview URL** — that's a commit + push + sandbox sync for what should be a sub-second read. Use `fimo preview url` instead. See `references/preview.md`.
|
|
137
138
|
- **Using `fimo deploy --publish` casually** — `--publish` makes the site live to the public. Plain `fimo deploy` (preview) is the default. Use `--publish` only when the user says "ship it" / "go live" / "publish".
|
|
138
139
|
- **Bare `fimo init` in a non-interactive shell** — will hang. Always pass `[dir]`, `--no-install` (or `--install`), `--no-skills` (or `--skills`), and `--org`/`--agents` as needed explicitly when scripted.
|
|
140
|
+
- **Using a starter on an existing project** — `--starter` is create-new only. Keep `--project-id` flows blank and use explicit content/form/label commands when changing an existing project.
|
|
141
|
+
- **Treating a starter as a permanent demo tenant** — starters are opt-in seed data in the new project, not a global default. Keep starter agents manual-only; do not add schedules or webhooks unless the project owner explicitly asks for automation.
|
|
139
142
|
- **Hardcoding content / strings / images in code** — that's what schemas, `t()`, and the media library are for. See the `fimo-code` skill's anti-patterns.
|
|
140
143
|
- **Adding another deploy tool (Vercel, Netlify, Cloudflare Pages)** — Fimo handles deploys; don't bolt another platform on.
|
|
141
144
|
- **Committing secrets** — hidden values use `fimo secrets`; public browser-safe values use `fimo vars`. Local env files are ignored and must not become deploy inputs.
|
|
@@ -13,26 +13,51 @@ fimo init [dir] [options]
|
|
|
13
13
|
`fimo init` runs the full onboarding in one shot:
|
|
14
14
|
|
|
15
15
|
1. **Signs in automatically** — if there's no session it opens the browser loopback login. In `--json` / non-TTY with no session it fails with `NOT_AUTHENTICATED` (run `fimo login` first).
|
|
16
|
-
2. **Resolves the project** — with `--project-id <id>` it links an EXISTING project (clones it); without it, it creates a NEW project. An interactive TTY shows a "Create new / Link existing" picker; non-interactive shells default to create-new.
|
|
16
|
+
2. **Resolves the project** — with `--project-id <id>` it links an EXISTING project (clones it); without it, it creates a NEW project. An interactive TTY shows a "Create new / Link existing" picker; non-interactive shells default to create-new. An optional `--starter fimo-site@1` adds a content-led starter only to a newly created project.
|
|
17
17
|
3. **Installs dependencies** — prompts for the package manager, or honor `--package-manager <pm>` / `--no-install`.
|
|
18
18
|
4. **Optionally sets up Fimo skills** — confirm prompt, or `--skills` / `--no-skills`.
|
|
19
19
|
|
|
20
|
-
| Flag | Required | Purpose
|
|
21
|
-
| ---------------------------- | ----------------------------- |
|
|
22
|
-
| `[dir]` positional | yes in non-TTY for create-new | Target directory (e.g. `my-site`)
|
|
23
|
-
| `--project-id <id>` | no | Link an EXISTING project instead of creating one (clones it).
|
|
24
|
-
| `--org <id>` | no | Target organization **id** when creating (use the `ID` column from `fimo orgs list`, NOT the slug). Picker shown interactively if omitted and TTY.
|
|
25
|
-
| `--env <name>` | no | Env to check out when linking an existing project (defaults to `main`).
|
|
26
|
-
| `--
|
|
27
|
-
| `--
|
|
28
|
-
| `--
|
|
29
|
-
| `--
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
20
|
+
| Flag | Required | Purpose |
|
|
21
|
+
| ---------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| `[dir]` positional | yes in non-TTY for create-new | Target directory (e.g. `my-site`) |
|
|
23
|
+
| `--project-id <id>` | no | Link an EXISTING project instead of creating one (clones it). |
|
|
24
|
+
| `--org <id>` | no | Target organization **id** when creating (use the `ID` column from `fimo orgs list`, NOT the slug). Picker shown interactively if omitted and TTY. |
|
|
25
|
+
| `--env <name>` | no | Env to check out when linking an existing project (defaults to `main`). |
|
|
26
|
+
| `--starter <id>` | no | Opt-in project starter for create-new (currently `fimo-site@1`). It seeds CMS content, labels, a contact form, and manual-only agents. Rejects `--project-id`. |
|
|
27
|
+
| `--package-manager <pm>` | no | `npm` \| `pnpm` \| `yarn` \| `bun`. |
|
|
28
|
+
| `--install` / `--no-install` | yes in non-TTY | Install deps after checkout (or skip it). |
|
|
29
|
+
| `--skills` / `--no-skills` | yes in non-TTY | Install the global Fimo skill (or skip it). |
|
|
30
|
+
| `--agents <list>` | no | Comma-separated agent IDs (e.g. `claude-code,cursor`). Pass `none` to skip installing the global skill. |
|
|
31
|
+
| `-a, --agent <id>` | alternative | Repeatable form: `-a claude-code -a cursor`. |
|
|
32
|
+
| `--template-root <path>` | no | Override the bundled template (development only). |
|
|
33
|
+
| `-y, --yes` | no | Run non-interactively using defaults (no prompts). |
|
|
33
34
|
|
|
34
35
|
In a non-TTY shell the create-new path requires `[dir]`, `--install` / `--no-install`, and `--skills` / `--no-skills` (and `--org` if you have no active org and belong to multiple orgs). Without them the command exits with `Cannot prompt in a non-interactive shell.`
|
|
35
36
|
|
|
37
|
+
### Non-interactive starter setup
|
|
38
|
+
|
|
39
|
+
Use explicit flags when a script or agent owns the flow. `--json` gives the
|
|
40
|
+
caller one stable result envelope; `--yes` is useful when running in a TTY but
|
|
41
|
+
is not required in a pipe or CI shell. The starter is never selected by default.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx fimo@latest --json init my-site \
|
|
45
|
+
--starter fimo-site@1 \
|
|
46
|
+
--org <organizationId> \
|
|
47
|
+
--no-install \
|
|
48
|
+
--no-skills \
|
|
49
|
+
--agents=none \
|
|
50
|
+
--yes
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The command creates the project, copies the starter overlay, seeds its `HomePage`
|
|
54
|
+
singleton, `contact` form, and labels in `main`, writes the form id into
|
|
55
|
+
`src/forms/contact.json`, and commits the starter agents as manual-only bundles.
|
|
56
|
+
`--starter` is create-new only; an existing-project command such as
|
|
57
|
+
`fimo init --project-id <id> --starter fimo-site@1` fails before cloning or
|
|
58
|
+
writing anything. If dependencies were skipped, install them and run
|
|
59
|
+
`fimo validate` before starting the app so generated clients are present.
|
|
60
|
+
|
|
36
61
|
> **Zero-install:** run `npx fimo@latest init` — no global install needed. `fimo projects create` is also an alias of `fimo init` (create-new path).
|
|
37
62
|
|
|
38
63
|
For `--agents`, pass the AI tool you're running in: `--agents=claude-code` for Claude Code, `--agents=cursor` for Cursor, `--agents=codex` for Codex, etc. When the flag is omitted, env-var detection of the invoking harness (e.g. `CLAUDECODE` / `CLAUDE_CODE_ENTRYPOINT` → claude-code) auto-targets that agent; other agents must be selected explicitly via `--agents`/`-a` or the interactive picker.
|
|
@@ -53,6 +78,14 @@ Pass `--project-id <id>` to link an EXISTING project instead of creating one. Th
|
|
|
53
78
|
- `AGENTS.md` + `CLAUDE.md` — project rules (from the template) read by AGENTS.md-compatible tools and Claude Code. Both point the agent at `fimo skills get` to load the real skills on demand.
|
|
54
79
|
- A git repository with `fimo` as the remote, and — after dependencies install — an initial commit (including the lockfile) plus an initial push
|
|
55
80
|
|
|
81
|
+
When `--starter fimo-site@1` is supplied, the overlay also includes a
|
|
82
|
+
content-backed homepage, `src/schemas/HomePage.json`, a `contact` form spec,
|
|
83
|
+
nine English labels, and two read-only agent bundles (`site-review` and
|
|
84
|
+
`content-guide`). The seed is opt-in and scoped to the new project; the default
|
|
85
|
+
blank template and all existing-project flows remain unchanged. Starter agents
|
|
86
|
+
declare only a manual trigger so copying them does not create schedules or
|
|
87
|
+
webhooks.
|
|
88
|
+
|
|
56
89
|
Nothing is written into per-agent skill dirs: the rich skills are fetched on demand via `fimo skills get <name>`, never copied into the project. `fimo init` separately installs the small global `fimo` bootstrap skill for the agents you select (machine-wide, not in the project) unless you pass `--no-skills`. For other agents' rule-file variants (`.cursor/rules/fimo.mdc`, `.windsurfrules`, `GEMINI.md`), run `fimo rules sync`. See `references/agent-setup.md`.
|
|
57
90
|
|
|
58
91
|
Codex, OpenCode, Aider, and similar AGENTS.md-native tools work directly off AGENTS.md.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fimo-site",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"description": "A content-led site with a homepage, contact form, labels, and manual review agents.",
|
|
5
|
+
"singletons": [
|
|
6
|
+
{
|
|
7
|
+
"type": "HomePage",
|
|
8
|
+
"data": {
|
|
9
|
+
"eyebrow": "Field notes",
|
|
10
|
+
"title": "Your website, ready to edit.",
|
|
11
|
+
"intro": {
|
|
12
|
+
"type": "doc",
|
|
13
|
+
"content": [
|
|
14
|
+
{
|
|
15
|
+
"type": "paragraph",
|
|
16
|
+
"content": [
|
|
17
|
+
{
|
|
18
|
+
"type": "text",
|
|
19
|
+
"text": "This starter is real website content. Edit the copy in Studio, change the code with your favorite agent, and keep both surfaces in sync."
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"primaryCtaLabel": "Read the field notes",
|
|
26
|
+
"primaryCtaHref": "#stories",
|
|
27
|
+
"featured": [
|
|
28
|
+
{
|
|
29
|
+
"label": "Start here",
|
|
30
|
+
"title": "A calmer content workflow",
|
|
31
|
+
"summary": "Keep content, code, previews, and the team in one editable project.",
|
|
32
|
+
"href": "#stories"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"label": "For agents",
|
|
36
|
+
"title": "Build once, keep iterating",
|
|
37
|
+
"summary": "Give your coding agent a real CMS surface to work with after the first deploy.",
|
|
38
|
+
"href": "#stories"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"label": "For teams",
|
|
42
|
+
"title": "Make every edit reviewable",
|
|
43
|
+
"summary": "Let teammates shape the site without asking them to open a code editor.",
|
|
44
|
+
"href": "#stories"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"contactHeading": "Make something useful",
|
|
48
|
+
"contactIntro": "Tell us what you are building and we will help you find the shortest path from first draft to a site your team can own."
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"labels": [
|
|
53
|
+
{ "key": "nav.stories", "value": "Field notes", "locale": "en" },
|
|
54
|
+
{ "key": "nav.contact", "value": "Start a conversation", "locale": "en" },
|
|
55
|
+
{ "key": "hero.scroll", "value": "Explore the starter", "locale": "en" },
|
|
56
|
+
{ "key": "contact.name", "value": "Your name", "locale": "en" },
|
|
57
|
+
{ "key": "contact.email", "value": "Email address", "locale": "en" },
|
|
58
|
+
{ "key": "contact.message", "value": "What are you building?", "locale": "en" },
|
|
59
|
+
{ "key": "contact.submit", "value": "Send a note", "locale": "en" },
|
|
60
|
+
{ "key": "contact.sent", "value": "Thanks. Your note is on its way.", "locale": "en" },
|
|
61
|
+
{ "key": "contact.failed", "value": "That did not send. Please try again.", "locale": "en" }
|
|
62
|
+
],
|
|
63
|
+
"forms": ["contact"]
|
|
64
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Content guide
|
|
2
|
+
|
|
3
|
+
Inspect the site's content model and homepage copy. Suggest a small editorial
|
|
4
|
+
backlog that makes the site more useful to its intended audience.
|
|
5
|
+
|
|
6
|
+
Return three suggestions, each with the content type or field it touches and
|
|
7
|
+
why it matters. Use the existing content as evidence. Do not write files,
|
|
8
|
+
change entries, or create labels.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Keep starter agents manual-only. Adding a schedule or webhook and pushing to
|
|
2
|
+
# main registers an automation for the project.
|
|
3
|
+
version: 1
|
|
4
|
+
description: 'Suggest useful editorial improvements from the existing CMS content.'
|
|
5
|
+
data_sources:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
triggers:
|
|
9
|
+
- type: manual
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Site review
|
|
2
|
+
|
|
3
|
+
Review the starter project as a teammate would before the first publish.
|
|
4
|
+
|
|
5
|
+
Read the declared routes, the CMS content, the labels, and the public site.
|
|
6
|
+
Return a short report with:
|
|
7
|
+
|
|
8
|
+
- one thing that already feels clear;
|
|
9
|
+
- the three highest-value improvements;
|
|
10
|
+
- any missing content or broken route you can prove;
|
|
11
|
+
- one next action that a human or coding agent can review.
|
|
12
|
+
|
|
13
|
+
This is a read-only review. Do not edit files, change CMS content, create
|
|
14
|
+
branches, or publish anything.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Keep starter agents manual-only. Adding a schedule or webhook and pushing to
|
|
2
|
+
# main registers an automation for the project.
|
|
3
|
+
version: 1
|
|
4
|
+
description: 'Review the starter site and return a concise, read-only launch report.'
|
|
5
|
+
data_sources:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
triggers:
|
|
9
|
+
- type: manual
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "contact",
|
|
3
|
+
"label": "Start a conversation",
|
|
4
|
+
"fields": {
|
|
5
|
+
"name": { "type": "text", "label": "Your name", "required": true },
|
|
6
|
+
"email": { "type": "email", "label": "Email address", "required": true },
|
|
7
|
+
"message": {
|
|
8
|
+
"type": "textarea",
|
|
9
|
+
"label": "What are you building?",
|
|
10
|
+
"required": true,
|
|
11
|
+
"validation": { "minLength": 10, "maxLength": 2000 }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// AUTO-GENERATED ⚠️ — do not edit by hand
|
|
2
|
+
// Source: src/forms/contact.json
|
|
3
|
+
// The CLI replaces the placeholder form id while seeding the starter. `fimo
|
|
4
|
+
// validate` regenerates this file from the JSON spec on later edits.
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
export const FORM_ID = '__FIMO_FORM_ID__';
|
|
9
|
+
|
|
10
|
+
export const ContactSchema = z.object({
|
|
11
|
+
name: z.string().min(1, 'Your name is required'),
|
|
12
|
+
email: z.string().min(1, 'Email address is required').email('Invalid email address'),
|
|
13
|
+
message: z.string().min(1, 'What are you building? is required').min(10).max(2000),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type ContactData = z.infer<typeof ContactSchema>;
|
|
17
|
+
|
|
18
|
+
// @ts-ignore - meta.env is a vite feature
|
|
19
|
+
const base = import.meta.env.VITE_API_URL;
|
|
20
|
+
|
|
21
|
+
export async function submitContact(data: ContactData): Promise<{
|
|
22
|
+
success: boolean;
|
|
23
|
+
errors?: Record<string, string>;
|
|
24
|
+
}> {
|
|
25
|
+
let response: Response;
|
|
26
|
+
try {
|
|
27
|
+
response = await fetch(`${base}/forms/${FORM_ID}/submit`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify(data),
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
return { success: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (response.ok) {
|
|
37
|
+
return { success: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (response.status === 422) {
|
|
41
|
+
const json = (await response.json()) as { errors?: Record<string, string> };
|
|
42
|
+
return { success: false, errors: json.errors };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { success: false };
|
|
46
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { type FimoString, Text, useLabels } from 'fimo/ui';
|
|
2
|
+
import { ArrowUpRight, Check } from 'lucide-react';
|
|
3
|
+
import { type FormEvent, type ReactNode, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Input } from '@/components/ui/input';
|
|
7
|
+
import { Label } from '@/components/ui/label';
|
|
8
|
+
import { Spinner } from '@/components/ui/spinner';
|
|
9
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
10
|
+
import { ContactSchema, submitContact } from '@/forms/contact';
|
|
11
|
+
import { cn } from '@/lib/utils';
|
|
12
|
+
import type { HomePage } from '@/schemas/HomePage';
|
|
13
|
+
|
|
14
|
+
import { Bar } from './SiteContent';
|
|
15
|
+
|
|
16
|
+
export function Contact({ page, loading }: { page?: HomePage; loading: boolean }) {
|
|
17
|
+
return (
|
|
18
|
+
<section id="contact" className="dark bg-background text-foreground scroll-mt-16">
|
|
19
|
+
<div className="mx-auto grid w-full max-w-6xl gap-12 px-6 py-20 lg:grid-cols-12 lg:gap-16 lg:px-10 lg:py-28">
|
|
20
|
+
<div className="lg:col-span-5">
|
|
21
|
+
{page ? (
|
|
22
|
+
<>
|
|
23
|
+
<Text
|
|
24
|
+
value={page.contactHeading}
|
|
25
|
+
as="h2"
|
|
26
|
+
className="text-4xl font-semibold tracking-[-0.03em] text-balance lg:text-5xl"
|
|
27
|
+
/>
|
|
28
|
+
<Text value={page.contactIntro} as="p" className="text-muted-foreground mt-6 max-w-md leading-relaxed" />
|
|
29
|
+
</>
|
|
30
|
+
) : (
|
|
31
|
+
<div aria-hidden>
|
|
32
|
+
<div className="grid gap-3">
|
|
33
|
+
<Bar loading={loading} className="h-10 w-4/5" />
|
|
34
|
+
<Bar loading={loading} className="h-10 w-2/5" />
|
|
35
|
+
</div>
|
|
36
|
+
<div className="mt-8 grid gap-2">
|
|
37
|
+
<Bar loading={loading} className="h-4 w-full max-w-md" />
|
|
38
|
+
<Bar loading={loading} className="h-4 w-11/12 max-w-md" />
|
|
39
|
+
<Bar loading={loading} className="h-4 w-2/3 max-w-md" />
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div className="lg:col-span-7">
|
|
46
|
+
<ContactForm />
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type SubmitState = 'idle' | 'submitting' | 'sent' | 'invalid' | 'failed';
|
|
54
|
+
|
|
55
|
+
function ContactForm() {
|
|
56
|
+
const { t } = useLabels();
|
|
57
|
+
const [state, setState] = useState<SubmitState>('idle');
|
|
58
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
59
|
+
|
|
60
|
+
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
// `currentTarget` is cleared once the handler yields, so keep the node.
|
|
63
|
+
const form = event.currentTarget;
|
|
64
|
+
setErrors({});
|
|
65
|
+
|
|
66
|
+
const parsed = ContactSchema.safeParse(Object.fromEntries(new FormData(form)));
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
const fieldErrors: Record<string, string> = {};
|
|
69
|
+
for (const issue of parsed.error.issues) {
|
|
70
|
+
fieldErrors[issue.path[0] as string] = issue.message;
|
|
71
|
+
}
|
|
72
|
+
setErrors(fieldErrors);
|
|
73
|
+
setState('invalid');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setState('submitting');
|
|
78
|
+
const result = await submitContact(parsed.data);
|
|
79
|
+
if (result.success) {
|
|
80
|
+
form.reset();
|
|
81
|
+
setState('sent');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A rejected submission comes back with per-field messages; a network or
|
|
86
|
+
// server failure has nothing to attach to a field, so it needs its own.
|
|
87
|
+
const fieldErrors = result.errors ?? {};
|
|
88
|
+
setErrors(fieldErrors);
|
|
89
|
+
setState(Object.keys(fieldErrors).length > 0 ? 'invalid' : 'failed');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const busy = state === 'submitting';
|
|
93
|
+
const sent = state === 'sent';
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<form
|
|
97
|
+
onSubmit={handleSubmit}
|
|
98
|
+
noValidate
|
|
99
|
+
aria-busy={busy}
|
|
100
|
+
className="border-border bg-card rounded-xl border p-6 sm:p-8"
|
|
101
|
+
>
|
|
102
|
+
<div className="grid gap-6 sm:grid-cols-2">
|
|
103
|
+
<FormField id="contact-name" label={t('contact.name')} error={errors.name}>
|
|
104
|
+
<Input
|
|
105
|
+
id="contact-name"
|
|
106
|
+
name="name"
|
|
107
|
+
type="text"
|
|
108
|
+
autoComplete="name"
|
|
109
|
+
disabled={busy || sent}
|
|
110
|
+
aria-invalid={Boolean(errors.name)}
|
|
111
|
+
aria-describedby={errors.name ? 'contact-name-error' : undefined}
|
|
112
|
+
/>
|
|
113
|
+
</FormField>
|
|
114
|
+
|
|
115
|
+
<FormField id="contact-email" label={t('contact.email')} error={errors.email}>
|
|
116
|
+
<Input
|
|
117
|
+
id="contact-email"
|
|
118
|
+
name="email"
|
|
119
|
+
type="email"
|
|
120
|
+
autoComplete="email"
|
|
121
|
+
disabled={busy || sent}
|
|
122
|
+
aria-invalid={Boolean(errors.email)}
|
|
123
|
+
aria-describedby={errors.email ? 'contact-email-error' : undefined}
|
|
124
|
+
/>
|
|
125
|
+
</FormField>
|
|
126
|
+
|
|
127
|
+
<div className="sm:col-span-2">
|
|
128
|
+
<FormField id="contact-message" label={t('contact.message')} error={errors.message}>
|
|
129
|
+
<Textarea
|
|
130
|
+
id="contact-message"
|
|
131
|
+
name="message"
|
|
132
|
+
rows={5}
|
|
133
|
+
disabled={busy || sent}
|
|
134
|
+
aria-invalid={Boolean(errors.message)}
|
|
135
|
+
aria-describedby={errors.message ? 'contact-message-error' : undefined}
|
|
136
|
+
/>
|
|
137
|
+
</FormField>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className="mt-8 flex flex-wrap items-center gap-x-4 gap-y-3">
|
|
142
|
+
<Button
|
|
143
|
+
type="submit"
|
|
144
|
+
size="lg"
|
|
145
|
+
disabled={busy || sent}
|
|
146
|
+
className={cn('group', state === 'invalid' && 'ring-destructive/40 ring-[3px]')}
|
|
147
|
+
>
|
|
148
|
+
{busy ? <Spinner /> : null}
|
|
149
|
+
{sent ? <Check /> : null}
|
|
150
|
+
<Text value={t('contact.submit')} />
|
|
151
|
+
{!busy && !sent ? (
|
|
152
|
+
<ArrowUpRight className="transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
|
153
|
+
) : null}
|
|
154
|
+
</Button>
|
|
155
|
+
|
|
156
|
+
{/* Both regions stay mounted and empty: a live region inserted at the same
|
|
157
|
+
moment as its text is announced unreliably. */}
|
|
158
|
+
<p role="status" className="text-muted-foreground text-sm">
|
|
159
|
+
{sent ? <Text value={t('contact.sent')} /> : null}
|
|
160
|
+
</p>
|
|
161
|
+
<p role="alert" className="text-destructive text-sm">
|
|
162
|
+
{state === 'failed' ? <Text value={t('contact.failed')} /> : null}
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
</form>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function FormField({
|
|
170
|
+
id,
|
|
171
|
+
label,
|
|
172
|
+
error,
|
|
173
|
+
children,
|
|
174
|
+
}: {
|
|
175
|
+
id: string;
|
|
176
|
+
label: FimoString;
|
|
177
|
+
error?: string;
|
|
178
|
+
children: ReactNode;
|
|
179
|
+
}) {
|
|
180
|
+
return (
|
|
181
|
+
<div className="grid gap-2">
|
|
182
|
+
<Label htmlFor={id} className="text-muted-foreground text-[11px] font-medium tracking-[0.16em] uppercase">
|
|
183
|
+
<Text value={label} />
|
|
184
|
+
</Label>
|
|
185
|
+
{children}
|
|
186
|
+
{error ? (
|
|
187
|
+
<p id={`${id}-error`} role="alert" className="text-destructive text-xs">
|
|
188
|
+
{error}
|
|
189
|
+
</p>
|
|
190
|
+
) : null}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import HomePageContent from '@/schemas/HomePage';
|
|
2
|
+
|
|
3
|
+
import { Contact } from './Contact';
|
|
4
|
+
import { ContentFallback, Hero, SiteHeader, Stories } from './SiteContent';
|
|
5
|
+
|
|
6
|
+
const Index = () => {
|
|
7
|
+
const { data: page, isLoading } = HomePageContent.useGet();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="bg-background text-foreground min-h-screen">
|
|
11
|
+
<SiteHeader />
|
|
12
|
+
<main>
|
|
13
|
+
{page ? (
|
|
14
|
+
<>
|
|
15
|
+
<Hero page={page} />
|
|
16
|
+
<Stories items={page.featured ?? []} />
|
|
17
|
+
</>
|
|
18
|
+
) : (
|
|
19
|
+
<ContentFallback loading={isLoading} />
|
|
20
|
+
)}
|
|
21
|
+
<Contact page={page} loading={isLoading} />
|
|
22
|
+
</main>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default Index;
|