From 0ff33f82fbe98f6532d8421adb24e7a8999ad81c Mon Sep 17 00:00:00 2001 From: DMleadgen Date: Thu, 26 Mar 2026 14:11:33 -0600 Subject: [PATCH] deploy: add Jessica chat and A2P consent forms --- app/api/chat/route.ts | 354 +++++++++++ app/api/contact/route.test.ts | 18 +- app/globals.css | 19 +- app/layout.tsx | 43 +- components/assistant-avatar.tsx | 57 ++ components/contact-page.tsx | 193 +++--- components/forms/contact-form.tsx | 434 +++++++++----- components/forms/form-input.tsx | 38 +- components/forms/form-select.tsx | 61 ++ components/forms/form-textarea.tsx | 37 +- components/forms/request-machine-form.tsx | 679 ++++++++++------------ components/forms/sms-consent-fields.tsx | 93 +++ components/privacy-policy-page.tsx | 335 +++-------- components/request-machine-section.tsx | 52 +- components/site-chat-widget.tsx | 633 ++++++++++++++++++++ components/terms-and-conditions-page.tsx | 239 +++----- lib/seo-config.ts | 25 +- lib/server/contact-submission.ts | 78 ++- lib/site-chat/config.ts | 47 ++ lib/site-chat/intents.ts | 27 + lib/site-chat/prompt.ts | 31 + lib/site-chat/rate-limit.ts | 112 ++++ lib/sms-compliance.ts | 51 ++ public/images/jessica-avatar.jpg | Bin 0 -> 93204 bytes 24 files changed, 2464 insertions(+), 1192 deletions(-) create mode 100644 app/api/chat/route.ts create mode 100644 components/assistant-avatar.tsx create mode 100644 components/forms/form-select.tsx create mode 100644 components/forms/sms-consent-fields.tsx create mode 100644 components/site-chat-widget.tsx create mode 100644 lib/site-chat/config.ts create mode 100644 lib/site-chat/intents.ts create mode 100644 lib/site-chat/prompt.ts create mode 100644 lib/site-chat/rate-limit.ts create mode 100644 lib/sms-compliance.ts create mode 100644 public/images/jessica-avatar.jpg diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 00000000..83eff21a --- /dev/null +++ b/app/api/chat/route.ts @@ -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 = { + "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 }, + ) + } +} diff --git a/app/api/contact/route.test.ts b/app/api/contact/route.test.ts index 0757cdf1..cb10fa5a 100644 --- a/app/api/contact/route.test.ts +++ b/app/api/contact/route.test.ts @@ -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", { diff --git a/app/globals.css b/app/globals.css index 20b8a5d9..739203be 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 */ diff --git a/app/layout.tsx b/app/layout.tsx index 07323b5c..ab8cef77 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - {/* Resource hints for third-party domains */} - - -
- {/* Skip to main content link for keyboard users */} - - Skip to main content - -
-
- {children} -
-
-
-
- {/* Third-party scripts - loaded after page becomes interactive to avoid blocking render */} +
+ {/* Skip to main content link for keyboard users */} + + Skip to main content + +
+
+ {children} +
+
+
+ + -