since3-website/modules/landingPage/forms/contact-form.tsx
2026-09-25 18:53:25 +02:00

194 lines
6.1 KiB
TypeScript

"use client"
import { useActionState, useEffect, useId, useRef } from "react"
import { CircleAlert, CircleCheck } from "lucide-react"
import { Button, ButtonArrow } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { play } from "@/lib/sound"
import { cn } from "@/lib/utils"
import { CONTACT_PREFILL_EVENT } from "@/modules/landingPage/lib/contact-prefill"
import {
initialContactState,
type ContactField,
} from "@/modules/landingPage/schema/contact-schema"
import { submitContact } from "@/modules/landingPage/server/contact-action"
export function ContactForm() {
const [state, formAction, pending] = useActionState(
submitContact,
initialContactState
)
const id = useId()
const messageRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (state.status === "success") play("confirm")
}, [state])
// Text handed over by other sections, e.g. the price calculator
useEffect(() => {
const onPrefill = (event: Event) => {
const el = messageRef.current
if (!el) return
const text = (event as CustomEvent<string>).detail
// Keep whatever the visitor already wrote
el.value = el.value.trim() ? `${text}
${el.value}` : text
// The page is still scrolling there, so focus without jumping
el.focus({ preventScroll: true })
el.setSelectionRange(el.value.length, el.value.length)
}
window.addEventListener(CONTACT_PREFILL_EVENT, onPrefill)
return () => window.removeEventListener(CONTACT_PREFILL_EVENT, onPrefill)
}, [])
const fieldId = (field: ContactField) => `${id}-${field}`
const errorId = (field: ContactField) => `${id}-${field}-error`
const errorFor = (field: ContactField) => state.fieldErrors?.[field]?.[0]
// Wires label, error text and aria attributes together for one field
const fieldProps = (field: ContactField) => ({
id: fieldId(field),
name: field,
"aria-invalid": errorFor(field) ? true : undefined,
"aria-describedby": errorFor(field) ? errorId(field) : undefined,
})
if (state.status === "success") {
return (
<div role="status" className="flex flex-col items-start gap-4 py-8">
<CircleCheck className="size-8 text-signal-success" aria-hidden />
<p className="text-title font-heading">{state.message}</p>
<p className="type-body text-fg-muted">
Du bekommst eine Antwort von einem Menschen, nicht von einem Ticketsystem.
</p>
</div>
)
}
return (
<form action={formAction} noValidate className="grid gap-6 sm:grid-cols-2">
<Field id={errorId("name")} message={errorFor("name")}>
<Label htmlFor={fieldId("name")}>Name</Label>
<Input
{...fieldProps("name")}
autoComplete="name"
required
defaultValue={state.values?.name}
/>
</Field>
<Field id={errorId("email")} message={errorFor("email")}>
<Label htmlFor={fieldId("email")}>E-Mail</Label>
<Input
{...fieldProps("email")}
type="email"
autoComplete="email"
required
defaultValue={state.values?.email}
/>
</Field>
<Field
id={errorId("company")}
message={errorFor("company")}
className="sm:col-span-2"
>
<Label htmlFor={fieldId("company")}>
Unternehmen <span className="text-fg-muted">(optional)</span>
</Label>
<Input
{...fieldProps("company")}
autoComplete="organization"
defaultValue={state.values?.company}
/>
</Field>
<Field
id={errorId("message")}
message={errorFor("message")}
className="sm:col-span-2"
>
<Label htmlFor={fieldId("message")}>Worum geht es?</Label>
<Textarea
{...fieldProps("message")}
ref={messageRef}
required
rows={5}
defaultValue={state.values?.message}
/>
</Field>
{/* Honeypot, hidden from people and assistive technology */}
<div aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden">
<label>
Website
<input type="text" name="website" tabIndex={-1} autoComplete="off" />
</label>
</div>
<Field
id={errorId("privacy")}
message={errorFor("privacy")}
className="sm:col-span-2"
>
<div className="flex items-start gap-3">
<input
{...fieldProps("privacy")}
type="checkbox"
required
defaultChecked={state.values?.privacy}
className="mt-0.5 size-5 shrink-0 accent-current"
/>
<label htmlFor={fieldId("privacy")} className="text-sm text-fg-muted">
Ich bin einverstanden, dass meine Angaben zur Bearbeitung der Anfrage
gespeichert werden. Details in der{" "}
<a href="/datenschutz" className="text-fg underline underline-offset-4">
Datenschutzerklärung
</a>
.
</label>
</div>
</Field>
<div className="flex flex-col items-start gap-4 sm:col-span-2 sm:flex-row sm:items-center">
<Button type="submit" size="lg" disabled={pending}>
{pending ? "Wird gesendet …" : "Anfrage senden"}
<ButtonArrow />
</Button>
<p aria-live="polite" className="text-sm text-signal-error">
{state.status === "error" ? state.message : null}
</p>
</div>
</form>
)
}
function Field({
id,
message,
className,
children,
}: {
id: string
message?: string
className?: string
children: React.ReactNode
}) {
return (
<div className={cn("flex flex-col gap-2.5", className)}>
{children}
{/* Errors are shown in text and color, never color alone */}
{message ? (
<p id={id} className="flex items-center gap-1.5 text-sm text-signal-error">
<CircleAlert className="size-4 shrink-0" aria-hidden />
{message}
</p>
) : null}
</div>
)
}