Next.js website for Rocky Mountain Vending company featuring: - Product catalog with Stripe integration - Service areas and parts pages - Admin dashboard with Clerk authentication - SEO optimized pages with JSON-LD structured data Co-authored-by: Cursor <cursoragent@cursor.com>
466 lines
No EOL
16 KiB
TypeScript
466 lines
No EOL
16 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { useForm } from "react-hook-form"
|
|
import { CheckCircle, AlertCircle } from "lucide-react"
|
|
import Link from "next/link"
|
|
|
|
import { FormInput } from "./form-input"
|
|
import { FormButton } from "./form-button"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Label } from "@/components/ui/label"
|
|
import { WebhookClient } from "@/lib/webhook-client"
|
|
|
|
interface RequestMachineFormData {
|
|
firstName: string
|
|
lastName: string
|
|
email: string
|
|
phone: string
|
|
company: string
|
|
employeeCount: string
|
|
machineType: string
|
|
machineCount: string
|
|
message?: string
|
|
marketingConsent: boolean
|
|
termsAgreement: boolean
|
|
source?: string
|
|
page?: string
|
|
}
|
|
|
|
interface RequestMachineFormProps {
|
|
onSubmit?: (data: RequestMachineFormData) => void
|
|
className?: string
|
|
}
|
|
|
|
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: "",
|
|
marketingConsent: false,
|
|
termsAgreement: false,
|
|
},
|
|
})
|
|
|
|
const watchedValues = watch()
|
|
|
|
// Update page URL when component mounts
|
|
useState(() => {
|
|
if (typeof window !== 'undefined') {
|
|
setValue("page", window.location.pathname)
|
|
}
|
|
})
|
|
|
|
const onFormSubmit = async (data: RequestMachineFormData) => {
|
|
setIsSubmitting(true)
|
|
setSubmitError(null)
|
|
|
|
try {
|
|
// Add URL and timestamp
|
|
const formData = {
|
|
...data,
|
|
timestamp: new Date().toISOString(),
|
|
url: window.location.href,
|
|
}
|
|
|
|
// Call the webhook client directly
|
|
const result = await WebhookClient.submitRequestMachineForm(formData)
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || "Failed to submit form")
|
|
}
|
|
|
|
setIsSubmitted(true)
|
|
reset()
|
|
|
|
// Call custom onSubmit handler if provided
|
|
if (onSubmit) {
|
|
onSubmit(formData)
|
|
}
|
|
} catch (error) {
|
|
console.error("Form submission error:", error)
|
|
setSubmitError("Failed to submit form. Please try again.")
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const onFormReset = () => {
|
|
setIsSubmitted(false)
|
|
setSubmitError(null)
|
|
reset()
|
|
}
|
|
|
|
if (isSubmitted) {
|
|
return (
|
|
<div className={`space-y-4 text-center ${className}`}>
|
|
<div className="flex justify-center">
|
|
<CheckCircle className="h-12 w-12 text-green-500" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold">Thank You!</h3>
|
|
<p className="text-muted-foreground">
|
|
We've received your request and will call you within 24 hours to discuss your vending machine needs.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={onFormReset}
|
|
className="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
|
>
|
|
Request Another Machine
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onFormSubmit)} className={`space-y-4 ${className}`}>
|
|
{/* Honeypot field for spam prevention */}
|
|
<div className="sr-only">
|
|
<label htmlFor="confirm-email">
|
|
Confirm Email (leave empty)
|
|
</label>
|
|
<input
|
|
id="confirm-email"
|
|
type="email"
|
|
{...register("confirmEmail", {
|
|
shouldTouch: true,
|
|
})}
|
|
className="sr-only"
|
|
tabIndex={-1}
|
|
/>
|
|
</div>
|
|
|
|
{/* Name Fields */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<FormInput
|
|
id="firstName"
|
|
label="First Name"
|
|
placeholder="John"
|
|
error={errors.firstName?.message}
|
|
{...register("firstName", {
|
|
required: "First name is required",
|
|
})}
|
|
/>
|
|
<FormInput
|
|
id="lastName"
|
|
label="Last Name"
|
|
placeholder="Doe"
|
|
error={errors.lastName?.message}
|
|
{...register("lastName", {
|
|
required: "Last name is required",
|
|
})}
|
|
/>
|
|
</div>
|
|
|
|
{/* Contact Information */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<FormInput
|
|
id="email"
|
|
type="email"
|
|
label="Email"
|
|
placeholder="john@example.com"
|
|
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="phone"
|
|
type="tel"
|
|
inputMode="tel"
|
|
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}|[0-9]{10}"
|
|
label="Phone"
|
|
placeholder="(435) 233-9668"
|
|
error={errors.phone?.message}
|
|
{...register("phone", {
|
|
required: "Phone number is required",
|
|
validate: (value) => {
|
|
// Remove all non-digit characters
|
|
const phoneDigits = value.replace(/\D/g, '')
|
|
return phoneDigits.length >= 10 && phoneDigits.length <= 15 || "Please enter a valid phone number (10-15 digits)"
|
|
},
|
|
})}
|
|
/>
|
|
</div>
|
|
|
|
{/* Company Information */}
|
|
<FormInput
|
|
id="company"
|
|
label="Company/Business Name"
|
|
placeholder="Your Company Name"
|
|
error={errors.company?.message}
|
|
{...register("company", {
|
|
required: "Company name is required",
|
|
})}
|
|
/>
|
|
|
|
{/* Employee Count */}
|
|
<FormInput
|
|
id="employeeCount"
|
|
type="number"
|
|
min="1"
|
|
label="Number of People/Employees"
|
|
placeholder="50"
|
|
helperText="Approximate number of people at your location"
|
|
error={errors.employeeCount?.message}
|
|
{...register("employeeCount", {
|
|
required: "Number of people is required",
|
|
valueAsNumber: true,
|
|
validate: (value) => {
|
|
const num = parseInt(value)
|
|
return num >= 1 && num <= 10000 || "Please enter a valid number between 1 and 10,000"
|
|
},
|
|
})}
|
|
/>
|
|
|
|
{/* Marketing Consent */}
|
|
<div className="flex items-start space-x-1.5 pt-1">
|
|
<Checkbox
|
|
id="marketingConsent"
|
|
checked={watchedValues.marketingConsent || false}
|
|
onCheckedChange={(checked) => setValue("marketingConsent", !!checked)}
|
|
className="mt-0.5"
|
|
/>
|
|
<Label htmlFor="marketingConsent" className="text-xs text-muted-foreground leading-relaxed">
|
|
I consent to receive marketing communications from Rocky Mountain Vending via email and phone. I can unsubscribe at any time.
|
|
</Label>
|
|
</div>
|
|
{errors.marketingConsent && (
|
|
<p className="text-xs text-destructive pt-1">{errors.marketingConsent.message}</p>
|
|
)}
|
|
|
|
{/* Terms Agreement */}
|
|
<div className="flex items-start space-x-1.5 pt-1">
|
|
<Checkbox
|
|
id="termsAgreement"
|
|
checked={watchedValues.termsAgreement || false}
|
|
onCheckedChange={(checked) => setValue("termsAgreement", !!checked)}
|
|
className="mt-0.5"
|
|
/>
|
|
<Label htmlFor="termsAgreement" className="text-xs text-muted-foreground leading-relaxed">
|
|
I agree to the <Link href="/terms-and-conditions" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Terms and Conditions</Link>.
|
|
</Label>
|
|
</div>
|
|
{errors.termsAgreement && (
|
|
<p className="text-xs text-destructive pt-1">{errors.termsAgreement.message}</p>
|
|
)}
|
|
|
|
{/* Hidden form fields for validation */}
|
|
<input
|
|
type="hidden"
|
|
{...register("marketingConsent", {
|
|
required: "You must consent to marketing communications",
|
|
})}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
{...register("termsAgreement", {
|
|
required: "You must agree to the Terms and Conditions",
|
|
})}
|
|
/>
|
|
|
|
{/* Machine Type */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">
|
|
Type of Vending Machine Needed (select all that apply)
|
|
</Label>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="machineType-snack"
|
|
checked={watchedValues.machineType?.includes('snack') || false}
|
|
onCheckedChange={(checked) => {
|
|
const currentTypes = watchedValues.machineType ? watchedValues.machineType.split(',').filter(Boolean) : []
|
|
const newTypes = checked
|
|
? [...currentTypes, 'snack']
|
|
: currentTypes.filter(type => type !== 'snack')
|
|
setValue("machineType", newTypes.join(','))
|
|
}}
|
|
/>
|
|
<Label htmlFor="machineType-snack" className="text-sm">Snack Vending Machine</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="machineType-beverage"
|
|
checked={watchedValues.machineType?.includes('beverage') || false}
|
|
onCheckedChange={(checked) => {
|
|
const currentTypes = watchedValues.machineType ? watchedValues.machineType.split(',').filter(Boolean) : []
|
|
const newTypes = checked
|
|
? [...currentTypes, 'beverage']
|
|
: currentTypes.filter(type => type !== 'beverage')
|
|
setValue("machineType", newTypes.join(','))
|
|
}}
|
|
/>
|
|
<Label htmlFor="machineType-beverage" className="text-sm">Beverage Vending Machine</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="machineType-combo"
|
|
checked={watchedValues.machineType?.includes('combo') || false}
|
|
onCheckedChange={(checked) => {
|
|
const currentTypes = watchedValues.machineType ? watchedValues.machineType.split(',').filter(Boolean) : []
|
|
const newTypes = checked
|
|
? [...currentTypes, 'combo']
|
|
: currentTypes.filter(type => type !== 'combo')
|
|
setValue("machineType", newTypes.join(','))
|
|
}}
|
|
/>
|
|
<Label htmlFor="machineType-combo" className="text-sm">Combo/Snack & Beverage</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="machineType-cold-food"
|
|
checked={watchedValues.machineType?.includes('cold-food') || false}
|
|
onCheckedChange={(checked) => {
|
|
const currentTypes = watchedValues.machineType ? watchedValues.machineType.split(',').filter(Boolean) : []
|
|
const newTypes = checked
|
|
? [...currentTypes, 'cold-food']
|
|
: currentTypes.filter(type => type !== 'cold-food')
|
|
setValue("machineType", newTypes.join(','))
|
|
}}
|
|
/>
|
|
<Label htmlFor="machineType-cold-food" className="text-sm">Cold Food Vending Machine</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="machineType-hot-food"
|
|
checked={watchedValues.machineType?.includes('hot-food') || false}
|
|
onCheckedChange={(checked) => {
|
|
const currentTypes = watchedValues.machineType ? watchedValues.machineType.split(',').filter(Boolean) : []
|
|
const newTypes = checked
|
|
? [...currentTypes, 'hot-food']
|
|
: currentTypes.filter(type => type !== 'hot-food')
|
|
setValue("machineType", newTypes.join(','))
|
|
}}
|
|
/>
|
|
<Label htmlFor="machineType-hot-food" className="text-sm">Hot Food Vending Machine</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="machineType-coffee"
|
|
checked={watchedValues.machineType?.includes('coffee') || false}
|
|
onCheckedChange={(checked) => {
|
|
const currentTypes = watchedValues.machineType ? watchedValues.machineType.split(',').filter(Boolean) : []
|
|
const newTypes = checked
|
|
? [...currentTypes, 'coffee']
|
|
: currentTypes.filter(type => type !== 'coffee')
|
|
setValue("machineType", newTypes.join(','))
|
|
}}
|
|
/>
|
|
<Label htmlFor="machineType-coffee" className="text-sm">Coffee Vending Machine</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="machineType-micro-market"
|
|
checked={watchedValues.machineType?.includes('micro-market') || false}
|
|
onCheckedChange={(checked) => {
|
|
const currentTypes = watchedValues.machineType ? watchedValues.machineType.split(',').filter(Boolean) : []
|
|
const newTypes = checked
|
|
? [...currentTypes, 'micro-market']
|
|
: currentTypes.filter(type => type !== 'micro-market')
|
|
setValue("machineType", newTypes.join(','))
|
|
}}
|
|
/>
|
|
<Label htmlFor="machineType-micro-market" className="text-sm">Micro Market</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="machineType-other"
|
|
checked={watchedValues.machineType?.includes('other') || false}
|
|
onCheckedChange={(checked) => {
|
|
const currentTypes = watchedValues.machineType ? watchedValues.machineType.split(',').filter(Boolean) : []
|
|
const newTypes = checked
|
|
? [...currentTypes, 'other']
|
|
: currentTypes.filter(type => type !== 'other')
|
|
setValue("machineType", newTypes.join(','))
|
|
}}
|
|
/>
|
|
<Label htmlFor="machineType-other" className="text-sm">Other (specify below)</Label>
|
|
</div>
|
|
</div>
|
|
{errors.machineType && (
|
|
<p className="text-sm text-destructive">{errors.machineType.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Machine Count */}
|
|
<FormInput
|
|
id="machineCount"
|
|
type="number"
|
|
min="1"
|
|
max="100"
|
|
label="Number of Machines"
|
|
placeholder="1"
|
|
error={errors.machineCount?.message}
|
|
{...register("machineCount", {
|
|
required: "Number of machines is required",
|
|
valueAsNumber: true,
|
|
validate: (value) => {
|
|
const num = parseInt(value)
|
|
return num >= 1 && num <= 100 || "Please enter a number between 1 and 100"
|
|
},
|
|
})}
|
|
/>
|
|
|
|
{/* Optional Message */}
|
|
<FormInput
|
|
id="message"
|
|
label="Additional Information (Optional)"
|
|
placeholder="Any specific requirements or questions?"
|
|
error={errors.message?.message}
|
|
{...register("message")}
|
|
/>
|
|
|
|
{/* Submit Error */}
|
|
{submitError && (
|
|
<div className="flex items-center gap-2 text-destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<span className="text-sm">{submitError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit Button */}
|
|
<FormButton
|
|
type="submit"
|
|
className="w-full"
|
|
loading={isSubmitting}
|
|
size="lg"
|
|
>
|
|
{isSubmitting ? "Submitting..." : "Get Your FREE Consultation"}
|
|
</FormButton>
|
|
|
|
<input
|
|
type="hidden"
|
|
{...register("source")}
|
|
value={watchedValues.source || "website"}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
{...register("page")}
|
|
value={watchedValues.page || ""}
|
|
/>
|
|
</form>
|
|
)
|
|
} |