mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 19:51:16 +00:00
194 lines
6.1 KiB
TypeScript
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>
|
|
)
|
|
}
|