377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useMemo, useState } from "react"
|
||
import { useForm } from "react-hook-form"
|
||
import { AlertCircle, CheckCircle, MessageSquare, PhoneCall, Wrench } from "lucide-react"
|
||
|
||
import { FormButton } from "./form-button"
|
||
import { FormInput } from "./form-input"
|
||
import { FormSelect } from "./form-select"
|
||
import { FormTextarea } from "./form-textarea"
|
||
import { SmsConsentFields } from "./sms-consent-fields"
|
||
import { PublicSectionHeader } from "@/components/public-surface"
|
||
import { CONTACT_INTENT_OPTIONS } from "@/lib/site-chat/intents"
|
||
import { businessConfig } from "@/lib/seo-config"
|
||
import { SMS_CONSENT_VERSION } from "@/lib/sms-compliance"
|
||
|
||
interface ContactFormData {
|
||
firstName: string
|
||
lastName: string
|
||
email: string
|
||
phone: string
|
||
company: string
|
||
intent: string
|
||
message: string
|
||
serviceTextConsent: boolean
|
||
marketingTextConsent: boolean
|
||
consentVersion: string
|
||
consentCapturedAt: string
|
||
consentSourcePage: string
|
||
confirmEmail?: string
|
||
source?: string
|
||
page?: string
|
||
}
|
||
|
||
interface ContactFormProps {
|
||
onSubmit?: (data: ContactFormData) => void
|
||
className?: string
|
||
defaultIntent?: string
|
||
}
|
||
|
||
export function ContactForm({ onSubmit, className, defaultIntent = "" }: ContactFormProps) {
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||
|
||
const intentOptions = useMemo(
|
||
() => CONTACT_INTENT_OPTIONS.map((option) => ({ label: option, value: option })),
|
||
[],
|
||
)
|
||
|
||
const {
|
||
register,
|
||
handleSubmit,
|
||
formState: { errors },
|
||
reset,
|
||
setValue,
|
||
watch,
|
||
} = useForm<ContactFormData>({
|
||
defaultValues: {
|
||
source: "website",
|
||
page: "",
|
||
intent: defaultIntent,
|
||
serviceTextConsent: false,
|
||
marketingTextConsent: false,
|
||
consentVersion: SMS_CONSENT_VERSION,
|
||
consentCapturedAt: "",
|
||
consentSourcePage: "",
|
||
},
|
||
})
|
||
|
||
const watchedValues = watch()
|
||
const canSubmit = Boolean(watchedValues.serviceTextConsent)
|
||
|
||
useEffect(() => {
|
||
if (typeof window !== "undefined") {
|
||
const pathname = window.location.pathname
|
||
setValue("page", pathname)
|
||
setValue("consentSourcePage", pathname)
|
||
setValue("consentVersion", SMS_CONSENT_VERSION)
|
||
if (defaultIntent) {
|
||
setValue("intent", defaultIntent)
|
||
}
|
||
}
|
||
}, [defaultIntent, setValue])
|
||
|
||
const buildResetValues = () => ({
|
||
source: "website",
|
||
page: typeof window !== "undefined" ? window.location.pathname : "",
|
||
intent: defaultIntent,
|
||
serviceTextConsent: false,
|
||
marketingTextConsent: false,
|
||
consentVersion: SMS_CONSENT_VERSION,
|
||
consentCapturedAt: "",
|
||
consentSourcePage: typeof window !== "undefined" ? window.location.pathname : "",
|
||
firstName: "",
|
||
lastName: "",
|
||
email: "",
|
||
phone: "",
|
||
company: "",
|
||
message: "",
|
||
confirmEmail: "",
|
||
})
|
||
|
||
const onFormSubmit = async (data: ContactFormData) => {
|
||
setIsSubmitting(true)
|
||
setSubmitError(null)
|
||
|
||
try {
|
||
const consentCapturedAt = new Date().toISOString()
|
||
const formData = {
|
||
...data,
|
||
consentCapturedAt,
|
||
consentSourcePage: data.consentSourcePage || data.page || window.location.pathname,
|
||
consentVersion: data.consentVersion || SMS_CONSENT_VERSION,
|
||
timestamp: consentCapturedAt,
|
||
url: window.location.href,
|
||
}
|
||
|
||
const response = await fetch("/api/contact", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(formData),
|
||
})
|
||
|
||
const result = await response.json()
|
||
|
||
if (!response.ok || !result.success) {
|
||
throw new Error(result.error || result.message || "Failed to submit form")
|
||
}
|
||
|
||
setIsSubmitted(true)
|
||
reset(buildResetValues())
|
||
|
||
if (onSubmit) {
|
||
onSubmit(formData)
|
||
}
|
||
} catch (error) {
|
||
console.error("Form submission error:", error)
|
||
setSubmitError("We couldn't send that right now. Please try again or call us.")
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const onFormReset = () => {
|
||
setIsSubmitted(false)
|
||
setSubmitError(null)
|
||
reset(buildResetValues())
|
||
}
|
||
|
||
if (isSubmitted) {
|
||
return (
|
||
<div className={`rounded-[1.75rem] border border-emerald-200 bg-emerald-50/80 p-6 text-center shadow-sm ${className || ""}`}>
|
||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
|
||
<CheckCircle className="h-7 w-7" />
|
||
</div>
|
||
<h3 className="mt-4 text-2xl font-semibold text-foreground">Thanks, we’ve got it.</h3>
|
||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||
Our team will review your request and follow up within one business day.
|
||
</p>
|
||
<FormButton type="button" onClick={onFormReset} className="mt-5 w-full rounded-full" size="lg">
|
||
Send Another Request
|
||
</FormButton>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit(onFormSubmit)} className={`space-y-5 ${className || ""}`}>
|
||
<div className="sr-only">
|
||
<label htmlFor="confirm-email">Confirm Email (leave empty)</label>
|
||
<input id="confirm-email" type="email" {...register("confirmEmail")} className="sr-only" tabIndex={-1} />
|
||
</div>
|
||
|
||
<div className="rounded-[1.75rem] border border-border/70 bg-white p-5 shadow-sm md:p-6">
|
||
<PublicSectionHeader
|
||
eyebrow="Step 1"
|
||
title="How should we reach you?"
|
||
description="Share your contact details so the right team member can follow up quickly."
|
||
/>
|
||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||
<FormInput
|
||
id="contact-first-name"
|
||
label="First Name"
|
||
placeholder="John"
|
||
autoComplete="given-name"
|
||
error={errors.firstName?.message}
|
||
{...register("firstName", { required: "First name is required" })}
|
||
/>
|
||
<FormInput
|
||
id="contact-last-name"
|
||
label="Last Name"
|
||
placeholder="Doe"
|
||
autoComplete="family-name"
|
||
error={errors.lastName?.message}
|
||
{...register("lastName", { required: "Last name is required" })}
|
||
/>
|
||
<FormInput
|
||
id="contact-email"
|
||
type="email"
|
||
label="Email"
|
||
placeholder="john@example.com"
|
||
autoComplete="email"
|
||
error={errors.email?.message}
|
||
{...register("email", {
|
||
required: "Email is required",
|
||
pattern: {
|
||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||
message: "Please enter a valid email address",
|
||
},
|
||
})}
|
||
/>
|
||
<FormInput
|
||
id="contact-phone"
|
||
type="tel"
|
||
inputMode="tel"
|
||
autoComplete="tel"
|
||
label="Phone"
|
||
placeholder={businessConfig.publicCallNumber}
|
||
error={errors.phone?.message}
|
||
{...register("phone", {
|
||
required: "Phone number is required",
|
||
validate: (value) => {
|
||
const phoneDigits = value.replace(/\D/g, "")
|
||
return phoneDigits.length >= 10 && phoneDigits.length <= 15
|
||
? true
|
||
: "Please enter a valid phone number"
|
||
},
|
||
})}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-[1.75rem] border border-border/70 bg-white p-5 shadow-sm md:p-6">
|
||
<PublicSectionHeader
|
||
eyebrow="Step 2"
|
||
title="What do you need help with?"
|
||
description="A little context helps us route this to the right person the first time."
|
||
/>
|
||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||
<FormSelect
|
||
id="contact-intent"
|
||
label="Intent"
|
||
placeholder="Choose one"
|
||
error={errors.intent?.message}
|
||
options={intentOptions}
|
||
{...register("intent", {
|
||
required: "Please choose what you need help with",
|
||
})}
|
||
/>
|
||
<FormInput
|
||
id="contact-company"
|
||
label="Business or Location"
|
||
placeholder="Your company or site name"
|
||
autoComplete="organization"
|
||
helperText="If this is for a home-owned machine, you can leave the company name as your household name."
|
||
error={errors.company?.message}
|
||
{...register("company")}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-5 grid gap-3 rounded-2xl border border-border/60 bg-white p-4 text-sm text-muted-foreground md:grid-cols-[auto_1fr]">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||
<Wrench className="h-4 w-4" />
|
||
</div>
|
||
<p className="leading-relaxed">
|
||
For repairs or moving, include the machine model and a clear text description of the issue. You can also text photos or videos to{" "}
|
||
<a
|
||
href={businessConfig.publicSmsUrl}
|
||
className="font-semibold text-foreground underline decoration-primary/40 underline-offset-4 hover:decoration-primary"
|
||
>
|
||
{businessConfig.publicSmsNumber}
|
||
</a>{" "}
|
||
if that's easier.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-[1.75rem] border border-border/70 bg-white p-5 shadow-sm md:p-6">
|
||
<PublicSectionHeader
|
||
eyebrow="Step 3"
|
||
title="Tell us the details"
|
||
description="A few specifics here will help us answer faster and avoid extra back-and-forth."
|
||
/>
|
||
<div className="mt-5">
|
||
<FormTextarea
|
||
id="contact-message"
|
||
label="Message"
|
||
placeholder="Tell us what's going on, what machine or service you need, and any timing details we should know."
|
||
helperText="For moves, let us know whether it's a vending machine or a safe."
|
||
error={errors.message?.message}
|
||
{...register("message", {
|
||
required: "Message is required",
|
||
minLength: {
|
||
value: 10,
|
||
message: "Message must be at least 10 characters",
|
||
},
|
||
})}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-[1.75rem] border border-border/70 bg-white p-5 shadow-sm md:p-6">
|
||
<PublicSectionHeader
|
||
eyebrow="Step 4"
|
||
title="Text updates"
|
||
description="This single opt-in covers scheduling, support, and follow-up texts for this request."
|
||
/>
|
||
<div className="mt-5">
|
||
<SmsConsentFields
|
||
idPrefix="contact"
|
||
serviceChecked={Boolean(watchedValues.serviceTextConsent)}
|
||
onServiceChange={(checked) => setValue("serviceTextConsent", checked, { shouldValidate: true })}
|
||
serviceError={errors.serviceTextConsent?.message}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-[1.5rem] border border-border/60 bg-white px-4 py-4 text-sm text-muted-foreground">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-1">
|
||
<p className="font-medium text-foreground">Need help right away?</p>
|
||
<p className="leading-relaxed">Call during business hours or send us the details here and we'll follow up quickly.</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<a
|
||
href={businessConfig.publicCallUrl}
|
||
className="inline-flex items-center gap-2 rounded-full border border-border bg-white px-4 py-2 font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
|
||
>
|
||
<PhoneCall className="h-4 w-4" />
|
||
Call
|
||
</a>
|
||
<a
|
||
href={businessConfig.publicSmsUrl}
|
||
className="inline-flex items-center gap-2 rounded-full border border-border bg-white px-4 py-2 font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
|
||
>
|
||
<MessageSquare className="h-4 w-4" />
|
||
Text Photos
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{submitError ? (
|
||
<div className="rounded-2xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||
<div className="flex items-start gap-2">
|
||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||
<span>{submitError}</span>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<FormButton type="submit" className="w-full rounded-full" loading={isSubmitting} disabled={!canSubmit} size="lg">
|
||
{isSubmitting ? "Sending..." : "Send Request"}
|
||
</FormButton>
|
||
|
||
<input
|
||
type="hidden"
|
||
{...register("serviceTextConsent", {
|
||
validate: (value) => value || "Please agree to receive service-related SMS for this request",
|
||
})}
|
||
/>
|
||
<input type="hidden" {...register("marketingTextConsent")} />
|
||
<input type="hidden" {...register("consentVersion")} value={watchedValues.consentVersion || SMS_CONSENT_VERSION} />
|
||
<input type="hidden" {...register("consentCapturedAt")} value={watchedValues.consentCapturedAt || ""} />
|
||
<input
|
||
type="hidden"
|
||
{...register("consentSourcePage")}
|
||
value={watchedValues.consentSourcePage || watchedValues.page || ""}
|
||
/>
|
||
<input type="hidden" {...register("source")} value={watchedValues.source || "website"} />
|
||
<input type="hidden" {...register("page")} value={watchedValues.page || ""} />
|
||
</form>
|
||
)
|
||
}
|