491 lines
16 KiB
TypeScript
491 lines
16 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>
|
|
)
|
|
}
|