Rocky_Mountain_Vending/components/forms/contact-form.tsx

377 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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, weve 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&apos;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>
)
}