Rocky_Mountain_Vending/components/forms/request-machine-form.tsx

414 lines
15 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { AlertCircle, CheckCircle, ClipboardCheck, PhoneCall } from "lucide-react"
import { FormButton } from "./form-button"
import { FormInput } from "./form-input"
import { FormTextarea } from "./form-textarea"
import { SmsConsentFields } from "./sms-consent-fields"
import { Checkbox } from "@/components/ui/checkbox"
import { PublicSectionHeader } from "@/components/public-surface"
import { businessConfig } from "@/lib/seo-config"
import { SMS_CONSENT_VERSION } from "@/lib/sms-compliance"
interface RequestMachineFormData {
firstName: string
lastName: string
email: string
phone: string
company: string
employeeCount: string
machineType: string
machineCount: string
message?: string
serviceTextConsent: boolean
marketingTextConsent: boolean
consentVersion: string
consentCapturedAt: string
consentSourcePage: string
confirmEmail?: string
source?: string
page?: string
}
interface RequestMachineFormProps {
onSubmit?: (data: RequestMachineFormData) => void
className?: string
}
const MACHINE_TYPE_OPTIONS = [
{ label: "Snack vending machine", value: "snack" },
{ label: "Beverage vending machine", value: "beverage" },
{ label: "Combo snack + beverage", value: "combo" },
{ label: "Cold food vending machine", value: "cold-food" },
{ label: "Hot food vending machine", value: "hot-food" },
{ label: "Coffee vending machine", value: "coffee" },
{ label: "Micro market", value: "micro-market" },
{ label: "Other / not sure yet", value: "other" },
] as const
export function RequestMachineForm({ onSubmit, className }: RequestMachineFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
reset,
setValue,
watch,
} = useForm<RequestMachineFormData>({
defaultValues: {
source: "website",
page: "",
serviceTextConsent: false,
marketingTextConsent: false,
consentVersion: SMS_CONSENT_VERSION,
consentCapturedAt: "",
consentSourcePage: "",
machineType: "",
},
})
const watchedValues = watch()
const canSubmit = Boolean(watchedValues.serviceTextConsent)
const selectedMachineTypes = useMemo(
() => (watchedValues.machineType ? watchedValues.machineType.split(",").filter(Boolean) : []),
[watchedValues.machineType],
)
useEffect(() => {
if (typeof window !== "undefined") {
const pathname = window.location.pathname
setValue("page", pathname)
setValue("consentSourcePage", pathname)
setValue("consentVersion", SMS_CONSENT_VERSION)
}
}, [setValue])
const buildResetValues = () => ({
source: "website",
page: typeof window !== "undefined" ? window.location.pathname : "",
serviceTextConsent: false,
marketingTextConsent: false,
consentVersion: SMS_CONSENT_VERSION,
consentCapturedAt: "",
consentSourcePage: typeof window !== "undefined" ? window.location.pathname : "",
machineType: "",
firstName: "",
lastName: "",
email: "",
phone: "",
company: "",
employeeCount: "",
machineCount: "",
message: "",
confirmEmail: "",
})
const updateMachineType = (value: string, checked: boolean) => {
const nextValues = checked
? [...selectedMachineTypes, value]
: selectedMachineTypes.filter((item) => item !== value)
setValue("machineType", Array.from(new Set(nextValues)).join(","), { shouldValidate: true })
}
const onFormSubmit = async (data: RequestMachineFormData) => {
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/request-machine", {
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">Your request is in.</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
We'll review your location details and call you within one business day.
</p>
<FormButton type="button" onClick={onFormReset} className="mt-5 w-full rounded-full" size="lg">
Submit Another Location
</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="Business contact"
description="Free placement is for qualifying businesses. Start with the best person to reach."
/>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<FormInput
id="request-first-name"
label="First Name"
placeholder="John"
autoComplete="given-name"
error={errors.firstName?.message}
{...register("firstName", { required: "First name is required" })}
/>
<FormInput
id="request-last-name"
label="Last Name"
placeholder="Doe"
autoComplete="family-name"
error={errors.lastName?.message}
{...register("lastName", { required: "Last name is required" })}
/>
<FormInput
id="request-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="request-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"
},
})}
/>
<FormInput
id="request-company"
label="Business Name"
placeholder="Your company or property"
autoComplete="organization"
error={errors.company?.message}
{...register("company", { required: "Business name is required" })}
/>
<FormInput
id="request-employee-count"
type="number"
min="1"
label="Approximate People On Site"
placeholder="50"
helperText="Give us your best estimate for the location this machine will serve."
error={errors.employeeCount?.message}
{...register("employeeCount", {
required: "Number of people is required",
validate: (value) => {
const num = Number(value)
return num >= 1 && num <= 10000 ? true : "Please enter a number between 1 and 10,000"
},
})}
/>
</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 should we plan for?"
description="Tell us what kind of setup you'd like so we can recommend the right mix."
/>
<div className="mt-5 grid gap-3 rounded-[1.5rem] border border-border/60 bg-white p-4 md:grid-cols-2">
{MACHINE_TYPE_OPTIONS.map((option) => {
const isChecked = selectedMachineTypes.includes(option.value)
return (
<label
key={option.value}
htmlFor={`machine-type-${option.value}`}
className="flex cursor-pointer items-start gap-3 rounded-xl border border-border/60 bg-white px-4 py-3 transition hover:border-primary/35"
>
<Checkbox
id={`machine-type-${option.value}`}
checked={isChecked}
onCheckedChange={(checked) => updateMachineType(option.value, Boolean(checked))}
className="mt-0.5"
/>
<span className="text-sm font-medium text-foreground">{option.label}</span>
</label>
)
})}
</div>
{errors.machineType ? <p className="mt-2 text-sm text-destructive">{errors.machineType.message}</p> : null}
<div className="mt-5 grid gap-4 md:grid-cols-2">
<FormInput
id="request-machine-count"
type="number"
min="1"
max="100"
label="Number of Machines"
placeholder="1"
error={errors.machineCount?.message}
{...register("machineCount", {
required: "Number of machines is required",
validate: (value) => {
const num = Number(value)
return num >= 1 && num <= 100 ? true : "Please enter a number between 1 and 100"
},
})}
/>
<div className="rounded-2xl border border-border/60 bg-white p-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2 font-medium text-foreground">
<ClipboardCheck className="h-4 w-4 text-primary" />
What we look at
</div>
<p className="mt-2 leading-relaxed">
We'll review your foot traffic, the type of location, and the mix of machines that fits best before we schedule the consultation.
</p>
</div>
</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="Anything else we should know?"
description="Optional details like break-room setup, preferred snacks, or special access notes are helpful."
/>
<div className="mt-5">
<FormTextarea
id="request-message"
label="Additional Information (Optional)"
placeholder="Share any timing, location, or machine preferences you already know about."
error={errors.message?.message}
{...register("message")}
/>
</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, installation planning, service, and follow-up texts for this request."
/>
<div className="mt-5">
<SmsConsentFields
idPrefix="request-machine"
serviceChecked={Boolean(watchedValues.serviceTextConsent)}
onServiceChange={(checked) => setValue("serviceTextConsent", checked, { shouldValidate: true })}
serviceError={errors.serviceTextConsent?.message}
/>
</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}
<div className="flex flex-col gap-3 sm:flex-row">
<FormButton type="submit" className="w-full rounded-full sm:flex-1" loading={isSubmitting} disabled={!canSubmit} size="lg">
{isSubmitting ? "Submitting..." : "Request Free Placement"}
</FormButton>
<a
href={businessConfig.publicCallUrl}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
>
<PhoneCall className="h-4 w-4" />
Call
</a>
</div>
<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("machineType", {
required: "Select at least one machine type",
})}
/>
<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>
)
}