deploy: add Jessica chat and A2P consent forms
This commit is contained in:
parent
51dc972ac7
commit
0ff33f82fb
24 changed files with 2464 additions and 1192 deletions
354
app/api/chat/route.ts
Normal file
354
app/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { NextResponse, type NextRequest } from "next/server"
|
||||
import {
|
||||
SITE_CHAT_MAX_HISTORY_MESSAGES,
|
||||
SITE_CHAT_MAX_INPUT_CHARS,
|
||||
SITE_CHAT_MAX_MESSAGE_CHARS,
|
||||
SITE_CHAT_MAX_OUTPUT_CHARS,
|
||||
SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW,
|
||||
SITE_CHAT_MAX_OUTPUT_TOKENS,
|
||||
SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW,
|
||||
SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW,
|
||||
SITE_CHAT_MODEL,
|
||||
SITE_CHAT_OUTPUT_WINDOW_MS,
|
||||
SITE_CHAT_REQUEST_WINDOW_MS,
|
||||
SITE_CHAT_SESSION_COOKIE,
|
||||
SITE_CHAT_SOURCE,
|
||||
SITE_CHAT_TEMPERATURE,
|
||||
isSiteChatSuppressedRoute,
|
||||
} from "@/lib/site-chat/config"
|
||||
import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt"
|
||||
import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus } from "@/lib/site-chat/rate-limit"
|
||||
import { createSmsConsentPayload } from "@/lib/sms-compliance"
|
||||
|
||||
type ChatRole = "user" | "assistant"
|
||||
|
||||
type ChatMessage = {
|
||||
role: ChatRole
|
||||
content: string
|
||||
}
|
||||
|
||||
type ChatRequestBody = {
|
||||
messages?: ChatMessage[]
|
||||
pathname?: string
|
||||
sessionId?: string
|
||||
visitor?: {
|
||||
consentCapturedAt?: string
|
||||
consentSourcePage?: string
|
||||
consentVersion?: string
|
||||
email?: string
|
||||
intent?: string
|
||||
marketingTextConsent?: boolean
|
||||
name?: string
|
||||
phone?: string
|
||||
serviceTextConsent?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type ChatVisitorProfile = {
|
||||
consentCapturedAt: string
|
||||
consentSourcePage: string
|
||||
consentVersion: string
|
||||
email: string
|
||||
intent: string
|
||||
marketingTextConsent: boolean
|
||||
name: string
|
||||
phone: string
|
||||
serviceTextConsent: boolean
|
||||
}
|
||||
|
||||
function readRequiredEnv(name: string) {
|
||||
const value = process.env[name]
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required site chat environment variable: ${name}`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function getClientIp(request: NextRequest) {
|
||||
const forwardedFor = request.headers.get("x-forwarded-for")
|
||||
if (forwardedFor) {
|
||||
return forwardedFor.split(",")[0]?.trim() || "unknown"
|
||||
}
|
||||
|
||||
return request.headers.get("x-real-ip") || "unknown"
|
||||
}
|
||||
|
||||
function normalizeSessionId(rawSessionId: string | undefined | null) {
|
||||
const value = (rawSessionId || "").trim()
|
||||
if (!value) {
|
||||
return randomUUID()
|
||||
}
|
||||
|
||||
return value.slice(0, 120)
|
||||
}
|
||||
|
||||
function normalizePathname(rawPathname: string | undefined) {
|
||||
const pathname = typeof rawPathname === "string" && rawPathname.trim() ? rawPathname.trim() : "/"
|
||||
return pathname.startsWith("/") ? pathname : `/${pathname}`
|
||||
}
|
||||
|
||||
function normalizeMessages(messages: ChatMessage[] | undefined) {
|
||||
const safeMessages = Array.isArray(messages) ? messages : []
|
||||
|
||||
return safeMessages
|
||||
.filter((message) => message && (message.role === "user" || message.role === "assistant"))
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "").replace(/\s+/g, " ").trim().slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
|
||||
}))
|
||||
.filter((message) => message.content.length > 0)
|
||||
.slice(-SITE_CHAT_MAX_HISTORY_MESSAGES)
|
||||
}
|
||||
|
||||
function normalizeVisitorProfile(rawVisitor: ChatRequestBody["visitor"], pathname: string): ChatVisitorProfile | null {
|
||||
if (!rawVisitor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = String(rawVisitor.name || "").replace(/\s+/g, " ").trim().slice(0, 80)
|
||||
const phone = String(rawVisitor.phone || "").replace(/\s+/g, " ").trim().slice(0, 40)
|
||||
const email = String(rawVisitor.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase()
|
||||
const intent = String(rawVisitor.intent || "").replace(/\s+/g, " ").trim().slice(0, 80)
|
||||
|
||||
if (!name || !phone || !email || !intent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const digits = phone.replace(/\D/g, "")
|
||||
const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
if (digits.length < 10 || !emailIsValid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const consentPayload = createSmsConsentPayload({
|
||||
consentCapturedAt: rawVisitor.consentCapturedAt,
|
||||
consentSourcePage: rawVisitor.consentSourcePage || pathname,
|
||||
consentVersion: rawVisitor.consentVersion,
|
||||
marketingTextConsent: rawVisitor.marketingTextConsent,
|
||||
serviceTextConsent: rawVisitor.serviceTextConsent,
|
||||
})
|
||||
|
||||
if (!consentPayload.serviceTextConsent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
intent,
|
||||
name,
|
||||
phone,
|
||||
...consentPayload,
|
||||
}
|
||||
}
|
||||
|
||||
function truncateOutput(text: string) {
|
||||
const clean = text.replace(/\s+/g, " ").trim()
|
||||
if (clean.length <= SITE_CHAT_MAX_OUTPUT_CHARS) {
|
||||
return clean
|
||||
}
|
||||
|
||||
return `${clean.slice(0, SITE_CHAT_MAX_OUTPUT_CHARS - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
function extractAssistantText(data: any) {
|
||||
const messageContent = data?.choices?.[0]?.message?.content
|
||||
|
||||
if (typeof messageContent === "string") {
|
||||
return messageContent
|
||||
}
|
||||
|
||||
if (Array.isArray(messageContent)) {
|
||||
return messageContent
|
||||
.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item
|
||||
}
|
||||
|
||||
if (item && typeof item.text === "string") {
|
||||
return item.text
|
||||
}
|
||||
|
||||
if (item && typeof item.content === "string") {
|
||||
return item.content
|
||||
}
|
||||
|
||||
return ""
|
||||
})
|
||||
.join(" ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const responseHeaders: Record<string, string> = {
|
||||
"Cache-Control": "no-store",
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as ChatRequestBody
|
||||
const pathname = normalizePathname(body.pathname)
|
||||
const visitor = normalizeVisitorProfile(body.visitor, pathname)
|
||||
|
||||
if (isSiteChatSuppressedRoute(pathname)) {
|
||||
return NextResponse.json({ error: "Chat is not available on this route." }, { status: 403, headers: responseHeaders })
|
||||
}
|
||||
|
||||
if (!visitor) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Name, phone, email, intent, and required service SMS consent are needed to start chat.",
|
||||
},
|
||||
{ status: 400, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
const sessionId = normalizeSessionId(body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value)
|
||||
const ip = getClientIp(request)
|
||||
const messages = normalizeMessages(body.messages)
|
||||
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user")
|
||||
|
||||
if (!latestUserMessage) {
|
||||
return NextResponse.json({ error: "A user message is required.", sessionId }, { status: 400, headers: responseHeaders })
|
||||
}
|
||||
|
||||
if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`,
|
||||
sessionId,
|
||||
},
|
||||
{ status: 400, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
const limitStatus = getChatRateLimitStatus({
|
||||
ip,
|
||||
maxIpRequests: SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW,
|
||||
maxSessionRequests: SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW,
|
||||
maxSessionOutputChars: SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW,
|
||||
outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS,
|
||||
requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
if (limitStatus.blocked) {
|
||||
const blockedResponse = NextResponse.json(
|
||||
{
|
||||
error: "Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
|
||||
sessionId,
|
||||
limits: limitStatus,
|
||||
},
|
||||
{ status: 429, headers: responseHeaders },
|
||||
)
|
||||
|
||||
blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
||||
return blockedResponse
|
||||
}
|
||||
|
||||
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
|
||||
|
||||
const xaiApiKey = readRequiredEnv("XAI_API_KEY")
|
||||
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${xaiApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: SITE_CHAT_MODEL,
|
||||
temperature: SITE_CHAT_TEMPERATURE,
|
||||
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `${SITE_CHAT_SYSTEM_PROMPT}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
|
||||
},
|
||||
...messages,
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const completionData = await completionResponse.json().catch(() => ({}))
|
||||
|
||||
if (!completionResponse.ok) {
|
||||
console.error("[site-chat] xAI completion failed", {
|
||||
status: completionResponse.status,
|
||||
pathname,
|
||||
sessionId,
|
||||
completionData,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Jessica is having trouble replying right now. Please try again or call us directly.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 502, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
const assistantReply = truncateOutput(extractAssistantText(completionData))
|
||||
|
||||
if (!assistantReply) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Jessica did not return a usable reply. Please try again.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 502, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
consumeChatOutput({ chars: assistantReply.length, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, sessionId })
|
||||
|
||||
const nextLimitStatus = getChatRateLimitStatus({
|
||||
ip,
|
||||
maxIpRequests: SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW,
|
||||
maxSessionRequests: SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW,
|
||||
maxSessionOutputChars: SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW,
|
||||
outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS,
|
||||
requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
reply: assistantReply,
|
||||
sessionId,
|
||||
limits: nextLimitStatus,
|
||||
},
|
||||
{ headers: responseHeaders },
|
||||
)
|
||||
|
||||
response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[site-chat] request failed", error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Chat failed unexpectedly.",
|
||||
},
|
||||
{ status: 500, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,13 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
|
|||
email: "john@example.com",
|
||||
phone: "(435) 555-1212",
|
||||
company: "ACME",
|
||||
intent: "Repairs",
|
||||
message: "Need vending help for our office.",
|
||||
serviceTextConsent: true,
|
||||
marketingTextConsent: false,
|
||||
consentVersion: "sms-consent-v1-2026-03-26",
|
||||
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
||||
consentSourcePage: "/contact-us",
|
||||
source: "website",
|
||||
page: "/contact",
|
||||
timestamp: "2026-03-25T00:00:00.000Z",
|
||||
|
|
@ -73,8 +79,11 @@ test("processLeadSubmission validates request-machine submissions", async () =>
|
|||
employeeCount: "0",
|
||||
machineType: "snack",
|
||||
machineCount: "2",
|
||||
marketingConsent: true,
|
||||
termsAgreement: true,
|
||||
serviceTextConsent: true,
|
||||
marketingTextConsent: false,
|
||||
consentVersion: "sms-consent-v1-2026-03-26",
|
||||
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
||||
consentSourcePage: "/",
|
||||
};
|
||||
|
||||
const result = await processLeadSubmission(payload, "rmv.example", {
|
||||
|
|
@ -112,6 +121,11 @@ test("processLeadSubmission returns deduped success when Convex already has the
|
|||
email: "john@example.com",
|
||||
phone: "(435) 555-1212",
|
||||
message: "Need vending help for our office.",
|
||||
serviceTextConsent: true,
|
||||
marketingTextConsent: false,
|
||||
consentVersion: "sms-consent-v1-2026-03-26",
|
||||
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
||||
consentSourcePage: "/contact-us",
|
||||
};
|
||||
|
||||
const result = await processLeadSubmission(payload, "rmv.example", {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
--popover-foreground: oklch(0.178 0.014 275.627);
|
||||
--primary: #29A047; /* Primary brand color (green from logo) */
|
||||
--primary-foreground: oklch(0.989 0.003 106.423);
|
||||
--primary-dark: #1d7a35; /* Darker primary for gradients and hover states */
|
||||
--secondary: #54595F; /* Secondary color (gray) */
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.961 0.004 106.423);
|
||||
|
|
@ -314,24 +315,6 @@
|
|||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Chat Widget Styling - Ensure it doesn't interfere with header */
|
||||
/* Target common chat widget containers */
|
||||
[id*="chat-widget"],
|
||||
[id*="leadconnector"],
|
||||
[class*="chat-widget"],
|
||||
[class*="lc-widget"],
|
||||
iframe[src*="leadconnector"],
|
||||
iframe[src*="chat-widget"] {
|
||||
z-index: 30 !important;
|
||||
}
|
||||
|
||||
/* Ensure chat widget buttons/containers are below header but above content */
|
||||
body > div > [id*="chat"],
|
||||
body > div > [class*="chat"] {
|
||||
z-index: 30 !important;
|
||||
position: fixed !important;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for horizontal scrolling galleries */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Inter, Geist_Mono } from "next/font/google"
|
||||
import Script from "next/script"
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { Header } from "@/components/header"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { StructuredData } from "@/components/structured-data"
|
||||
import { OrganizationSchema } from "@/components/organization-schema"
|
||||
import { SiteChatWidget } from "@/components/site-chat-widget"
|
||||
import { CartProvider } from "@/lib/cart/context"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import "./globals.css"
|
||||
|
|
@ -115,37 +115,28 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en" className={`${inter.variable} ${geistMono.variable}`}>
|
||||
<head>
|
||||
{/* Resource hints for third-party domains */}
|
||||
<link rel="preconnect" href="https://beta.leadconnectorhq.com" />
|
||||
<link rel="dns-prefetch" href="https://beta.leadconnectorhq.com" />
|
||||
<StructuredData />
|
||||
<OrganizationSchema />
|
||||
</head>
|
||||
<body className="font-sans antialiased">
|
||||
<CartProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Skip to main content link for keyboard users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:border-primary focus:rounded-md focus:text-primary focus:font-medium"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<Header />
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</CartProvider>
|
||||
{/* Third-party scripts - loaded after page becomes interactive to avoid blocking render */}
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Skip to main content link for keyboard users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:border-primary focus:rounded-md focus:text-primary focus:font-medium"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<Header />
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<SiteChatWidget />
|
||||
</CartProvider>
|
||||
<Analytics />
|
||||
<Script
|
||||
src="https://beta.leadconnectorhq.com/loader.js"
|
||||
data-resources-url="https://beta.leadconnectorhq.com/chat-widget/loader.js"
|
||||
data-widget-id="679bb5e2ce087f8626e06144"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
57
components/assistant-avatar.tsx
Normal file
57
components/assistant-avatar.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DEFAULT_AVATAR_SRC = "/images/jessica-avatar.jpg"
|
||||
|
||||
type AssistantAvatarProps = {
|
||||
alt?: string
|
||||
className?: string
|
||||
sizeClassName?: string
|
||||
src?: string
|
||||
}
|
||||
|
||||
export function AssistantAvatar({
|
||||
alt = "Jessica",
|
||||
className,
|
||||
sizeClassName = "h-10 w-10",
|
||||
src = DEFAULT_AVATAR_SRC,
|
||||
}: AssistantAvatarProps) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex items-center justify-center overflow-hidden rounded-full border border-white/60 bg-gradient-to-br from-stone-200 via-amber-50 to-stone-300 font-semibold text-stone-700 shadow-sm",
|
||||
sizeClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
J
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-full border border-white/60 bg-stone-100 shadow-sm",
|
||||
sizeClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes="64px"
|
||||
className="object-cover"
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
'use client'
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Phone, Mail, Clock } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Clock, Mail, Phone } from "lucide-react"
|
||||
import { ContactForm } from "@/components/forms/contact-form"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
export function ContactPage() {
|
||||
|
||||
const businessHours = [
|
||||
{ day: "Sunday", hours: "Closed", isClosed: true },
|
||||
{ day: "Monday", hours: "8:00 AM to 5:00 PM", isClosed: false },
|
||||
|
|
@ -19,139 +17,90 @@ export function ContactPage() {
|
|||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-12 max-w-6xl">
|
||||
{/* Header */}
|
||||
<header className="text-center mb-12 md:mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">Contact Us</h1>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[var(--link-hover-color)] to-[var(--link-hover-color-dark)] mx-auto rounded-full" />
|
||||
<div className="container mx-auto max-w-6xl px-4 py-10 md:py-14">
|
||||
<header className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary/80">Contact Rocky Mountain Vending</p>
|
||||
<h1 className="mt-3 text-4xl font-bold tracking-tight text-balance md:text-5xl">Tell us what you need and we'll point you to the right team.</h1>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||
Use the form for repairs, moving, manuals, machine sales, or general questions. If you'd rather talk now, call us during business hours.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Main Content - Form and Contact Info */}
|
||||
<div className="grid gap-8 md:grid-cols-2 mb-16">
|
||||
{/* Left Column - Contact Form */}
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-6 text-center md:text-left tracking-tight text-balance">
|
||||
Get in Touch with Us
|
||||
</h2>
|
||||
<Card className="border-border/50 shadow-lg transition-colors">
|
||||
<CardContent className="p-0 min-h-[738px]">
|
||||
<div className="p-6">
|
||||
<ContactForm onSubmit={(data) => console.log('Contact form submitted:', data)} />
|
||||
<div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)] lg:items-start">
|
||||
<section id="contact-form" className="rounded-[2rem] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.92),rgba(249,247,242,0.92))] p-5 shadow-[0_24px_60px_rgba(0,0,0,0.08)] md:p-7">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Contact Form
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">For repairs or moving, include the machine model and a clear description of what's happening.</p>
|
||||
</div>
|
||||
<ContactForm onSubmit={(data) => console.log("Contact form submitted:", data)} />
|
||||
</section>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<Card className="overflow-hidden rounded-[2rem] border-border/70 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
|
||||
<CardContent className="bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.16),transparent_55%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(247,244,236,0.98))] p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">Direct Options</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-foreground">Reach the team directly</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
We monitor calls, texts, and email throughout the business day. If you're sending repair photos or videos, text them to the number below.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<a href={businessConfig.publicCallUrl} className="flex items-start gap-4 rounded-2xl border border-border/60 bg-background/85 px-4 py-4 transition hover:border-primary/35 hover:bg-primary/[0.04]">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Phone className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Call</p>
|
||||
<p className="mt-1 text-base font-medium text-foreground">{businessConfig.publicCallNumber}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Best for immediate questions during business hours.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`} className="flex items-start gap-4 rounded-2xl border border-border/60 bg-background/85 px-4 py-4 transition hover:border-primary/35 hover:bg-primary/[0.04]">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Mail className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Email</p>
|
||||
<p className="mt-1 break-all text-base font-medium text-foreground">{businessConfig.email}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Good for longer requests and supporting details.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Contact Information */}
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-6 text-center md:text-left tracking-tight text-balance">
|
||||
Contact Information
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-md">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-secondary/10 flex items-center justify-center">
|
||||
<Phone className="w-6 h-6 text-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">Phone</h3>
|
||||
<Link
|
||||
href="tel:+14352339668"
|
||||
className="hover:underline text-lg font-medium"
|
||||
>
|
||||
(435) 233-9668
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Call us during business hours
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-md">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-secondary/10 flex items-center justify-center">
|
||||
<Mail className="w-6 h-6 text-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">Email</h3>
|
||||
<Link
|
||||
href="mailto:info@rockymountainvending.com?Subject=I%20Need%20More%20Information"
|
||||
className="hover:underline text-lg font-medium break-all"
|
||||
>
|
||||
info@rockymountainvending.com
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Send us an email anytime
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours of Operation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-8 text-center tracking-tight text-balance">
|
||||
Hours of Operation
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 max-w-4xl mx-auto">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-lg">
|
||||
<Card className="rounded-[2rem] border-border/70 shadow-[0_18px_45px_rgba(0,0,0,0.06)]">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-secondary" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">Business Hours</p>
|
||||
<h2 className="text-xl font-semibold text-foreground">When we're available</h2>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">Business Hours</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{businessHours.map((schedule, index) => (
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
{businessHours.map((schedule) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex justify-between items-center py-2 px-3 rounded-lg transition-colors ${
|
||||
schedule.isClosed
|
||||
? 'bg-muted/50 text-muted-foreground'
|
||||
: 'bg-secondary/5 hover:bg-secondary/10'
|
||||
key={schedule.day}
|
||||
className={`flex items-center justify-between rounded-xl px-3 py-2 ${
|
||||
schedule.isClosed ? "bg-muted/55 text-muted-foreground" : "bg-primary/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{schedule.day}</span>
|
||||
<span className={schedule.isClosed ? 'font-normal' : 'font-semibold text-secondary'}>
|
||||
{schedule.hours}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{schedule.day}</span>
|
||||
<span className={schedule.isClosed ? "text-sm" : "text-sm font-semibold text-primary"}>{schedule.hours}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Additional Info Card */}
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-lg bg-secondary/5">
|
||||
<CardContent className="p-6 flex flex-col justify-center h-full">
|
||||
<h3 className="text-xl font-semibold mb-2">Need Help Outside Business Hours?</h3>
|
||||
<p className="text-muted-foreground mb-4 leading-relaxed">
|
||||
While our office hours are Monday through Friday, 8:00 AM to 5:00 PM, we understand that vending machine issues can happen anytime. For urgent matters, please leave us a message or send an email, and we'll get back to you as soon as possible.
|
||||
</p>
|
||||
<div className="mt-4 p-4 bg-card rounded-lg border border-border">
|
||||
<p className="text-sm font-semibold mb-2">Quick Response Options:</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||
<li>Text us at (435) 233-9668</li>
|
||||
<li>Use the chat widget on our website</li>
|
||||
<li>Submit the contact form above</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { CheckCircle, AlertCircle } from "lucide-react"
|
||||
import { AlertCircle, CheckCircle, MessageSquare, PhoneCall, Wrench } from "lucide-react"
|
||||
|
||||
import { FormInput } from "./form-input"
|
||||
import { FormTextarea } from "./form-textarea"
|
||||
import { FormButton } from "./form-button"
|
||||
import { WebhookClient } from "@/lib/webhook-client"
|
||||
import { FormInput } from "./form-input"
|
||||
import { FormSelect } from "./form-select"
|
||||
import { FormTextarea } from "./form-textarea"
|
||||
import { SmsConsentFields } from "./sms-consent-fields"
|
||||
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
|
||||
|
|
@ -15,27 +19,43 @@ interface ContactFormData {
|
|||
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
|
||||
confirmEmail?: string
|
||||
}
|
||||
|
||||
interface SubmittedContactFormData extends ContactFormData {
|
||||
timestamp: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface ContactFormProps {
|
||||
onSubmit?: (data: SubmittedContactFormData) => void
|
||||
onSubmit?: (data: ContactFormData) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SectionHeader({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">{eyebrow}</p>
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContactForm({ onSubmit, className }: 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,
|
||||
|
|
@ -47,46 +67,83 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
|
|||
defaultValues: {
|
||||
source: "website",
|
||||
page: "",
|
||||
intent: "",
|
||||
serviceTextConsent: false,
|
||||
marketingTextConsent: false,
|
||||
consentVersion: SMS_CONSENT_VERSION,
|
||||
consentCapturedAt: "",
|
||||
consentSourcePage: "",
|
||||
},
|
||||
})
|
||||
|
||||
const watchedValues = watch()
|
||||
const canSubmit = Boolean(watchedValues.serviceTextConsent)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setValue("page", window.location.pathname)
|
||||
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 : "",
|
||||
intent: "",
|
||||
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 {
|
||||
// Add URL and timestamp
|
||||
const formData: SubmittedContactFormData = {
|
||||
const consentCapturedAt = new Date().toISOString()
|
||||
const formData = {
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
consentCapturedAt,
|
||||
consentSourcePage: data.consentSourcePage || data.page || window.location.pathname,
|
||||
consentVersion: data.consentVersion || SMS_CONSENT_VERSION,
|
||||
timestamp: consentCapturedAt,
|
||||
url: window.location.href,
|
||||
}
|
||||
|
||||
// Call the webhook client directly
|
||||
const result = await WebhookClient.submitContactForm(formData)
|
||||
const response = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to submit form")
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || result.message || "Failed to submit form")
|
||||
}
|
||||
|
||||
setIsSubmitted(true)
|
||||
reset()
|
||||
|
||||
// Call custom onSubmit handler if provided
|
||||
reset(buildResetValues())
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(formData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Form submission error:", error)
|
||||
setSubmitError("Failed to submit form. Please try again.")
|
||||
setSubmitError("We couldn't send that right now. Please try again or call us.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
|
@ -95,155 +152,234 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
|
|||
const onFormReset = () => {
|
||||
setIsSubmitted(false)
|
||||
setSubmitError(null)
|
||||
reset()
|
||||
reset(buildResetValues())
|
||||
}
|
||||
|
||||
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 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="text-xl font-semibold">Thank You!</h3>
|
||||
<p className="text-muted-foreground">
|
||||
We've received your message and will get back to you within 24 hours.
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
Submit Another Message
|
||||
</button>
|
||||
<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-4 ${className}`}>
|
||||
{/* Honeypot field for spam prevention */}
|
||||
<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}
|
||||
/>
|
||||
<label htmlFor="confirm-email">Confirm Email (leave empty)</label>
|
||||
<input id="confirm-email" type="email" {...register("confirmEmail")} 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",
|
||||
})}
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
eyebrow="Step 1"
|
||||
title="How should we reach you?"
|
||||
description="Share your contact details so the right team member can follow up quickly."
|
||||
/>
|
||||
<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 (Optional) */}
|
||||
<FormInput
|
||||
id="company"
|
||||
label="Company (Optional)"
|
||||
placeholder="Your Company Name"
|
||||
error={errors.company?.message}
|
||||
{...register("company")}
|
||||
/>
|
||||
|
||||
{/* Message */}
|
||||
<FormTextarea
|
||||
id="message"
|
||||
label="Message"
|
||||
placeholder="Tell us more about your needs and we'll get back to you within 24 hours."
|
||||
error={errors.message?.message}
|
||||
{...register("message", {
|
||||
required: "Message is required",
|
||||
minLength: {
|
||||
value: 10,
|
||||
message: "Message must be at least 10 characters",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* 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 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>
|
||||
|
||||
{/* Submit Button */}
|
||||
<FormButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={isSubmitting}
|
||||
size="lg"
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send Message"}
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
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-primary/10 bg-primary/[0.04] 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-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
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-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
eyebrow="Step 4"
|
||||
title="Text updates"
|
||||
description="Required service consent covers scheduling, support, and follow-up texts for this request. Marketing texts stay separate and optional."
|
||||
/>
|
||||
<div className="mt-5">
|
||||
<SmsConsentFields
|
||||
idPrefix="contact"
|
||||
serviceChecked={Boolean(watchedValues.serviceTextConsent)}
|
||||
marketingChecked={Boolean(watchedValues.marketingTextConsent)}
|
||||
onServiceChange={(checked) => setValue("serviceTextConsent", checked, { shouldValidate: true })}
|
||||
onMarketingChange={(checked) => setValue("marketingTextConsent", checked, { shouldValidate: true })}
|
||||
serviceError={errors.serviceTextConsent?.message}
|
||||
marketingError={errors.marketingTextConsent?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-border/60 bg-muted/35 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 a faster handoff?</p>
|
||||
<p className="leading-relaxed">Call during business hours or send us a detailed request here and we'll take it from there.</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-background 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-background 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("source")}
|
||||
value={watchedValues.source || "website"}
|
||||
{...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("page")}
|
||||
value={watchedValues.page || ""}
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface FormInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface FormInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
|
|
@ -12,45 +11,38 @@ export interface FormInputProps
|
|||
|
||||
const FormInput = React.forwardRef<HTMLInputElement, FormInputProps>(
|
||||
({ className, type, label, error, helperText, id, ...props }, ref) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const generatedId = React.useId()
|
||||
const inputId = id || generatedId
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label ? (
|
||||
<label htmlFor={inputId} className="text-sm font-semibold leading-none text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<input
|
||||
id={inputId}
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow,border-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
error
|
||||
? "aria-invalid:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40"
|
||||
: "",
|
||||
"h-12 w-full min-w-0 rounded-xl border border-border/70 bg-background/85 px-4 text-base text-foreground shadow-sm transition outline-none",
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||
"focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
error ? "border-destructive focus:ring-destructive/10" : "",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="text-sm text-muted-foreground">{helperText}</p>
|
||||
)}
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{helperText && !error ? <p className="text-sm text-muted-foreground">{helperText}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
FormInput.displayName = "FormInput"
|
||||
|
||||
export { FormInput }
|
||||
export { FormInput }
|
||||
|
|
|
|||
61
components/forms/form-select.tsx
Normal file
61
components/forms/form-select.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface FormSelectOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface FormSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
options: FormSelectOption[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const FormSelect = React.forwardRef<HTMLSelectElement, FormSelectProps>(
|
||||
({ className, label, error, helperText, id, options, placeholder, ...props }, ref) => {
|
||||
const generatedId = React.useId()
|
||||
const selectId = id || generatedId
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label ? (
|
||||
<label htmlFor={selectId} className="text-sm font-semibold leading-none text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<select
|
||||
id={selectId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 w-full appearance-none rounded-xl border border-border/70 bg-background/85 px-4 pr-11 text-base text-foreground shadow-sm transition outline-none",
|
||||
"focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
error ? "border-destructive focus:ring-destructive/10" : "",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{placeholder ? <option value="">{placeholder}</option> : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{helperText && !error ? <p className="text-sm text-muted-foreground">{helperText}</p> : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
FormSelect.displayName = "FormSelect"
|
||||
|
||||
export { FormSelect }
|
||||
|
|
@ -3,8 +3,7 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface FormTextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
export interface FormTextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
|
|
@ -12,43 +11,37 @@ export interface FormTextareaProps
|
|||
|
||||
const FormTextarea = React.forwardRef<HTMLTextAreaElement, FormTextareaProps>(
|
||||
({ className, label, error, helperText, id, ...props }, ref) => {
|
||||
const textareaId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const generatedId = React.useId()
|
||||
const textareaId = id || generatedId
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label ? (
|
||||
<label htmlFor={textareaId} className="text-sm font-semibold leading-none text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id={textareaId}
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-[100px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow,border-color] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
error
|
||||
? "border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40"
|
||||
: "",
|
||||
"min-h-[136px] w-full rounded-xl border border-border/70 bg-background/85 px-4 py-3 text-base text-foreground shadow-sm transition outline-none",
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||
"focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
error ? "border-destructive focus:ring-destructive/10" : "",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="text-sm text-muted-foreground">{helperText}</p>
|
||||
)}
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{helperText && !error ? <p className="text-sm text-muted-foreground">{helperText}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
FormTextarea.displayName = "FormTextarea"
|
||||
|
||||
export { FormTextarea }
|
||||
export { FormTextarea }
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { CheckCircle, AlertCircle } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { AlertCircle, CheckCircle, ClipboardCheck, PhoneCall } from "lucide-react"
|
||||
|
||||
import { FormInput } from "./form-input"
|
||||
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 { Label } from "@/components/ui/label"
|
||||
import { WebhookClient } from "@/lib/webhook-client"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { SMS_CONSENT_VERSION } from "@/lib/sms-compliance"
|
||||
|
||||
interface RequestMachineFormData {
|
||||
firstName: string
|
||||
|
|
@ -21,23 +22,42 @@ interface RequestMachineFormData {
|
|||
machineType: string
|
||||
machineCount: string
|
||||
message?: string
|
||||
marketingConsent: boolean
|
||||
termsAgreement: boolean
|
||||
serviceTextConsent: boolean
|
||||
marketingTextConsent: boolean
|
||||
consentVersion: string
|
||||
consentCapturedAt: string
|
||||
consentSourcePage: string
|
||||
confirmEmail?: string
|
||||
source?: string
|
||||
page?: string
|
||||
confirmEmail?: string
|
||||
}
|
||||
|
||||
interface SubmittedRequestMachineFormData extends RequestMachineFormData {
|
||||
timestamp: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface RequestMachineFormProps {
|
||||
onSubmit?: (data: SubmittedRequestMachineFormData) => void
|
||||
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
|
||||
|
||||
function SectionHeader({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">{eyebrow}</p>
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RequestMachineForm({ onSubmit, className }: RequestMachineFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||||
|
|
@ -54,48 +74,97 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
|
|||
defaultValues: {
|
||||
source: "website",
|
||||
page: "",
|
||||
marketingConsent: false,
|
||||
termsAgreement: false,
|
||||
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') {
|
||||
setValue("page", window.location.pathname)
|
||||
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 {
|
||||
// Add URL and timestamp
|
||||
const formData: SubmittedRequestMachineFormData = {
|
||||
const consentCapturedAt = new Date().toISOString()
|
||||
const formData = {
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
consentCapturedAt,
|
||||
consentSourcePage: data.consentSourcePage || data.page || window.location.pathname,
|
||||
consentVersion: data.consentVersion || SMS_CONSENT_VERSION,
|
||||
timestamp: consentCapturedAt,
|
||||
url: window.location.href,
|
||||
}
|
||||
|
||||
// Call the webhook client directly
|
||||
const result = await WebhookClient.submitRequestMachineForm(formData)
|
||||
const response = await fetch("/api/request-machine", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to submit form")
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || result.message || "Failed to submit form")
|
||||
}
|
||||
|
||||
setIsSubmitted(true)
|
||||
reset()
|
||||
|
||||
// Call custom onSubmit handler if provided
|
||||
reset(buildResetValues())
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(formData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Form submission error:", error)
|
||||
setSubmitError("Failed to submit form. Please try again.")
|
||||
setSubmitError("We couldn't send that right now. Please try again or call us.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
|
@ -104,364 +173,254 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
|
|||
const onFormReset = () => {
|
||||
setIsSubmitted(false)
|
||||
setSubmitError(null)
|
||||
reset()
|
||||
reset(buildResetValues())
|
||||
}
|
||||
|
||||
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 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="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.
|
||||
<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>
|
||||
<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>
|
||||
<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-4 ${className}`}>
|
||||
{/* Honeypot field for spam prevention */}
|
||||
<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}
|
||||
/>
|
||||
<label htmlFor="confirm-email">Confirm Email (leave empty)</label>
|
||||
<input id="confirm-email" type="email" {...register("confirmEmail")} 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",
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
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-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
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-muted/25 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-background/90 px-4 py-3 transition hover:border-primary/35 hover:bg-primary/[0.04]"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
<FormInput
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
placeholder="Doe"
|
||||
error={errors.lastName?.message}
|
||||
{...register("lastName", {
|
||||
required: "Last name is required",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{errors.machineType ? <p className="mt-2 text-sm text-destructive">{errors.machineType.message}</p> : null}
|
||||
|
||||
{/* 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",
|
||||
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 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-primary/10 bg-primary/[0.04] 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" />
|
||||
Free placement intake
|
||||
</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>
|
||||
{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",
|
||||
validate: (value) => {
|
||||
const num = parseInt(value)
|
||||
return num >= 1 && num <= 100 || "Please enter a number between 1 and 100"
|
||||
},
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
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-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
eyebrow="Step 4"
|
||||
title="Text updates"
|
||||
description="Required service consent covers scheduling, installation planning, service, and follow-up texts for this request. Marketing texts stay separate and optional."
|
||||
/>
|
||||
<div className="mt-5">
|
||||
<SmsConsentFields
|
||||
idPrefix="request-machine"
|
||||
serviceChecked={Boolean(watchedValues.serviceTextConsent)}
|
||||
marketingChecked={Boolean(watchedValues.marketingTextConsent)}
|
||||
onServiceChange={(checked) => setValue("serviceTextConsent", checked, { shouldValidate: true })}
|
||||
onMarketingChange={(checked) => setValue("marketingTextConsent", checked, { shouldValidate: true })}
|
||||
serviceError={errors.serviceTextConsent?.message}
|
||||
marketingError={errors.marketingTextConsent?.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-background 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",
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* 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("marketingTextConsent")} />
|
||||
<input
|
||||
type="hidden"
|
||||
{...register("source")}
|
||||
value={watchedValues.source || "website"}
|
||||
{...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("page")}
|
||||
value={watchedValues.page || ""}
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
93
components/forms/sms-consent-fields.tsx
Normal file
93
components/forms/sms-consent-fields.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import Link from "next/link"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
function PolicyLinks() {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
Review our{" "}
|
||||
<Link
|
||||
href="/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-foreground underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{" "}and{" "}
|
||||
<Link
|
||||
href="/terms-and-conditions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-foreground underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Terms
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type SmsConsentFieldsProps = {
|
||||
idPrefix: string
|
||||
marketingChecked: boolean
|
||||
marketingError?: string
|
||||
onMarketingChange: (checked: boolean) => void
|
||||
onServiceChange: (checked: boolean) => void
|
||||
serviceChecked: boolean
|
||||
serviceError?: string
|
||||
}
|
||||
|
||||
export function SmsConsentFields({
|
||||
idPrefix,
|
||||
marketingChecked,
|
||||
marketingError,
|
||||
onMarketingChange,
|
||||
onServiceChange,
|
||||
serviceChecked,
|
||||
serviceError,
|
||||
}: SmsConsentFieldsProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-border/60 bg-background/90 px-4 py-3">
|
||||
<Checkbox
|
||||
id={`${idPrefix}-service-consent`}
|
||||
checked={serviceChecked}
|
||||
onCheckedChange={(checked) => onServiceChange(Boolean(checked))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-service-consent`}
|
||||
className="text-xs leading-relaxed text-muted-foreground"
|
||||
>
|
||||
I agree to receive conversational SMS from {businessConfig.legalName} about my inquiry, scheduling,
|
||||
support, repairs, moving, and follow-up. Message frequency varies. Message and data rates may apply.
|
||||
Reply STOP to opt out and HELP for help. Consent is not a condition of purchase.
|
||||
<PolicyLinks />
|
||||
</Label>
|
||||
</div>
|
||||
{serviceError ? <p className="text-xs text-destructive">{serviceError}</p> : null}
|
||||
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-border/60 bg-background/90 px-4 py-3">
|
||||
<Checkbox
|
||||
id={`${idPrefix}-marketing-consent`}
|
||||
checked={marketingChecked}
|
||||
onCheckedChange={(checked) => onMarketingChange(Boolean(checked))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-marketing-consent`}
|
||||
className="text-xs leading-relaxed text-muted-foreground"
|
||||
>
|
||||
I agree to receive promotional and marketing SMS from {businessConfig.legalName}. Message frequency
|
||||
varies. Message and data rates may apply. Reply STOP to opt out and HELP for help. Consent is not a
|
||||
condition of purchase.
|
||||
<PolicyLinks />
|
||||
</Label>
|
||||
</div>
|
||||
{marketingError ? <p className="text-xs text-destructive">{marketingError}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,268 +1,87 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { businessConfig } from '@/lib/seo-config'
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-4 rounded-[1.5rem] border border-border/70 bg-background/85 p-6 shadow-sm">
|
||||
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>
|
||||
<div className="space-y-4 text-sm leading-7 text-muted-foreground">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrivacyPolicyPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance mb-8">Privacy Policy</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-8">Last updated: June 20, 2022</p>
|
||||
|
||||
<div className="prose prose-lg max-w-none space-y-6">
|
||||
<p>
|
||||
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use our Service and tells You about Your privacy rights and how the law protects You.
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">Privacy</p>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground">Privacy Policy</h1>
|
||||
<p className="text-sm text-muted-foreground">Last updated: March 26, 2026</p>
|
||||
<p className="max-w-3xl text-base leading-7 text-muted-foreground">
|
||||
This Privacy Policy explains how {businessConfig.legalName} collects, uses, and protects the information you share with us through this website, our forms, and our text messaging program.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
We use Your Personal data to provide and improve our Service. By using our Service, You agree to the collection and use of information in accordance with this Privacy Policy.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold mb-4 mt-8">Interpretation and Definitions</h2>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4 mt-6">Interpretation</h3>
|
||||
<p className="mb-4">
|
||||
The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.
|
||||
</p>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4 mt-6">Definitions</h3>
|
||||
<p className="mb-4">For the purposes of this Privacy Policy:</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>
|
||||
<p><strong>Account</strong> means a unique account created for You to access our Service or parts of our Service.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Company</strong> (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Rocky Mountain Vending LLC, Utah.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Cookies</strong> are small files that are placed on Your computer, mobile device or any other device by a website, containing details of Your browsing history on that website among its many uses.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Country</strong> refers to: Utah, United States</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Device</strong> means any device that can access our Service such as a computer, a cellphone or a digital tablet.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Personal Data</strong> is any information that relates to an identified or identifiable individual.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Service</strong> refers to the Website.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Service Provider</strong> means any natural or legal person who processes data on behalf of the Company. It refers to third-party companies or individuals employed by the Company to facilitate the Service, to provide the Service on behalf of the Company, or to assist the Company in analyzing how the Service is used.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Usage Data</strong> refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself (for example, the duration of a page visit).</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Website</strong> refers to Rocky Mountain Vending, accessible from <a href="/" className="text-primary hover:text-secondary underline">rockymountainvending.com</a></p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>You</strong> means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold mb-4 mt-8">Collecting and Using Your Personal Data</h2>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4 mt-6">Types of Data Collected</h3>
|
||||
|
||||
<h4 className="text-xl font-semibold mb-3 mt-4">Personal Data</h4>
|
||||
<p className="mb-4">
|
||||
While using our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li><p>Email address</p></li>
|
||||
<li><p>First name and last name</p></li>
|
||||
<li><p>Phone number</p></li>
|
||||
<li><p>Usage Data</p></li>
|
||||
</ul>
|
||||
|
||||
<h4 className="text-xl font-semibold mb-3 mt-4">Usage Data</h4>
|
||||
<p className="mb-4">
|
||||
Usage Data is collected automatically when using the Service.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
When You access the Service by or through a mobile device, We may collect certain information automatically, including, but is not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.
|
||||
</p>
|
||||
|
||||
<h4 className="text-xl font-semibold mb-3 mt-4">Tracking Technologies and Cookies</h4>
|
||||
<p className="mb-4">
|
||||
We use Cookies and similar tracking technologies to track activity on Our Service and store certain information. Tracking technologies used are beacons, tags, and scripts to collect and track information and to improve and analyze Our Service. The technologies We use may include:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>
|
||||
<p><strong>Cookies or Browser Cookies.</strong> A cookie is a small file placed on Your Device. You can instruct Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do not accept Cookies, You may not be able to use some parts of our Service.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Flash Cookies.</strong> Certain features of our Service may use local stored objects (or Flash Cookies) to collect and store information about Your preferences or Your activity on our Service.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Web Beacons.</strong> Certain sections of our Service and our emails may contain small electronic files known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that permit the Company, for example, to count users who have visited those pages or opened an email and for other related website statistics (for example, recording the popularity of a certain section and verifying system and server integrity).</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal computer or mobile device when You go offline, while Session Cookies are deleted as soon as You close Your web browser. Learn more about cookies on <a href="https://www.freeprivacypolicy.com/blog/sample-privacy-policy-template/#Use_Of_Cookies_And_Tracking" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-secondary underline">Free Privacy Policy website</a> article.
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
We use both Session and Persistent Cookies for the purposes set out below:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-4">
|
||||
<li>
|
||||
<p><strong>Necessary / Essential Cookies</strong></p>
|
||||
<p>Type: Session Cookies</p>
|
||||
<p>Administered by: Us</p>
|
||||
<p>Purpose: These Cookies are essential to provide You with services available through the Website and to enable You to use some of its features. They help to authenticate users and prevent fraudulent use of user accounts. Without these Cookies, the services that You have asked for cannot be provided, and We only use these Cookies to provide You with those services.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Cookies Policy / Notice Acceptance Cookies</strong></p>
|
||||
<p>Type: Persistent Cookies</p>
|
||||
<p>Administered by: Us</p>
|
||||
<p>Purpose: These Cookies identify if users have accepted the use of cookies on the Website.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Functionality Cookies</strong></p>
|
||||
<p>Type: Persistent Cookies</p>
|
||||
<p>Administered by: Us</p>
|
||||
<p>Purpose: These Cookies allow us to remember the choices You make when You use the Website, such as remembering your login details or language preference. The purpose of these Cookies is to provide You with a more personal experience and to avoid You having to re-enter your preferences every time You use the Website.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-6">
|
||||
For more information about cookies we use and your choices regarding cookies, please visit our Cookies Policy or Cookies section of our Privacy Policy.
|
||||
</p>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4 mt-6">Use of Your Personal Data</h3>
|
||||
<p className="mb-4">The Company may use Personal Data for the following purposes:</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>
|
||||
<p><strong>To provide and maintain our Service</strong>, including to monitor the usage of our Service.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>To manage Your Account:</strong> to manage Your registration as a user of the Service. The Personal Data You provide can give You access to different functionalities of the Service that are available to You as a registered user.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>For the performance of a contract:</strong> development, compliance and undertaking of purchase contract for products, items or services You have purchased or of any other contract with Us through the Service.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>To contact You:</strong> To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication, such as a mobile application push notifications regarding updates or informative communications related to functionalities, products or contracted services, including security updates, when necessary or reasonable for their implementation.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>To provide You</strong> with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about, unless You have opted not to receive such information.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>To manage Your requests:</strong> To attend and manage Your requests to Us.</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="mb-4">We may share Your personal information in the following situations:</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li><strong>With Service Providers:</strong> We may share Your personal information with Service Providers to monitor and analyze the use of our Service, to contact You.</li>
|
||||
<li><strong>For business transfers:</strong> We may share or transfer Your personal information in connection with, or during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a portion of Our business to another company.</li>
|
||||
<li><strong>With Affiliates:</strong> We may share Your information with Our affiliates, in which case we will require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and any other subsidiaries, joint venture partners or other companies that We control or that are under common control with Us.</li>
|
||||
<li><strong>With business partners:</strong> We may share Your information with Our business partners to offer You certain products, services or promotions.</li>
|
||||
<li><strong>With other users:</strong> when You share personal information or otherwise interact in public areas with other users, such information may be viewed by all users and may be publicly distributed outside.</li>
|
||||
<li><strong>With Your consent</strong>: We may disclose Your personal information for any other purpose with Your consent.</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4 mt-6">Retention of Your Personal Data</h3>
|
||||
<p className="mb-4">
|
||||
The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.
|
||||
</p>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4 mt-6">Transfer of Your Personal Data</h3>
|
||||
<p className="mb-4">
|
||||
Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.
|
||||
</p>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4 mt-6">Disclosure of Your Personal Data</h3>
|
||||
|
||||
<h4 className="text-xl font-semibold mb-3 mt-4">Business Transactions</h4>
|
||||
<p className="mb-4">
|
||||
If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.
|
||||
</p>
|
||||
|
||||
<h4 className="text-xl font-semibold mb-3 mt-4">Law enforcement</h4>
|
||||
<p className="mb-4">
|
||||
Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).
|
||||
</p>
|
||||
|
||||
<h4 className="text-xl font-semibold mb-3 mt-4">Other legal requirements</h4>
|
||||
<p className="mb-4">The Company may disclose Your Personal Data in good faith belief that such action is necessary to:</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>Comply with a legal obligation</li>
|
||||
<li>Protect and defend rights or property of the Company</li>
|
||||
<li>Prevent or investigate possible wrongdoing in connection with the Service</li>
|
||||
<li>Protect the personal safety of Users of the Service or of the public</li>
|
||||
<li>Protect against legal liability</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4 mt-6">Security of Your Personal Data</h3>
|
||||
<p className="mb-6">
|
||||
The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold mb-4 mt-8">Children's Privacy</h2>
|
||||
<p className="mb-4">
|
||||
Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
If We need to rely on consent as a legal basis for processing Your information and Your country requires consent from a parent, We may require Your parent's consent before We collect and use that information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold mb-4 mt-8">Links to Other Websites</h2>
|
||||
<p className="mb-4">
|
||||
Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party's site. We strongly advise You to review Privacy Policy of every site You visit.
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold mb-4 mt-8">Changes to this Privacy Policy</h2>
|
||||
<p className="mb-4">
|
||||
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
We will let You know via email and/or a prominent notice on Our Service, prior to the change becoming effective and update the "Last updated" date at the top of this Privacy Policy.
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold mb-4 mt-8">Contact Us</h2>
|
||||
<p className="mb-4">If you have any questions about this Privacy Policy, You can contact us:</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>By email: info@rockymountainvending.com</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Section title="Information We Collect">
|
||||
<p>We may collect the information you provide directly, including your name, phone number, email address, company or location name, service intent, machine details, messages, and any other details you choose to submit.</p>
|
||||
<p>We also collect limited technical information such as IP address, browser details, page path, and timestamps to help operate the site, prevent abuse, and troubleshoot issues.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="How We Use Information">
|
||||
<ul className="list-disc space-y-2 pl-5">
|
||||
<li>Respond to your inquiries and route them to the right team member.</li>
|
||||
<li>Schedule service, repairs, moving requests, free placement consultations, and follow-up communications.</li>
|
||||
<li>Send conversational text messages related to your request when you provide the required service SMS consent.</li>
|
||||
<li>Send marketing or promotional text messages only when you separately opt in.</li>
|
||||
<li>Improve site functionality, audit performance, and prevent spam or abusive use.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="SMS Messaging Privacy">
|
||||
<p>When you opt in to conversational or marketing SMS, we store the consent status you selected, the consent version shown on the page, when consent was captured, and which page captured it.</p>
|
||||
<p>Service or conversational SMS may include scheduling updates, support follow-up, repair coordination, moving coordination, and related status messages. Message frequency varies. Message and data rates may apply. Reply STOP to opt out and HELP for help.</p>
|
||||
<p>Marketing SMS is optional and separate from service SMS. If you opt in, we may send occasional promotional or informational messages. Message frequency varies. Message and data rates may apply. Reply STOP to opt out and HELP for help.</p>
|
||||
<p>Consent to receive SMS is not a condition of purchase.</p>
|
||||
<p>Mobile information is not shared with third parties or affiliates for marketing or promotional purposes.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Sharing Information">
|
||||
<p>We may share information with vendors or service providers that help us operate our website, forms, messaging workflows, analytics, hosting, and CRM intake systems, but only as needed to provide our services.</p>
|
||||
<p>We may disclose information if required by law, to protect rights or safety, or as part of a business transfer such as a merger or asset sale.</p>
|
||||
<p>We do not sell your personal information.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Cookies And Analytics">
|
||||
<p>We may use cookies, local storage, session identifiers, and similar technologies to keep the site working, remember form or chat state, measure performance, and understand how visitors use the site.</p>
|
||||
<p>You can control cookies through your browser settings, but some features may not work as expected if they are disabled.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Data Retention And Security">
|
||||
<p>We retain information only as long as reasonably needed for inquiry handling, service follow-up, operational records, compliance, and dispute resolution.</p>
|
||||
<p>We use reasonable safeguards to protect information, but no internet transmission or storage system is guaranteed to be completely secure.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Your Choices">
|
||||
<ul className="list-disc space-y-2 pl-5">
|
||||
<li>You can request updates to your contact information by contacting us directly.</li>
|
||||
<li>You can opt out of SMS at any time by replying STOP.</li>
|
||||
<li>You can request help for our SMS program by replying HELP.</li>
|
||||
<li>You can contact us if you want us to review or delete information, subject to any legal or operational retention requirements.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="Contact Us">
|
||||
<p>If you have questions about this Privacy Policy or our SMS practices, contact us using any of the options below.</p>
|
||||
<ul className="space-y-2 text-foreground">
|
||||
<li>Email: <a href={`mailto:${businessConfig.email}`} className="underline underline-offset-4 hover:text-primary">{businessConfig.email}</a></li>
|
||||
<li>Phone: <a href={businessConfig.publicCallUrl} className="underline underline-offset-4 hover:text-primary">{businessConfig.publicCallNumber}</a></li>
|
||||
<li>Terms: <Link href="/terms-and-conditions" className="underline underline-offset-4 hover:text-primary">Terms and Conditions</Link></li>
|
||||
</ul>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,50 @@
|
|||
"use client"
|
||||
|
||||
import { Package } from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { ArrowRight, Package } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { RequestMachineForm } from "@/components/forms/request-machine-form"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
export function RequestMachineSection() {
|
||||
|
||||
return (
|
||||
<section id="request-machine" className="py-16 md:py-24 bg-background">
|
||||
<section id="request-machine" className="bg-[linear-gradient(180deg,rgba(247,244,236,0.55),rgba(255,255,255,0.98))] py-16 md:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center gap-2 mb-4">
|
||||
<Package className="h-6 w-6 text-secondary" />
|
||||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight text-balance">Get Your FREE Vending Machine Consultation</h2>
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
|
||||
<div className="rounded-[2rem] border border-border/70 bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.16),transparent_50%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(247,244,236,0.98))] p-6 shadow-[0_24px_60px_rgba(0,0,0,0.08)] md:p-8 lg:sticky lg:top-28">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
<Package className="h-4 w-4" />
|
||||
Free Placement
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
Fill out the form below and we'll call you within 24 hours to discuss your vending needs and schedule your free consultation
|
||||
<h2 className="mt-4 text-3xl font-bold tracking-tight text-balance md:text-4xl">
|
||||
Tell us about your location and we'll recommend the right machine mix.
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
||||
This intake is just for business locations that want free vending placement. We'll review foot traffic, preferred machine types, and next-step fit before scheduling the consultation.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3 rounded-[1.5rem] border border-border/60 bg-background/80 p-5 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">What to expect</p>
|
||||
<ul className="space-y-2">
|
||||
<li>We confirm the location type and approximate number of people on site.</li>
|
||||
<li>We review the best mix of snack, beverage, combo, or micro market options.</li>
|
||||
<li>We follow up within one business day to schedule the consultation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={businessConfig.publicCallUrl} className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary">
|
||||
Call Instead
|
||||
</a>
|
||||
<Link href="/contact-us#contact-form" className="inline-flex min-h-11 items-center gap-2 rounded-full text-sm font-medium text-foreground transition hover:text-primary">
|
||||
Need repairs or moving?
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-border transition-colors">
|
||||
<CardContent className="p-6 md:p-8">
|
||||
<RequestMachineForm onSubmit={(data) => console.log('Machine request form submitted:', data)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="rounded-[2rem] border border-border/70 bg-background/95 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.08)] md:p-7">
|
||||
<RequestMachineForm onSubmit={(data) => console.log("Machine request form submitted:", data)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
633
components/site-chat-widget.tsx
Normal file
633
components/site-chat-widget.tsx
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
"use client"
|
||||
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertCircle, Loader2, MessageSquare, Phone, SquarePen, X } from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { AssistantAvatar } from "@/components/assistant-avatar"
|
||||
import { FormInput } from "@/components/forms/form-input"
|
||||
import { FormSelect } from "@/components/forms/form-select"
|
||||
import { SmsConsentFields } from "@/components/forms/sms-consent-fields"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
getSiteChatBootstrap,
|
||||
isSiteChatSuppressedRoute,
|
||||
SITE_CHAT_MAX_INPUT_CHARS,
|
||||
} from "@/lib/site-chat/config"
|
||||
import {
|
||||
CHAT_INTENT_OPTIONS,
|
||||
getBestIntentFormHref,
|
||||
getBestIntentFormLabel,
|
||||
isFreePlacementIntent,
|
||||
isRepairOrMovingIntent,
|
||||
} from "@/lib/site-chat/intents"
|
||||
import { SMS_CONSENT_VERSION } from "@/lib/sms-compliance"
|
||||
|
||||
type ChatRole = "user" | "assistant"
|
||||
|
||||
type ChatMessage = {
|
||||
id: string
|
||||
role: ChatRole
|
||||
content: string
|
||||
}
|
||||
|
||||
type ChatVisitorProfile = {
|
||||
consentCapturedAt: string
|
||||
consentSourcePage: string
|
||||
consentVersion: string
|
||||
email: string
|
||||
intent: string
|
||||
marketingTextConsent: boolean
|
||||
name: string
|
||||
phone: string
|
||||
serviceTextConsent: boolean
|
||||
}
|
||||
|
||||
type ChatLimitStatus = {
|
||||
ipRemaining: number
|
||||
sessionRemaining: number
|
||||
outputCharsRemaining: number
|
||||
requestResetAt: string
|
||||
outputResetAt: string
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
type ChatApiResponse = {
|
||||
reply?: string
|
||||
error?: string
|
||||
sessionId?: string
|
||||
limits?: ChatLimitStatus
|
||||
}
|
||||
|
||||
const SESSION_STORAGE_KEY = "rmv-site-chat-session"
|
||||
const PROFILE_STORAGE_KEY = "rmv-site-chat-profile"
|
||||
const PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))"
|
||||
|
||||
function createMessage(role: ChatRole, content: string): ChatMessage {
|
||||
return {
|
||||
id: `${role}-${crypto.randomUUID()}`,
|
||||
role,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyProfileDraft(consentSourcePage: string): ChatVisitorProfile {
|
||||
return {
|
||||
name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
intent: "",
|
||||
serviceTextConsent: false,
|
||||
marketingTextConsent: false,
|
||||
consentVersion: SMS_CONSENT_VERSION,
|
||||
consentCapturedAt: "",
|
||||
consentSourcePage,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProfile(
|
||||
value: Partial<ChatVisitorProfile> | null | undefined,
|
||||
fallbackSourcePage: string,
|
||||
): ChatVisitorProfile | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = {
|
||||
name: String(value.name || "").replace(/\s+/g, " ").trim().slice(0, 80),
|
||||
phone: String(value.phone || "").replace(/\s+/g, " ").trim().slice(0, 40),
|
||||
email: String(value.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(),
|
||||
intent: String(value.intent || "").replace(/\s+/g, " ").trim().slice(0, 80),
|
||||
serviceTextConsent: Boolean(value.serviceTextConsent),
|
||||
marketingTextConsent: Boolean(value.marketingTextConsent),
|
||||
consentVersion: String(value.consentVersion || SMS_CONSENT_VERSION).trim() || SMS_CONSENT_VERSION,
|
||||
consentCapturedAt:
|
||||
typeof value.consentCapturedAt === "string" && value.consentCapturedAt.trim()
|
||||
? value.consentCapturedAt
|
||||
: new Date().toISOString(),
|
||||
consentSourcePage:
|
||||
typeof value.consentSourcePage === "string" && value.consentSourcePage.trim()
|
||||
? value.consentSourcePage.trim()
|
||||
: fallbackSourcePage,
|
||||
}
|
||||
|
||||
const digits = profile.phone.replace(/\D/g, "")
|
||||
const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.email)
|
||||
const timestampIsValid = !Number.isNaN(Date.parse(profile.consentCapturedAt))
|
||||
if (
|
||||
!profile.name ||
|
||||
!profile.phone ||
|
||||
!profile.email ||
|
||||
!profile.intent ||
|
||||
!profile.serviceTextConsent ||
|
||||
digits.length < 10 ||
|
||||
!emailIsValid ||
|
||||
!timestampIsValid
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
function createIntroMessage(profile: ChatVisitorProfile) {
|
||||
const firstName = profile.name.split(" ")[0] || profile.name
|
||||
const intentLabel = profile.intent.toLowerCase()
|
||||
return createMessage("assistant", `Hi ${firstName}, I've got your ${intentLabel} request. How can I help?`)
|
||||
}
|
||||
|
||||
function SupportHint({
|
||||
formHref,
|
||||
formLabel,
|
||||
intent,
|
||||
smsNumber,
|
||||
smsUrl,
|
||||
}: {
|
||||
formHref: string
|
||||
formLabel: string
|
||||
intent: string
|
||||
smsNumber: string
|
||||
smsUrl: string
|
||||
}) {
|
||||
if (isFreePlacementIntent(intent)) {
|
||||
return (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
Prefer a fuller intake? Use our{" "}
|
||||
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
||||
{formLabel}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (isRepairOrMovingIntent(intent)) {
|
||||
return (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
For repairs or moving, text photos or videos to{" "}
|
||||
<a href={smsUrl} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
||||
{smsNumber}
|
||||
</a>{" "}
|
||||
or use the{" "}
|
||||
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
||||
{formLabel}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
Need a fuller request? Use the{" "}
|
||||
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
||||
{formLabel}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteChatWidget() {
|
||||
const pathname = usePathname()
|
||||
const bootstrap = useMemo(() => getSiteChatBootstrap(), [])
|
||||
const intentOptions = useMemo(
|
||||
() => CHAT_INTENT_OPTIONS.map((option) => ({ label: option, value: option })),
|
||||
[],
|
||||
)
|
||||
const isSuppressed = isSiteChatSuppressedRoute(pathname)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [draft, setDraft] = useState("")
|
||||
const [profileDraft, setProfileDraft] = useState<ChatVisitorProfile>(() => createEmptyProfileDraft(pathname || "/"))
|
||||
const [profile, setProfile] = useState<ChatVisitorProfile | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [profileError, setProfileError] = useState<string | null>(null)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [, setLimits] = useState<ChatLimitStatus | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const storedSessionId = window.localStorage.getItem(SESSION_STORAGE_KEY)
|
||||
if (storedSessionId) {
|
||||
setSessionId(storedSessionId)
|
||||
}
|
||||
|
||||
const rawProfile = window.localStorage.getItem(PROFILE_STORAGE_KEY)
|
||||
if (!rawProfile) {
|
||||
setProfileDraft(createEmptyProfileDraft(pathname || "/"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedProfile = normalizeProfile(JSON.parse(rawProfile), pathname || "/")
|
||||
if (!parsedProfile) {
|
||||
window.localStorage.removeItem(PROFILE_STORAGE_KEY)
|
||||
setProfileDraft(createEmptyProfileDraft(pathname || "/"))
|
||||
return
|
||||
}
|
||||
|
||||
setProfile(parsedProfile)
|
||||
setProfileDraft(parsedProfile)
|
||||
setMessages([createIntroMessage(parsedProfile)])
|
||||
} catch {
|
||||
window.localStorage.removeItem(PROFILE_STORAGE_KEY)
|
||||
setProfileDraft(createEmptyProfileDraft(pathname || "/"))
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" })
|
||||
}, [messages.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuppressed) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsOpen(false)
|
||||
}, [isSuppressed])
|
||||
|
||||
const activeIntent = profile?.intent || profileDraft.intent
|
||||
const formHref = getBestIntentFormHref(activeIntent)
|
||||
const formLabel = getBestIntentFormLabel(activeIntent)
|
||||
const profileDraftIsReady = Boolean(
|
||||
profileDraft.name.trim() &&
|
||||
profileDraft.phone.trim() &&
|
||||
profileDraft.email.trim() &&
|
||||
profileDraft.intent &&
|
||||
profileDraft.serviceTextConsent,
|
||||
)
|
||||
|
||||
const handleProfileSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const nextProfile = normalizeProfile(
|
||||
{
|
||||
...profileDraft,
|
||||
consentCapturedAt: profileDraft.consentCapturedAt || new Date().toISOString(),
|
||||
consentSourcePage: pathname || "/",
|
||||
consentVersion: profileDraft.consentVersion || SMS_CONSENT_VERSION,
|
||||
},
|
||||
pathname || "/",
|
||||
)
|
||||
|
||||
if (!nextProfile) {
|
||||
setProfileError("Enter a valid name, phone, email, intent, and required service SMS consent.")
|
||||
return
|
||||
}
|
||||
|
||||
setProfile(nextProfile)
|
||||
setProfileDraft(nextProfile)
|
||||
setMessages([createIntroMessage(nextProfile)])
|
||||
setProfileError(null)
|
||||
setError(null)
|
||||
window.localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(nextProfile))
|
||||
},
|
||||
[pathname, profileDraft],
|
||||
)
|
||||
|
||||
const handleProfileReset = useCallback(() => {
|
||||
setProfile(null)
|
||||
setProfileDraft(createEmptyProfileDraft(pathname || "/"))
|
||||
setMessages([])
|
||||
setProfileError(null)
|
||||
setError(null)
|
||||
window.localStorage.removeItem(PROFILE_STORAGE_KEY)
|
||||
}, [pathname])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!profile) {
|
||||
setError("Enter your details before chatting.")
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed || isSending) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextUserMessage = createMessage("user", trimmed)
|
||||
const nextMessages = [...messages, nextUserMessage].slice(-12)
|
||||
|
||||
setMessages(nextMessages)
|
||||
setDraft("")
|
||||
setError(null)
|
||||
setIsSending(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pathname,
|
||||
sessionId,
|
||||
visitor: profile,
|
||||
messages: nextMessages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = (await response.json()) as ChatApiResponse
|
||||
|
||||
if (data.sessionId) {
|
||||
setSessionId(data.sessionId)
|
||||
window.localStorage.setItem(SESSION_STORAGE_KEY, data.sessionId)
|
||||
}
|
||||
|
||||
if (data.limits) {
|
||||
setLimits(data.limits)
|
||||
}
|
||||
|
||||
if (!response.ok || !data.reply) {
|
||||
throw new Error(data.error || "Jessica could not reply right now.")
|
||||
}
|
||||
|
||||
setMessages((current) => [...current, createMessage("assistant", data.reply || "")].slice(-12))
|
||||
} catch (chatError) {
|
||||
const message = chatError instanceof Error ? chatError.message : "Jessica could not reply right now."
|
||||
setError(message)
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createMessage("assistant", "I'm having trouble right now. Please try again or call us."),
|
||||
].slice(-12))
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
},
|
||||
[draft, isSending, messages, pathname, profile, sessionId],
|
||||
)
|
||||
|
||||
if (isSuppressed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed right-4 z-40 flex flex-col items-end gap-3"
|
||||
style={{ bottom: "calc(env(safe-area-inset-bottom, 0px) + 1rem)" }}
|
||||
>
|
||||
{isOpen ? (
|
||||
<div
|
||||
data-testid="site-chat-panel"
|
||||
className="pointer-events-auto flex w-[min(24rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-[1.75rem] border border-border/70 bg-background/95 shadow-[0_24px_80px_rgba(0,0,0,0.2)] backdrop-blur-xl"
|
||||
style={{ maxHeight: PANEL_MAX_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-start justify-between border-b border-border/70 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-11 w-11" />
|
||||
<div>
|
||||
<p className="text-base font-semibold text-foreground">{bootstrap.assistantName}</p>
|
||||
<p className="text-xs text-muted-foreground">Text support</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background text-foreground transition hover:border-primary/50 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!profile ? (
|
||||
<div className="min-h-0 overflow-y-auto px-4 py-4">
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-background/80 p-4 shadow-sm">
|
||||
<p className="text-sm font-medium text-foreground">Start with your details</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
We use this to route the conversation to the right team member.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<FormInput
|
||||
id="site-chat-name"
|
||||
label="Name"
|
||||
value={profileDraft.name}
|
||||
onChange={(event) => setProfileDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
id="site-chat-phone"
|
||||
label="Phone"
|
||||
value={profileDraft.phone}
|
||||
onChange={(event) => setProfileDraft((current) => ({ ...current, phone: event.target.value }))}
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
type="tel"
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
id="site-chat-email"
|
||||
label="Email"
|
||||
value={profileDraft.email}
|
||||
onChange={(event) => setProfileDraft((current) => ({ ...current, email: event.target.value }))}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
<FormSelect
|
||||
id="site-chat-intent"
|
||||
label="Intent"
|
||||
value={profileDraft.intent}
|
||||
onChange={(event) => setProfileDraft((current) => ({ ...current, intent: event.target.value }))}
|
||||
options={intentOptions}
|
||||
placeholder="Choose one"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-background/80 p-4 shadow-sm">
|
||||
<p className="text-sm font-medium text-foreground">Text updates</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Required service consent covers scheduling, support, repairs, moving, and follow-up texts for this request.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SmsConsentFields
|
||||
idPrefix="site-chat"
|
||||
serviceChecked={profileDraft.serviceTextConsent}
|
||||
marketingChecked={profileDraft.marketingTextConsent}
|
||||
onServiceChange={(checked) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
serviceTextConsent: checked,
|
||||
consentVersion: SMS_CONSENT_VERSION,
|
||||
consentSourcePage: pathname || "/",
|
||||
}))
|
||||
}
|
||||
onMarketingChange={(checked) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
marketingTextConsent: checked,
|
||||
consentVersion: SMS_CONSENT_VERSION,
|
||||
consentSourcePage: pathname || "/",
|
||||
}))
|
||||
}
|
||||
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profileDraftIsReady}
|
||||
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Start chat
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={bootstrap.callUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SupportHint
|
||||
formHref={formHref}
|
||||
formLabel={formLabel}
|
||||
intent={activeIntent}
|
||||
smsNumber={bootstrap.smsNumber}
|
||||
smsUrl={bootstrap.smsUrl}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{profileError ? (
|
||||
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{profileError}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Chatting as <span className="font-medium text-foreground">{profile.name}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProfileReset}
|
||||
className="inline-flex items-center gap-1 font-medium text-foreground transition hover:text-primary"
|
||||
>
|
||||
<SquarePen className="h-3.5 w-3.5" />
|
||||
Edit details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[22rem] overflow-y-auto px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="space-y-1">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{message.role === "assistant" ? bootstrap.assistantName : profile.name}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[92%] rounded-2xl px-3 py-2 text-sm shadow-sm",
|
||||
message.role === "assistant"
|
||||
? "bg-muted text-foreground"
|
||||
: "ml-auto bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/70 bg-background/95 px-4 py-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<label htmlFor="site-chat-input" className="text-sm font-semibold text-foreground">
|
||||
Message
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="site-chat-input"
|
||||
data-testid="site-chat-input"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))}
|
||||
placeholder="Describe what you need"
|
||||
rows={3}
|
||||
disabled={isSending}
|
||||
className="min-h-24 w-full rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
|
||||
<SupportHint
|
||||
formHref={formHref}
|
||||
formLabel={formLabel}
|
||||
intent={profile.intent}
|
||||
smsNumber={bootstrap.smsNumber}
|
||||
smsUrl={bootstrap.smsUrl}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="site-chat-send"
|
||||
disabled={isSending || !draft.trim()}
|
||||
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSending ? <Loader2 className="h-4 w-4 animate-spin" /> : <MessageSquare className="h-4 w-4" />}
|
||||
{isSending ? "Sending..." : "Send"}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={bootstrap.callUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="site-chat-launcher"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="pointer-events-auto inline-flex h-14 w-14 items-center justify-center rounded-full border border-white/70 bg-background/95 shadow-[0_20px_60px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_24px_68px_rgba(0,0,0,0.22)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
|
||||
aria-label={`Open chat with ${bootstrap.assistantName}`}
|
||||
>
|
||||
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-12 w-12" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,187 +1,78 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { businessConfig } from '@/lib/seo-config'
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-4 rounded-[1.5rem] border border-border/70 bg-background/85 p-6 shadow-sm">
|
||||
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>
|
||||
<div className="space-y-4 text-sm leading-7 text-muted-foreground">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TermsAndConditionsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
{/* Header */}
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">Terms and Conditions</h1>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p className="mb-6">
|
||||
Welcome to Rocky Mountain Vending!
|
||||
</p>
|
||||
|
||||
<p className="mb-6">
|
||||
These terms and conditions outline the rules and regulations for the use of Rocky Mountain Vending's Website, located at <Link href="/" className="text-primary hover:text-secondary underline">/</Link>.
|
||||
</p>
|
||||
|
||||
<p className="mb-8">
|
||||
By accessing this website we assume you accept these terms and conditions. Do not continue to use Rocky Mountain Vending if you do not agree to take all of the terms and conditions stated on this page.
|
||||
</p>
|
||||
|
||||
<p className="mb-8">
|
||||
The following terminology applies to these Terms and Conditions, Privacy Statement and Disclaimer Notice and all Agreements: "Client", "You" and "Your" refers to you, the person log on this website and compliant to the Company's terms and conditions. "The Company", "Ourselves", "We", "Our" and "Us", refers to our Company. "Party", "Parties", or "Us", refers to both the Client and ourselves. All terms refer to the offer, acceptance and consideration of payment necessary to undertake the process of our assistance to the Client in the most appropriate manner for the express purpose of meeting the Client's needs in respect of provision of the Company's stated services, in accordance with and subject to, prevailing law of Netherlands. Any use of the above terminology or other words in the singular, plural, capitalization and/or he/she or they, are taken as interchangeable and therefore as referring to same.
|
||||
</p>
|
||||
<div className="container mx-auto max-w-4xl px-4 py-16 md:py-24">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">Legal</p>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground">Terms and Conditions</h1>
|
||||
<p className="text-sm text-muted-foreground">Last updated: March 26, 2026</p>
|
||||
<p className="max-w-3xl text-base leading-7 text-muted-foreground">
|
||||
These Terms and Conditions govern your use of the {businessConfig.name} website, our contact and request forms, and our messaging program.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">Cookies</h3>
|
||||
<p className="mb-4">
|
||||
We employ the use of cookies. By accessing Rocky Mountain Vending, you agreed to use cookies in agreement with the Rocky Mountain Vending's Privacy Policy.
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
Most interactive websites use cookies to let us retrieve the user's details for each visit. Cookies are used by our website to enable the functionality of certain areas to make it easier for people visiting our website. Some of our affiliate/advertising partners may also use cookies.
|
||||
</p>
|
||||
</section>
|
||||
<Section title="Use Of This Website">
|
||||
<p>By using this website, you agree to use it only for lawful purposes and in a way that does not interfere with the site, our operations, or other users.</p>
|
||||
<p>You agree not to misuse forms, submit false information, attempt to bypass rate limits, or interfere with any security or technical protections on the site.</p>
|
||||
</Section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">License</h3>
|
||||
<p className="mb-4">
|
||||
Unless otherwise stated, Rocky Mountain Vending and/or its licensors own the intellectual property rights for all material on Rocky Mountain Vending. All intellectual property rights are reserved. You may access this from Rocky Mountain Vending for your own personal use subjected to restrictions set in these terms and conditions.
|
||||
</p>
|
||||
<p className="mb-4">You must not:</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>Republish material from Rocky Mountain Vending</li>
|
||||
<li>Sell, rent or sub-license material from Rocky Mountain Vending</li>
|
||||
<li>Reproduce, duplicate or copy material from Rocky Mountain Vending</li>
|
||||
<li>Redistribute content from Rocky Mountain Vending</li>
|
||||
<Section title="Informational Content">
|
||||
<p>Website content is provided for general informational purposes. Availability, service coverage, scheduling, machine inventory, and final service details may require direct confirmation from our team.</p>
|
||||
<p>Nothing on this site creates a guarantee of service, timeline, or commercial term unless confirmed directly by {businessConfig.legalName}.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Form And Inquiry Submissions">
|
||||
<p>When you submit a form or start chat, you agree to provide accurate information so we can respond to your inquiry.</p>
|
||||
<p>Submitting a form or chat request does not create a binding service contract by itself. Our team may contact you to confirm details before moving forward.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="SMS Messaging Program">
|
||||
<p>{businessConfig.legalName} offers conversational SMS related to inquiries, scheduling, support, repairs, moving requests, and follow-up. Marketing SMS is separate and optional.</p>
|
||||
<p>Message frequency varies. Message and data rates may apply. Reply STOP to opt out and HELP for help. Consent to receive text messages is not a condition of purchase.</p>
|
||||
<p>Your opt-in method is the website forms and chat gate where you provide your phone number and check the required service SMS consent box, with a separate optional box for marketing SMS consent.</p>
|
||||
<p>If you opt out of SMS, we may still contact you by phone or email when needed to respond to your request, subject to your preferences and applicable law.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Intellectual Property">
|
||||
<p>All text, branding, graphics, layout, and other site content are owned by or licensed to {businessConfig.legalName} unless otherwise stated. You may not copy, redistribute, or reuse site materials for commercial purposes without permission.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Links To Other Sites">
|
||||
<p>This website may link to third-party services or websites. We are not responsible for their content, policies, uptime, or practices.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Disclaimer And Limitation Of Liability">
|
||||
<p>The website is provided on an “as is” and “as available” basis. To the fullest extent permitted by law, {businessConfig.legalName} disclaims warranties of any kind regarding the website and its content.</p>
|
||||
<p>To the fullest extent permitted by law, {businessConfig.legalName} will not be liable for indirect, incidental, special, or consequential damages arising from your use of the website.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Updates To These Terms">
|
||||
<p>We may update these Terms and Conditions from time to time. Updates are effective when posted on this page.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Contact">
|
||||
<p>If you have questions about these Terms and Conditions or our messaging program, contact us below.</p>
|
||||
<ul className="space-y-2 text-foreground">
|
||||
<li>Email: <a href={`mailto:${businessConfig.email}`} className="underline underline-offset-4 hover:text-primary">{businessConfig.email}</a></li>
|
||||
<li>Phone: <a href={businessConfig.publicCallUrl} className="underline underline-offset-4 hover:text-primary">{businessConfig.publicCallNumber}</a></li>
|
||||
<li>Privacy: <Link href="/privacy-policy" className="underline underline-offset-4 hover:text-primary">Privacy Policy</Link></li>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
This Agreement shall begin on the date hereof.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Parts of this website offer an opportunity for users to post and exchange opinions and information in certain areas of the website. Rocky Mountain Vending does not filter, edit, publish or review Comments prior to their presence on the website. Comments do not reflect the views and opinions of Rocky Mountain Vending,its agents and/or affiliates. Comments reflect the views and opinions of the person who post their views and opinions. To the extent permitted by applicable laws, Rocky Mountain Vending shall not be liable for the Comments or for any liability, damages or expenses caused and/or suffered as a result of any use of and/or posting of and/or appearance of the Comments on this website.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Rocky Mountain Vending reserves the right to monitor all Comments and to remove any Comments which can be considered inappropriate, offensive or causes breach of these Terms and Conditions.
|
||||
</p>
|
||||
<p className="mb-4">You warrant and represent that:</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>You are entitled to post the Comments on our website and have all necessary licenses and consents to do so;</li>
|
||||
<li>The Comments do not invade any intellectual property right, including without limitation copyright, patent or trademark of any third party;</li>
|
||||
<li>The Comments do not contain any defamatory, libelous, offensive, indecent or otherwise unlawful material which is an invasion of privacy</li>
|
||||
<li>The Comments will not be used to solicit or promote business or custom or present commercial activities or unlawful activity.</li>
|
||||
</ul>
|
||||
<p className="mb-6">
|
||||
You hereby grant Rocky Mountain Vending a non-exclusive license to use, reproduce, edit and authorize others to use, reproduce and edit any of your Comments in any and all forms, formats or media.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">Hyperlinking to our Content</h3>
|
||||
<p className="mb-4">
|
||||
The following organizations may link to our Website without prior written approval:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>Government agencies;</li>
|
||||
<li>Search engines;</li>
|
||||
<li>News organizations;</li>
|
||||
<li>Online directory distributors may link to our Website in the same manner as they hyperlink to the Websites of other listed businesses; and</li>
|
||||
<li>System wide Accredited Businesses except soliciting non-profit organizations, charity shopping malls, and charity fundraising groups which may not hyperlink to our Web site.</li>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
These organizations may link to our home page, to publications or to other Website information so long as the link: (a) is not in any way deceptive; (b) does not falsely imply sponsorship, endorsement or approval of the linking party and its products and/or services; and (c) fits within the context of the linking party's site.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
We may consider and approve other link requests from the following types of organizations:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>commonly-known consumer and/or business information sources;</li>
|
||||
<li>dot.com community sites;</li>
|
||||
<li>associations or other groups representing charities;</li>
|
||||
<li>online directory distributors;</li>
|
||||
<li>internet portals;</li>
|
||||
<li>accounting, law and consulting firms; and</li>
|
||||
<li>educational institutions and trade associations.</li>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
We will approve link requests from these organizations if we decide that: (a) the link would not make us look unfavorably to ourselves or to our accredited businesses; (b) the organization does not have any negative records with us; (c) the benefit to us from the visibility of the hyperlink compensates the absence of Rocky Mountain Vending; and (d) the link is in the context of general resource information.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
These organizations may link to our home page so long as the link: (a) is not in any way deceptive; (b) does not falsely imply sponsorship, endorsement or approval of the linking party and its products or services; and (c) fits within the context of the linking party's site.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
If you are one of the organizations listed in paragraph 2 above and are interested in linking to our website, you must inform us by sending an e-mail to Rocky Mountain Vending. Please include your name, your organization name, contact information as well as the URL of your site, a list of any URLs from which you intend to link to our Website, and a list of the URLs on our site to which you would like to link. Wait 2-3 weeks for a response.
|
||||
</p>
|
||||
<p className="mb-4">Approved organizations may hyperlink to our Website as follows:</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>By use of our corporate name; or</li>
|
||||
<li>By use of the uniform resource locator being linked to; or</li>
|
||||
<li>By use of any other description of our Website being linked to that makes sense within the context and format of content on the linking party's site.</li>
|
||||
</ul>
|
||||
<p className="mb-6">
|
||||
No use of Rocky Mountain Vending's logo or other artwork will be allowed for linking absent a trademark license agreement.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">iFrames</h3>
|
||||
<p className="mb-6">
|
||||
Without prior approval and written permission, you may not create frames around our Webpages that alter in any way the visual presentation or appearance of our Website.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">Content Liability</h3>
|
||||
<p className="mb-6">
|
||||
We shall not be hold responsible for any content that appears on your Website. You agree to protect and defend us against all claims that is rising on your Website. No link(s) should appear on any Website that may be interpreted as libelous, obscene or criminal, or which infringes, otherwise violates, or advocates the infringement or other violation of, any third party rights.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">Your Privacy</h3>
|
||||
<p className="mb-6">
|
||||
Please read <Link href="/privacy-policy" className="text-primary hover:text-secondary underline">Privacy Policy</Link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">Reservation of Rights</h3>
|
||||
<p className="mb-6">
|
||||
We reserve the right to request that you remove all links or any particular link to our Website. You approve to immediately remove all links to our Website upon request. We also reserve the right to amen these terms and conditions and it's linking policy at any time. By continuously linking to our Website, you agree to be bound to and follow these linking terms and conditions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">Removal of links from our website</h3>
|
||||
<p className="mb-4">
|
||||
If you find any link on our Website that is offensive for any reason, you are free to contact and inform us any moment. We will consider requests to remove links but we are not obligated to or so or to respond to you directly.
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
We do not ensure that the information on this website is correct, we do not warrant its completeness or accuracy; nor do we promise to ensure that the website remains available or that the material on the website is kept up to date.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 mt-6">Disclaimer</h3>
|
||||
<p className="mb-4">
|
||||
To the maximum extent permitted by applicable law, we exclude all representations, warranties and conditions relating to our website and the use of this website. Nothing in this disclaimer will:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>limit or exclude our or your liability for death or personal injury;</li>
|
||||
<li>limit or exclude our or your liability for fraud or fraudulent misrepresentation;</li>
|
||||
<li>limit any of our or your liabilities in any way that is not permitted under applicable law; or</li>
|
||||
<li>exclude any of our or your liabilities that may not be excluded under applicable law.</li>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
The limitations and prohibitions of liability set in this Section and elsewhere in this disclaimer: (a) are subject to the preceding paragraph; and (b) govern all liabilities arising under the disclaimer, including liabilities arising in contract, in tort and for breach of statutory duty.
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
As long as the website and the information and services on the website are provided free of charge, we will not be liable for any loss or damage of any nature.
|
||||
</p>
|
||||
</section>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,30 @@ const configuredWebsite =
|
|||
* Used for NAP consistency, structured data, and SEO metadata
|
||||
*/
|
||||
|
||||
const DEFAULT_PUBLIC_CALL_PHONE = "(435) 233-9668"
|
||||
const DEFAULT_PUBLIC_CALL_PHONE_FORMATTED = "+14352339668"
|
||||
const DEFAULT_PUBLIC_SMS_PHONE = DEFAULT_PUBLIC_CALL_PHONE
|
||||
const DEFAULT_PUBLIC_SMS_PHONE_FORMATTED = DEFAULT_PUBLIC_CALL_PHONE_FORMATTED
|
||||
|
||||
const publicCallNumber = process.env.NEXT_PUBLIC_CALL_PHONE_DISPLAY || DEFAULT_PUBLIC_CALL_PHONE
|
||||
const publicCallFormatted = process.env.NEXT_PUBLIC_CALL_PHONE_E164 || DEFAULT_PUBLIC_CALL_PHONE_FORMATTED
|
||||
const publicSmsNumber = process.env.NEXT_PUBLIC_SMS_PHONE_DISPLAY || DEFAULT_PUBLIC_SMS_PHONE
|
||||
const publicSmsFormatted = process.env.NEXT_PUBLIC_SMS_PHONE_E164 || DEFAULT_PUBLIC_SMS_PHONE_FORMATTED
|
||||
|
||||
export const businessConfig = {
|
||||
name: "Rocky Mountain Vending",
|
||||
legalName: "Rocky Mountain Vending LLC",
|
||||
shortName: "RockyMountainVending",
|
||||
phone: "(435) 233-9668",
|
||||
phoneFormatted: "+14352339668",
|
||||
phoneUrl: "tel:+14352339668",
|
||||
smsUrl: "sms:+14352339668",
|
||||
phone: publicCallNumber,
|
||||
phoneFormatted: publicCallFormatted,
|
||||
phoneUrl: `tel:${publicCallFormatted}`,
|
||||
smsUrl: `sms:${publicSmsFormatted}`,
|
||||
publicCallNumber,
|
||||
publicCallFormatted,
|
||||
publicCallUrl: `tel:${publicCallFormatted}`,
|
||||
publicSmsNumber,
|
||||
publicSmsFormatted,
|
||||
publicSmsUrl: `sms:${publicSmsFormatted}`,
|
||||
email: "info@rockymountainvending.com",
|
||||
website: configuredWebsite,
|
||||
description:
|
||||
|
|
@ -88,4 +104,3 @@ export function getServiceAreaCities(): string[] {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@/lib/convex";
|
||||
import { isEmailConfigured, sendTransactionalEmail, TO_EMAIL } from "@/lib/email";
|
||||
import { createGHLContact } from "@/lib/ghl";
|
||||
import { createSmsConsentPayload, isValidConsentTimestamp } from "@/lib/sms-compliance";
|
||||
|
||||
export type LeadKind = "contact" | "request-machine";
|
||||
type StorageStatus = "stored" | "deduped" | "skipped" | "failed";
|
||||
|
|
@ -18,11 +19,17 @@ type SharedLeadFields = {
|
|||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
intent?: string;
|
||||
source?: string;
|
||||
page?: string;
|
||||
timestamp?: string;
|
||||
url?: string;
|
||||
confirmEmail?: string;
|
||||
serviceTextConsent: boolean;
|
||||
marketingTextConsent: boolean;
|
||||
consentVersion: string;
|
||||
consentCapturedAt: string;
|
||||
consentSourcePage: string;
|
||||
};
|
||||
|
||||
export type ContactLeadPayload = SharedLeadFields & {
|
||||
|
|
@ -38,8 +45,8 @@ export type RequestMachineLeadPayload = SharedLeadFields & {
|
|||
machineType: string;
|
||||
machineCount: string;
|
||||
message?: string;
|
||||
marketingConsent: boolean;
|
||||
termsAgreement: boolean;
|
||||
marketingConsent?: boolean;
|
||||
termsAgreement?: boolean;
|
||||
};
|
||||
|
||||
export type LeadPayload = ContactLeadPayload | RequestMachineLeadPayload;
|
||||
|
|
@ -175,17 +182,28 @@ function normalizeLeadPayload(
|
|||
sourceHost: string,
|
||||
kindOverride?: LeadKind
|
||||
): LeadPayload {
|
||||
const timestamp = coerceString(body.timestamp) || new Date().toISOString();
|
||||
const page = coerceString(body.page);
|
||||
const consentPayload = createSmsConsentPayload({
|
||||
serviceTextConsent: body.serviceTextConsent,
|
||||
marketingTextConsent: body.marketingTextConsent ?? body.marketingConsent,
|
||||
consentVersion: body.consentVersion,
|
||||
consentCapturedAt: body.consentCapturedAt ?? timestamp,
|
||||
consentSourcePage: body.consentSourcePage ?? page,
|
||||
});
|
||||
const kind = kindOverride || (isRequestMachinePayload(body) ? "request-machine" : "contact");
|
||||
const shared = {
|
||||
firstName: coerceString(body.firstName),
|
||||
lastName: coerceString(body.lastName),
|
||||
email: coerceString(body.email).toLowerCase(),
|
||||
phone: coerceString(body.phone),
|
||||
intent: coerceString(body.intent),
|
||||
source: coerceString(body.source) || "website",
|
||||
page: coerceString(body.page),
|
||||
timestamp: coerceString(body.timestamp) || new Date().toISOString(),
|
||||
page,
|
||||
timestamp,
|
||||
url: coerceString(body.url) || `https://${sourceHost}`,
|
||||
confirmEmail: coerceString(body.confirmEmail),
|
||||
...consentPayload,
|
||||
};
|
||||
|
||||
if (kind === "request-machine") {
|
||||
|
|
@ -247,6 +265,22 @@ function validateLeadPayload(payload: LeadPayload) {
|
|||
return "Invalid phone number";
|
||||
}
|
||||
|
||||
if (!payload.serviceTextConsent) {
|
||||
return "Service SMS consent is required";
|
||||
}
|
||||
|
||||
if (!payload.consentVersion.trim()) {
|
||||
return "Consent version is required";
|
||||
}
|
||||
|
||||
if (!payload.consentSourcePage.trim()) {
|
||||
return "Consent source page is required";
|
||||
}
|
||||
|
||||
if (!isValidConsentTimestamp(payload.consentCapturedAt)) {
|
||||
return "Consent timestamp is invalid";
|
||||
}
|
||||
|
||||
if (payload.kind === "contact") {
|
||||
if (payload.message.length < 10) {
|
||||
return "Message must be at least 10 characters";
|
||||
|
|
@ -264,30 +298,36 @@ function validateLeadPayload(payload: LeadPayload) {
|
|||
return "Invalid number of machines";
|
||||
}
|
||||
|
||||
if (!payload.marketingConsent) {
|
||||
return "Marketing consent is required";
|
||||
}
|
||||
|
||||
if (!payload.termsAgreement) {
|
||||
return "Terms agreement is required";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildLeadMessage(payload: LeadPayload) {
|
||||
const consentSummary = [
|
||||
`Service SMS consent: ${payload.serviceTextConsent ? "yes" : "no"}`,
|
||||
`Marketing SMS consent: ${payload.marketingTextConsent ? "yes" : "no"}`,
|
||||
`Consent version: ${payload.consentVersion}`,
|
||||
`Consent captured at: ${payload.consentCapturedAt}`,
|
||||
`Consent source page: ${payload.consentSourcePage}`,
|
||||
].join("\n");
|
||||
|
||||
if (payload.kind === "contact") {
|
||||
return payload.message;
|
||||
return [
|
||||
payload.intent ? `Intent: ${payload.intent}` : "",
|
||||
payload.message,
|
||||
consentSummary,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
payload.message ? `Additional information: ${payload.message}` : "",
|
||||
payload.intent ? `Intent: ${payload.intent}` : "",
|
||||
`Company: ${payload.company}`,
|
||||
`People/employees: ${payload.employeeCount}`,
|
||||
`Machine type: ${payload.machineType}`,
|
||||
`Machine count: ${payload.machineCount}`,
|
||||
`Marketing consent: ${payload.marketingConsent ? "yes" : "no"}`,
|
||||
`Terms agreement: ${payload.termsAgreement ? "yes" : "no"}`,
|
||||
consentSummary,
|
||||
`Source: ${payload.source || "website"}`,
|
||||
`Page: ${payload.page || ""}`,
|
||||
`URL: ${payload.url || ""}`,
|
||||
|
|
@ -330,9 +370,15 @@ function buildAdminEmailHtml(payload: LeadPayload) {
|
|||
<p><strong>Email:</strong> ${escapeHtml(payload.email)}</p>
|
||||
<p><strong>Phone:</strong> ${escapeHtml(payload.phone)}</p>
|
||||
${payload.company ? `<p><strong>Company:</strong> ${escapeHtml(payload.company)}</p>` : ""}
|
||||
${payload.intent ? `<p><strong>Intent:</strong> ${escapeHtml(payload.intent)}</p>` : ""}
|
||||
<p><strong>Source:</strong> ${escapeHtml(payload.source || "website")}</p>
|
||||
${payload.page ? `<p><strong>Page:</strong> ${escapeHtml(payload.page)}</p>` : ""}
|
||||
${payload.url ? `<p><strong>URL:</strong> ${escapeHtml(payload.url)}</p>` : ""}
|
||||
<p><strong>Service SMS consent:</strong> ${payload.serviceTextConsent ? "Yes" : "No"}</p>
|
||||
<p><strong>Marketing SMS consent:</strong> ${payload.marketingTextConsent ? "Yes" : "No"}</p>
|
||||
<p><strong>Consent version:</strong> ${escapeHtml(payload.consentVersion)}</p>
|
||||
<p><strong>Consent captured at:</strong> ${escapeHtml(payload.consentCapturedAt)}</p>
|
||||
<p><strong>Consent source page:</strong> ${escapeHtml(payload.consentSourcePage)}</p>
|
||||
`;
|
||||
|
||||
if (payload.kind === "contact") {
|
||||
|
|
@ -350,8 +396,6 @@ function buildAdminEmailHtml(payload: LeadPayload) {
|
|||
<p><strong>Employees/people:</strong> ${escapeHtml(payload.employeeCount)}</p>
|
||||
<p><strong>Machine type:</strong> ${escapeHtml(payload.machineType)}</p>
|
||||
<p><strong>Machine count:</strong> ${escapeHtml(payload.machineCount)}</p>
|
||||
<p><strong>Marketing consent:</strong> ${payload.marketingConsent ? "Yes" : "No"}</p>
|
||||
<p><strong>Terms agreement:</strong> ${payload.termsAgreement ? "Yes" : "No"}</p>
|
||||
${
|
||||
payload.message
|
||||
? `<p><strong>Additional information:</strong></p><pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(
|
||||
|
|
|
|||
47
lib/site-chat/config.ts
Normal file
47
lib/site-chat/config.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
export const SITE_CHAT_MODEL = process.env.XAI_CHAT_MODEL || "grok-4-1-fast-non-reasoning"
|
||||
export const SITE_CHAT_SOURCE = "site-chat"
|
||||
export const SITE_CHAT_SESSION_COOKIE = "rmv_site_chat_session"
|
||||
export const SITE_CHAT_AVATAR_SRC = "/images/jessica-avatar.jpg"
|
||||
|
||||
export const SITE_CHAT_SUPPRESSED_ROUTE_PREFIXES = [
|
||||
"/admin",
|
||||
"/auth",
|
||||
"/sign-in",
|
||||
"/stripe-setup",
|
||||
"/style-guide",
|
||||
"/manuals/dashboard",
|
||||
"/test-page",
|
||||
] as const
|
||||
|
||||
export const SITE_CHAT_MAX_HISTORY_MESSAGES = 12
|
||||
export const SITE_CHAT_MAX_INPUT_CHARS = 600
|
||||
export const SITE_CHAT_MAX_MESSAGE_CHARS = 1000
|
||||
export const SITE_CHAT_MAX_OUTPUT_TOKENS = 180
|
||||
export const SITE_CHAT_MAX_OUTPUT_CHARS = 700
|
||||
export const SITE_CHAT_TEMPERATURE = 0.2
|
||||
|
||||
export const SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW = 12
|
||||
export const SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW = 18
|
||||
export const SITE_CHAT_REQUEST_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
export const SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW = 5000
|
||||
export const SITE_CHAT_OUTPUT_WINDOW_MS = 60 * 60 * 1000
|
||||
|
||||
export function isSiteChatSuppressedRoute(pathname: string) {
|
||||
return SITE_CHAT_SUPPRESSED_ROUTE_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||
)
|
||||
}
|
||||
|
||||
export function getSiteChatBootstrap() {
|
||||
return {
|
||||
assistantName: "Jessica",
|
||||
avatarSrc: SITE_CHAT_AVATAR_SRC,
|
||||
callNumber: businessConfig.publicCallNumber,
|
||||
callUrl: businessConfig.publicCallUrl,
|
||||
smsNumber: businessConfig.publicSmsNumber,
|
||||
smsUrl: businessConfig.publicSmsUrl,
|
||||
}
|
||||
}
|
||||
27
lib/site-chat/intents.ts
Normal file
27
lib/site-chat/intents.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export const CHAT_INTENT_OPTIONS = [
|
||||
"Free Placement",
|
||||
"Repairs",
|
||||
"Moving (Vending Machine or Safe)",
|
||||
"Manuals",
|
||||
"Machine Sales",
|
||||
"Other",
|
||||
] as const
|
||||
|
||||
export const CONTACT_INTENT_OPTIONS = CHAT_INTENT_OPTIONS.filter((option) => option !== "Free Placement")
|
||||
|
||||
export function isFreePlacementIntent(intent: string | undefined | null) {
|
||||
return String(intent || "").trim().toLowerCase() === "free placement"
|
||||
}
|
||||
|
||||
export function isRepairOrMovingIntent(intent: string | undefined | null) {
|
||||
const value = String(intent || "").trim().toLowerCase()
|
||||
return value === "repairs" || value === "moving (vending machine or safe)"
|
||||
}
|
||||
|
||||
export function getBestIntentFormHref(intent: string | undefined | null) {
|
||||
return isFreePlacementIntent(intent) ? "/#request-machine" : "/contact-us#contact-form"
|
||||
}
|
||||
|
||||
export function getBestIntentFormLabel(intent: string | undefined | null) {
|
||||
return isFreePlacementIntent(intent) ? "free placement form" : "contact form"
|
||||
}
|
||||
31
lib/site-chat/prompt.ts
Normal file
31
lib/site-chat/prompt.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { businessConfig, serviceAreas } from "@/lib/seo-config"
|
||||
|
||||
const SERVICE_AREA_LIST = serviceAreas.map((area) => area.city).join(", ")
|
||||
|
||||
export const SITE_CHAT_SYSTEM_PROMPT = `You are Jessica, the friendly and professional text-chat assistant for ${businessConfig.legalName} in Utah.
|
||||
|
||||
Use this exact knowledge base and do not go beyond it:
|
||||
- Free vending placement is only for qualifying businesses. Rocky Mountain Vending installs, stocks, maintains, and repairs those machines at no cost to the business.
|
||||
- Repairs and maintenance are for machines the customer owns.
|
||||
- Moving requests can be for a vending machine or a safe, and they follow the same intake flow as repairs.
|
||||
- Vending machine sales can include new or used machines plus card readers, bill and coin mechanisms, and accessories.
|
||||
- Manuals and parts support are available.
|
||||
- Current public service area follows the live website coverage across Utah, including ${SERVICE_AREA_LIST}.
|
||||
|
||||
Conversation rules:
|
||||
- When the visitor name, email, and intent are available in context, use them naturally in your first reply.
|
||||
- Keep replies concise and useful. Use 2 to 4 short sentences max.
|
||||
- End every reply with one clear next-step question.
|
||||
- Never ask the visitor to upload files directly in chat.
|
||||
- For repairs or moving, first ask for the machine details and a clear text description of the issue. If the move is involved, clarify whether it is for a vending machine or a safe. Then direct them to text photos or videos to ${businessConfig.publicSmsNumber} or use the contact form on this page so the team can diagnose remotely first.
|
||||
- For free placement, confirm it is for a business and ask for the business type plus the approximate number of people.
|
||||
- For sales, ask whether they want new or used equipment, which features they need, and their budget range.
|
||||
- For manuals or parts, ask for the machine model or the specific part details.
|
||||
- If the visitor asks about a place that appears on the current website, treat it as inside the service area unless a human needs to confirm edge-case coverage.
|
||||
|
||||
Safety rules:
|
||||
- Never mention, quote, or hint at prices, service call fees, repair rates, hourly rates, parts costs, or internal policies.
|
||||
- If the visitor asks about pricing or cost, say: "Our complete vending service, including installation, stocking, and maintenance, is provided at no cost to qualifying businesses. I can get a few details so our team can schedule a quick call with you."
|
||||
- Do not invent timelines, guarantees, inventory, contract terms, or legal details.
|
||||
- If something needs confirmation, say a team member can confirm it.
|
||||
`
|
||||
112
lib/site-chat/rate-limit.ts
Normal file
112
lib/site-chat/rate-limit.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
type WindowCounter = {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const ipRequestStore = new Map<string, WindowCounter>()
|
||||
const sessionRequestStore = new Map<string, WindowCounter>()
|
||||
const sessionOutputStore = new Map<string, WindowCounter>()
|
||||
|
||||
function now() {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
function cleanup(store: Map<string, WindowCounter>) {
|
||||
const currentTime = now()
|
||||
|
||||
for (const [key, value] of store.entries()) {
|
||||
if (value.resetAt <= currentTime) {
|
||||
store.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readOrCreate(store: Map<string, WindowCounter>, key: string, windowMs: number) {
|
||||
cleanup(store)
|
||||
|
||||
const currentTime = now()
|
||||
const existing = store.get(key)
|
||||
|
||||
if (!existing || existing.resetAt <= currentTime) {
|
||||
const fresh = {
|
||||
count: 0,
|
||||
resetAt: currentTime + windowMs,
|
||||
}
|
||||
|
||||
store.set(key, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
function consume(store: Map<string, WindowCounter>, key: string, windowMs: number, amount: number) {
|
||||
const bucket = readOrCreate(store, key, windowMs)
|
||||
bucket.count += amount
|
||||
store.set(key, bucket)
|
||||
return bucket
|
||||
}
|
||||
|
||||
function peek(store: Map<string, WindowCounter>, key: string, windowMs: number) {
|
||||
return readOrCreate(store, key, windowMs)
|
||||
}
|
||||
|
||||
export function getChatRateLimitStatus({
|
||||
ip,
|
||||
maxIpRequests,
|
||||
maxSessionRequests,
|
||||
maxSessionOutputChars,
|
||||
requestWindowMs,
|
||||
sessionId,
|
||||
outputWindowMs,
|
||||
}: {
|
||||
ip: string
|
||||
maxIpRequests: number
|
||||
maxSessionRequests: number
|
||||
maxSessionOutputChars: number
|
||||
outputWindowMs: number
|
||||
requestWindowMs: number
|
||||
sessionId: string
|
||||
}) {
|
||||
const ipBucket = peek(ipRequestStore, ip, requestWindowMs)
|
||||
const sessionBucket = peek(sessionRequestStore, sessionId, requestWindowMs)
|
||||
const outputBucket = peek(sessionOutputStore, sessionId, outputWindowMs)
|
||||
|
||||
const ipRemaining = Math.max(0, maxIpRequests - ipBucket.count)
|
||||
const sessionRemaining = Math.max(0, maxSessionRequests - sessionBucket.count)
|
||||
const outputCharsRemaining = Math.max(0, maxSessionOutputChars - outputBucket.count)
|
||||
|
||||
return {
|
||||
ipRemaining,
|
||||
sessionRemaining,
|
||||
outputCharsRemaining,
|
||||
requestResetAt: new Date(Math.max(ipBucket.resetAt, sessionBucket.resetAt)).toISOString(),
|
||||
outputResetAt: new Date(outputBucket.resetAt).toISOString(),
|
||||
blocked: ipRemaining <= 0 || sessionRemaining <= 0 || outputCharsRemaining <= 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeChatRequest({
|
||||
ip,
|
||||
requestWindowMs,
|
||||
sessionId,
|
||||
}: {
|
||||
ip: string
|
||||
requestWindowMs: number
|
||||
sessionId: string
|
||||
}) {
|
||||
consume(ipRequestStore, ip, requestWindowMs, 1)
|
||||
consume(sessionRequestStore, sessionId, requestWindowMs, 1)
|
||||
}
|
||||
|
||||
export function consumeChatOutput({
|
||||
chars,
|
||||
outputWindowMs,
|
||||
sessionId,
|
||||
}: {
|
||||
chars: number
|
||||
outputWindowMs: number
|
||||
sessionId: string
|
||||
}) {
|
||||
consume(sessionOutputStore, sessionId, outputWindowMs, chars)
|
||||
}
|
||||
51
lib/sms-compliance.ts
Normal file
51
lib/sms-compliance.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
export const SMS_CONSENT_VERSION = "sms-consent-v1-2026-03-26"
|
||||
|
||||
export type SmsConsentFields = {
|
||||
serviceTextConsent: boolean
|
||||
marketingTextConsent: boolean
|
||||
consentVersion: string
|
||||
consentCapturedAt: string
|
||||
consentSourcePage: string
|
||||
}
|
||||
|
||||
export function normalizeConsentSourcePage(value: string | undefined | null) {
|
||||
const trimmed = String(value || "").trim()
|
||||
if (!trimmed) {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`
|
||||
}
|
||||
|
||||
export function createSmsConsentPayload(input: {
|
||||
consentCapturedAt?: string | null
|
||||
consentSourcePage?: string | null
|
||||
consentVersion?: string | null
|
||||
marketingTextConsent?: boolean | null
|
||||
serviceTextConsent?: boolean | null
|
||||
}): SmsConsentFields {
|
||||
return {
|
||||
serviceTextConsent: Boolean(input.serviceTextConsent),
|
||||
marketingTextConsent: Boolean(input.marketingTextConsent),
|
||||
consentVersion: String(input.consentVersion || SMS_CONSENT_VERSION).trim() || SMS_CONSENT_VERSION,
|
||||
consentCapturedAt:
|
||||
typeof input.consentCapturedAt === "string" && input.consentCapturedAt.trim()
|
||||
? input.consentCapturedAt
|
||||
: new Date().toISOString(),
|
||||
consentSourcePage: normalizeConsentSourcePage(input.consentSourcePage),
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidConsentTimestamp(value: string | undefined | null) {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !Number.isNaN(Date.parse(value))
|
||||
}
|
||||
|
||||
export const SERVICE_SMS_DISCLOSURE = `${businessConfig.legalName} may send conversational SMS about your inquiry, scheduling, support, repairs, moving, and follow-up. Message frequency varies. Message and data rates may apply. Reply STOP to opt out and HELP for help. Consent is not a condition of purchase.`
|
||||
|
||||
export const MARKETING_SMS_DISCLOSURE = `${businessConfig.legalName} may send promotional and marketing SMS. Message frequency varies. Message and data rates may apply. Reply STOP to opt out and HELP for help. Consent is not a condition of purchase.`
|
||||
BIN
public/images/jessica-avatar.jpg
Normal file
BIN
public/images/jessica-avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Loading…
Reference in a new issue