9b6638eb00
Deploy / deploy (push) Successful in 1m13s
Deux courbes partagées dans lib/motion.ts, pour que le CSS et Framer aient le même feeling, et quatre primitives que Tailwind ne sait pas exprimer : reflet de bouton, halo qui suit le curseur, soulignement qui se dessine, secousse d'erreur. Nouveaux composants : Stagger (les collections se distribuent une par une), Spotlight (trouve son parent tout seul, donc les cartes restent des composants serveur), ScrollProgress, BackToTop, Parallax, Spinner, CheckMark. Reveal accepte maintenant une direction. Côté rendu : le trait de nav glisse d'un onglet à l'autre, les cartes se soulèvent, le hero réagit au scroll, les piliers entrent en zig-zag, la lightbox glisse dans le sens demandé et les formulaires répondent. prefers-reduced-motion est respecté partout : Framer coupe via useReducedMotion, le CSS via la media query déjà en place. Le commit embarque aussi la réorganisation des composants par domaine qui était en cours dans l'arbre de travail — les deux étaient trop imbriquées pour être séparées proprement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
7.0 KiB
TypeScript
205 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { useActionState, useState } from "react";
|
|
import { useFormStatus } from "react-dom";
|
|
import { motion } from "framer-motion";
|
|
import { CheckMark } from "../ui/CheckMark";
|
|
import { Spinner } from "../ui/Spinner";
|
|
import { sendContact, type ContactState } from "@/app/actions/contact";
|
|
import { CONTACT_CHANNELS } from "@/lib/site";
|
|
import { EASE_OUT_EXPO } from "@/lib/motion";
|
|
|
|
const initialState: ContactState = { status: "idle", message: "" };
|
|
|
|
function SubmitButton() {
|
|
const { pending } = useFormStatus();
|
|
return (
|
|
<button
|
|
type="submit"
|
|
disabled={pending}
|
|
className="sheen relative inline-flex items-center justify-center gap-2 overflow-hidden bg-accent px-8 py-3 font-display text-sm tracking-widest text-background transition duration-200 ease-out hover:-translate-y-0.5 hover:bg-foreground hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0 disabled:hover:shadow-none"
|
|
>
|
|
{pending && <Spinner />}
|
|
{pending ? "SENDING…" : "SEND MESSAGE"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const inputBase =
|
|
"w-full bg-surface border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none transition-colors duration-200";
|
|
|
|
/** Field errors slide in under their input rather than appearing under it. */
|
|
function FieldError({ children, id }: { children: string; id: number }) {
|
|
return (
|
|
<motion.p
|
|
key={id}
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, ease: EASE_OUT_EXPO }}
|
|
className="mt-1.5 text-xs text-red-400"
|
|
>
|
|
{children}
|
|
</motion.p>
|
|
);
|
|
}
|
|
|
|
export function ContactForm() {
|
|
const [state, formAction] = useActionState(sendContact, initialState);
|
|
|
|
// A rejected submission has to be felt even when the message is identical to
|
|
// the last one. React would keep the same paragraph mounted and replay
|
|
// nothing, so every attempt gets a number and the number is the React key.
|
|
//
|
|
// Counted during render by comparing with the previous result rather than in
|
|
// an effect: the action already returns a fresh object per submission, so
|
|
// this needs no second render pass to stay in sync.
|
|
const [seen, setSeen] = useState(state);
|
|
const [attempt, setAttempt] = useState(0);
|
|
if (seen !== state) {
|
|
setSeen(state);
|
|
setAttempt((count) => count + 1);
|
|
}
|
|
|
|
if (state.status === "success") {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.96 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.45, ease: EASE_OUT_EXPO }}
|
|
className="rounded-2xl border border-accent/40 bg-surface p-10 text-center"
|
|
>
|
|
<CheckMark className="mx-auto mb-6 h-12 w-12" />
|
|
<p className="mb-3 font-display text-xs tracking-[0.3em] text-accent">
|
|
MESSAGE SENT
|
|
</p>
|
|
<p className="leading-relaxed text-foreground-muted">{state.message}</p>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
const err = state.fieldErrors ?? {};
|
|
|
|
return (
|
|
<form action={formAction} className="space-y-5">
|
|
<input
|
|
type="text"
|
|
name="website"
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
className="hidden"
|
|
aria-hidden
|
|
/>
|
|
|
|
<div className="grid sm:grid-cols-2 gap-5">
|
|
<div>
|
|
<label
|
|
htmlFor="contact-name"
|
|
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
|
>
|
|
NAME
|
|
</label>
|
|
<input
|
|
id="contact-name"
|
|
name="name"
|
|
type="text"
|
|
required
|
|
placeholder="Your name"
|
|
className={`${inputBase} ${err.name ? "border-red-400" : "border-border focus:border-accent"}`}
|
|
/>
|
|
{err.name && <FieldError id={attempt}>{err.name}</FieldError>}
|
|
</div>
|
|
<div>
|
|
<label
|
|
htmlFor="contact-email"
|
|
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
|
>
|
|
EMAIL
|
|
</label>
|
|
<input
|
|
id="contact-email"
|
|
name="email"
|
|
type="email"
|
|
required
|
|
placeholder="you@example.com"
|
|
className={`${inputBase} ${err.email ? "border-red-400" : "border-border focus:border-accent"}`}
|
|
/>
|
|
{err.email && <FieldError id={attempt}>{err.email}</FieldError>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-5 sm:grid-cols-2">
|
|
{/* The desk the message is for. It replaces the three mailto cards as
|
|
the default path: the visitor picks a reason instead of guessing
|
|
which address to write to. */}
|
|
<div>
|
|
<label
|
|
htmlFor="contact-topic"
|
|
className="mb-2 block font-display text-xs tracking-[0.3em] text-foreground-muted"
|
|
>
|
|
REASON
|
|
</label>
|
|
<select
|
|
id="contact-topic"
|
|
name="topic"
|
|
defaultValue={CONTACT_CHANNELS[0].label}
|
|
className={`${inputBase} cursor-pointer border-border focus:border-accent`}
|
|
>
|
|
{CONTACT_CHANNELS.map((channel) => (
|
|
<option key={channel.label} value={channel.label}>
|
|
{channel.label.charAt(0) + channel.label.slice(1).toLowerCase()}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="contact-subject"
|
|
className="mb-2 block font-display text-xs tracking-[0.3em] text-foreground-muted"
|
|
>
|
|
SUBJECT
|
|
</label>
|
|
<input
|
|
id="contact-subject"
|
|
name="subject"
|
|
type="text"
|
|
required
|
|
placeholder="What is this about?"
|
|
className={`${inputBase} ${err.subject ? "border-red-400" : "border-border focus:border-accent"}`}
|
|
/>
|
|
{err.subject && <FieldError id={attempt}>{err.subject}</FieldError>}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="contact-message"
|
|
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
|
>
|
|
MESSAGE
|
|
</label>
|
|
<textarea
|
|
id="contact-message"
|
|
name="message"
|
|
rows={6}
|
|
required
|
|
placeholder="Your message…"
|
|
className={`${inputBase} resize-y ${err.message ? "border-red-400" : "border-border focus:border-accent"}`}
|
|
/>
|
|
{err.message && <FieldError id={attempt}>{err.message}</FieldError>}
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pt-2">
|
|
<SubmitButton />
|
|
{state.status === "error" && !Object.keys(err).length && (
|
|
// Shakes once per attempt. The key remounts it, which is what makes
|
|
// the CSS animation run again on an identical repeated error.
|
|
<p key={attempt} className="animate-shake text-sm text-red-400">
|
|
{state.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|