diff --git a/STAGING_DEPLOYMENT.md b/STAGING_DEPLOYMENT.md
new file mode 100644
index 00000000..2cf7f8a7
--- /dev/null
+++ b/STAGING_DEPLOYMENT.md
@@ -0,0 +1,150 @@
+# Rocky Mountain Vending Staging Deployment
+
+This is the canonical staging deployment runbook for Rocky Mountain Vending.
+
+## Repo And Build Source
+
+- Deploy from the `code/` repository root, not the parent workspace folder.
+- Forgejo remote: `https://git.abundancepartners.app/matt/Rocky_Mountain_Vending.git`
+- Branch: `main`
+- Coolify build source: repository root Dockerfile at `/Dockerfile`
+- Container port: `3001`
+- Health check port: `3001`
+
+The parent workspace contains older migration notes and deployment docs that do not reflect the current Coolify app layout. Treat this file as the source of truth for staging execution.
+
+## Staging Targets
+
+- Staging host: `https://rmv.abundancepartners.app`
+- Preview URL: `https://bsowk840kccg08coocwwc44c.85.239.237.247.sslip.io`
+- Coolify project: `Rocky Mountain Vending`
+- Coolify app UUID: `bsowk840kccg08coocwwc44c`
+- Coolify environment UUID: `ew8k8og0gw48swck4ckk84kk`
+
+## Required Environment Contract
+
+Use the environment variables the app reads today.
+
+### Core Site
+
+- `NEXT_PUBLIC_SITE_URL`
+- `NEXT_PUBLIC_SITE_DOMAIN`
+- `NEXT_PUBLIC_CONVEX_URL`
+
+### Voice And Chat
+
+- `LIVEKIT_URL`
+- `LIVEKIT_API_KEY`
+- `LIVEKIT_API_SECRET`
+- `XAI_API_KEY`
+- `VOICE_ASSISTANT_SITE_URL`
+
+Optional:
+
+- `XAI_REALTIME_MODEL`
+- `NEXT_PUBLIC_CALL_PHONE_DISPLAY`
+- `NEXT_PUBLIC_CALL_PHONE_E164`
+- `NEXT_PUBLIC_SMS_PHONE_DISPLAY`
+- `NEXT_PUBLIC_SMS_PHONE_E164`
+- `NEXT_PUBLIC_MANUALS_BASE_URL`
+- `NEXT_PUBLIC_THUMBNAILS_BASE_URL`
+- `VOICE_RECORDING_ENABLED`
+- `VOICE_RECORDING_BUCKET`
+- `VOICE_RECORDING_ENDPOINT`
+- `VOICE_RECORDING_PUBLIC_BASE_URL`
+- `VOICE_RECORDING_ACCESS_KEY_ID`
+- `VOICE_RECORDING_SECRET_ACCESS_KEY`
+- `VOICE_RECORDING_REGION`
+
+### Admin And Auth
+
+- `ADMIN_EMAIL`
+- `ADMIN_PASSWORD`
+- `NEXT_PUBLIC_SUPABASE_URL`
+- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
+
+### Stripe
+
+- `STRIPE_SECRET_KEY`
+- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
+- `STRIPE_WEBHOOK_SECRET`
+
+### Current Non-Contract Handoff Vars
+
+Do not rely on these names for current code behavior without a separate alignment change:
+
+- `GHL_API_TOKEN`
+- `ADMIN_API_TOKEN`
+- `ADMIN_UI_ENABLED`
+- `USESEND_API_KEY`
+- `USESEND_BASE_URL`
+- `CONVEX_URL`
+- `CONVEX_SELF_HOSTED_URL`
+- `CONVEX_SELF_HOSTED_ADMIN_KEY`
+
+## Lead Routing Notes
+
+- `/api/contact` and `/api/request-machine` prefer Convex whenever `NEXT_PUBLIC_CONVEX_URL` is present.
+- The legacy webhook fallback still expects `GHL_CONTACT_WEBHOOK_URL` and `GHL_REQUEST_MACHINE_WEBHOOK_URL`.
+- Do not treat `GHL_API_TOKEN` as a fallback for staging readiness.
+
+## Pre-Deploy Checklist
+
+1. Start from a clean reviewed commit on `main`.
+2. Confirm required staging env coverage:
+
+```bash
+pnpm deploy:staging:env
+```
+
+3. Run the full local preflight:
+
+```bash
+pnpm deploy:staging:preflight
+```
+
+4. Push the reviewed release commit to `origin/main`.
+5. Verify Coolify is building from the repo root Dockerfile and exposing port `3001`.
+
+## Post-Deploy Smoke Tests
+
+Run the smoke test suite against either the preview URL or the staging host:
+
+```bash
+pnpm deploy:staging:smoke --base-url https://rmv.abundancepartners.app
+```
+
+If you want to verify real admin sign-in, pass credentials without committing them:
+
+```bash
+pnpm deploy:staging:smoke --base-url https://rmv.abundancepartners.app --admin-email "$ADMIN_EMAIL" --admin-password "$ADMIN_PASSWORD"
+```
+
+If you need to exercise valid contact or request-machine submissions, provide JSON payload files explicitly. This is opt-in because those requests create real records:
+
+```bash
+pnpm deploy:staging:smoke \
+ --base-url https://rmv.abundancepartners.app \
+ --contact-payload-file ./tmp/contact.json \
+ --request-machine-payload-file ./tmp/request-machine.json
+```
+
+## Acceptance Criteria
+
+Staging is ready only when all of the following pass on the deployed host:
+
+- `GET /`
+- `GET /contact-us`
+- `GET /products`
+- `GET /manuals/dashboard`
+- `POST /api/contact` with `{}` returns `400`
+- `POST /api/request-machine` with `{}` returns `400`
+- `POST /api/chat` succeeds
+- `POST /api/livekit/token` succeeds
+- Admin auth is configured and sign-in works
+- Stripe product fetch works
+- Stripe checkout route is configured
+
+## Known Non-Blocking Issue
+
+- `app/manuals/dashboard/page.tsx` still logs a missing parent-directory JSON file during builds when local manual-report artifacts are absent. The build currently completes successfully, so treat this as follow-up work rather than a staging blocker.
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index 83eff21a..b8d130b0 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -57,14 +57,9 @@ type ChatVisitorProfile = {
serviceTextConsent: boolean
}
-function readRequiredEnv(name: string) {
+function getOptionalEnv(name: string) {
const value = process.env[name]
-
- if (!value) {
- throw new Error(`Missing required site chat environment variable: ${name}`)
- }
-
- return value
+ return typeof value === "string" && value.trim() ? value.trim() : ""
}
function getClientIp(request: NextRequest) {
@@ -259,7 +254,22 @@ export async function POST(request: NextRequest) {
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
- const xaiApiKey = readRequiredEnv("XAI_API_KEY")
+ const xaiApiKey = getOptionalEnv("XAI_API_KEY")
+ if (!xaiApiKey) {
+ console.warn("[site-chat] missing XAI_API_KEY", {
+ pathname,
+ sessionId,
+ })
+
+ return NextResponse.json(
+ {
+ error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.",
+ sessionId,
+ },
+ { status: 503, headers: responseHeaders },
+ )
+ }
+
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
method: "POST",
headers: {
@@ -344,9 +354,16 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("[site-chat] request failed", error)
+ const safeError =
+ error instanceof Error && error.message.startsWith("Missing required site chat environment variable:")
+ ? "Jessica is temporarily unavailable right now. Please call us or use the contact form."
+ : error instanceof Error
+ ? error.message
+ : "Chat failed unexpectedly."
+
return NextResponse.json(
{
- error: error instanceof Error ? error.message : "Chat failed unexpectedly.",
+ error: safeError,
},
{ status: 500, headers: responseHeaders },
)
diff --git a/app/api/livekit/token/route.ts b/app/api/livekit/token/route.ts
new file mode 100644
index 00000000..7e002c4f
--- /dev/null
+++ b/app/api/livekit/token/route.ts
@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server"
+import { createVoiceAssistantTokenResponse } from "@/lib/voice-assistant/server"
+import { isVoiceAssistantSuppressedRoute } from "@/lib/voice-assistant/shared"
+
+export const runtime = "nodejs"
+
+type TokenRequestBody = {
+ pathname?: string
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = (await request.json().catch(() => ({}))) as TokenRequestBody
+ const pathname = typeof body.pathname === "string" && body.pathname.trim() ? body.pathname.trim() : "/"
+
+ if (isVoiceAssistantSuppressedRoute(pathname)) {
+ console.info("[voice-assistant/token] blocked on suppressed route", { pathname })
+ return NextResponse.json({ error: "Voice assistant is not available on this route." }, { status: 403 })
+ }
+
+ const tokenResponse = await createVoiceAssistantTokenResponse(pathname)
+ console.info("[voice-assistant/token] issued token", {
+ pathname,
+ roomName: tokenResponse.roomName,
+ participantIdentity: tokenResponse.participantIdentity,
+ })
+ return NextResponse.json(tokenResponse)
+ } catch (error) {
+ console.error("Failed to create LiveKit voice assistant token:", error)
+
+ return NextResponse.json(
+ {
+ error: error instanceof Error ? error.message : "Failed to create voice assistant token",
+ },
+ { status: 500 },
+ )
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index ab8cef77..31efce42 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -56,19 +56,18 @@ export const metadata: Metadata = {
icons: {
icon: [
{
- url: "/icon-light-32x32.png",
- media: "(prefers-color-scheme: light)",
+ url: "/favicon-16x16.png",
+ sizes: "16x16",
+ type: "image/png",
},
{
- url: "/icon-dark-32x32.png",
- media: "(prefers-color-scheme: dark)",
- },
- {
- url: "/icon.svg",
- type: "image/svg+xml",
+ url: "/favicon-32x32.png",
+ sizes: "32x32",
+ type: "image/png",
},
],
- apple: "/apple-icon.png",
+ shortcut: "/favicon.ico",
+ apple: "/apple-touch-icon.png",
},
openGraph: {
type: "website",
diff --git a/components/forms/sms-consent-fields.tsx b/components/forms/sms-consent-fields.tsx
index f12344a5..13312191 100644
--- a/components/forms/sms-consent-fields.tsx
+++ b/components/forms/sms-consent-fields.tsx
@@ -34,6 +34,7 @@ type SmsConsentFieldsProps = {
idPrefix: string
marketingChecked: boolean
marketingError?: string
+ mode?: "chat" | "forms"
onMarketingChange: (checked: boolean) => void
onServiceChange: (checked: boolean) => void
serviceChecked: boolean
@@ -44,6 +45,7 @@ export function SmsConsentFields({
idPrefix,
marketingChecked,
marketingError,
+ mode = "forms",
onMarketingChange,
onServiceChange,
serviceChecked,
@@ -70,24 +72,28 @@ export function SmsConsentFields({
{serviceError ?
{serviceError}
: null}
-
-
onMarketingChange(Boolean(checked))}
- className="mt-0.5"
- />
-
-
- {marketingError ? {marketingError}
: null}
+ {mode === "forms" ? (
+ <>
+
+
onMarketingChange(Boolean(checked))}
+ className="mt-0.5"
+ />
+
+
+ {marketingError ? {marketingError}
: null}
+ >
+ ) : null}
)
}
diff --git a/components/site-chat-widget.tsx b/components/site-chat-widget.tsx
index 7198aec6..d17683ba 100644
--- a/components/site-chat-widget.tsx
+++ b/components/site-chat-widget.tsx
@@ -59,6 +59,8 @@ type ChatApiResponse = {
limits?: ChatLimitStatus
}
+const CHAT_UNAVAILABLE_MESSAGE = "Jessica is temporarily unavailable right now. Please call or use the contact form."
+
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))"
@@ -348,16 +350,16 @@ export function SiteChatWidget() {
}
if (!response.ok || !data.reply) {
- throw new Error(data.error || "Jessica could not reply right now.")
+ throw new Error(data.error || CHAT_UNAVAILABLE_MESSAGE)
}
setMessages((current) => [...current, createMessage("assistant", data.reply || "")].slice(-12))
} catch (chatError) {
- const message = chatError instanceof Error ? chatError.message : "Jessica could not reply right now."
+ const message = chatError instanceof Error ? chatError.message : CHAT_UNAVAILABLE_MESSAGE
setError(message)
setMessages((current) => [
...current,
- createMessage("assistant", "I'm having trouble right now. Please try again or call us."),
+ createMessage("assistant", "I'm temporarily unavailable right now. Please call us or use the contact form."),
].slice(-12))
} finally {
setIsSending(false)
@@ -457,6 +459,7 @@ export function SiteChatWidget() {
@@ -467,14 +470,7 @@ export function SiteChatWidget() {
consentSourcePage: pathname || "/",
}))
}
- onMarketingChange={(checked) =>
- setProfileDraft((current) => ({
- ...current,
- marketingTextConsent: checked,
- consentVersion: SMS_CONSENT_VERSION,
- consentSourcePage: pathname || "/",
- }))
- }
+ onMarketingChange={() => undefined}
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
/>
diff --git a/convex/_generated/api.ts b/convex/_generated/api.ts
new file mode 100644
index 00000000..ccc0e103
--- /dev/null
+++ b/convex/_generated/api.ts
@@ -0,0 +1,4 @@
+import { anyApi } from "convex/server";
+
+export const api = anyApi;
+export const internal = anyApi;
diff --git a/convex/_generated/server.ts b/convex/_generated/server.ts
new file mode 100644
index 00000000..ee8faebf
--- /dev/null
+++ b/convex/_generated/server.ts
@@ -0,0 +1,9 @@
+export {
+ actionGeneric as action,
+ httpActionGeneric as httpAction,
+ internalActionGeneric as internalAction,
+ internalMutationGeneric as internalMutation,
+ internalQueryGeneric as internalQuery,
+ mutationGeneric as mutation,
+ queryGeneric as query,
+} from "convex/server";
diff --git a/convex/admin.ts b/convex/admin.ts
new file mode 100644
index 00000000..2d92ac19
--- /dev/null
+++ b/convex/admin.ts
@@ -0,0 +1,108 @@
+// @ts-nocheck
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+export const ensureAdminUser = mutation({
+ args: {
+ email: v.string(),
+ name: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("adminUsers")
+ .withIndex("by_email", (q) => q.eq("email", args.email))
+ .unique();
+ const now = Date.now();
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ active: true,
+ name: args.name ?? existing.name,
+ updatedAt: now,
+ lastLoginAt: now,
+ });
+ return await ctx.db.get(existing._id);
+ }
+
+ const id = await ctx.db.insert("adminUsers", {
+ email: args.email,
+ name: args.name,
+ role: "admin",
+ active: true,
+ createdAt: now,
+ updatedAt: now,
+ lastLoginAt: now,
+ });
+ return await ctx.db.get(id);
+ },
+});
+
+export const createSession = mutation({
+ args: {
+ email: v.string(),
+ tokenHash: v.string(),
+ expiresAt: v.number(),
+ },
+ handler: async (ctx, args) => {
+ const user = await ctx.db
+ .query("adminUsers")
+ .withIndex("by_email", (q) => q.eq("email", args.email))
+ .unique();
+ if (!user) {
+ throw new Error("Admin user not found");
+ }
+
+ const id = await ctx.db.insert("adminSessions", {
+ adminUserId: user._id,
+ tokenHash: args.tokenHash,
+ expiresAt: args.expiresAt,
+ createdAt: Date.now(),
+ });
+ return { sessionId: id, user };
+ },
+});
+
+export const destroySession = mutation({
+ args: {
+ tokenHash: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const session = await ctx.db
+ .query("adminSessions")
+ .withIndex("by_tokenHash", (q) => q.eq("tokenHash", args.tokenHash))
+ .unique();
+ if (session) {
+ await ctx.db.delete(session._id);
+ }
+ return { success: true };
+ },
+});
+
+export const validateSession = query({
+ args: {
+ tokenHash: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const session = await ctx.db
+ .query("adminSessions")
+ .withIndex("by_tokenHash", (q) => q.eq("tokenHash", args.tokenHash))
+ .unique();
+ if (!session || session.expiresAt < Date.now()) {
+ return null;
+ }
+ const user = await ctx.db.get(session.adminUserId);
+ if (!user || !user.active) {
+ return null;
+ }
+ return {
+ user: {
+ id: user._id,
+ email: user.email,
+ name: user.name,
+ role: user.role,
+ },
+ sessionId: session._id,
+ expiresAt: session.expiresAt,
+ };
+ },
+});
diff --git a/convex/leads.ts b/convex/leads.ts
new file mode 100644
index 00000000..80ffba8f
--- /dev/null
+++ b/convex/leads.ts
@@ -0,0 +1,158 @@
+// @ts-nocheck
+import { action, mutation } from "./_generated/server";
+import { v } from "convex/values";
+
+const baseLead = {
+ firstName: v.string(),
+ lastName: v.string(),
+ email: v.string(),
+ phone: v.string(),
+ company: v.optional(v.string()),
+ intent: v.optional(v.string()),
+ message: v.optional(v.string()),
+ source: v.optional(v.string()),
+ page: v.optional(v.string()),
+ url: v.optional(v.string()),
+ serviceTextConsent: v.optional(v.boolean()),
+ marketingTextConsent: v.optional(v.boolean()),
+ consentVersion: v.optional(v.string()),
+ consentCapturedAt: v.optional(v.string()),
+ consentSourcePage: v.optional(v.string()),
+};
+
+async function sendWebhook(webhookUrl: string | undefined, payload: Record) {
+ if (!webhookUrl) {
+ return { success: false, error: "Webhook URL not configured" };
+ }
+
+ const response = await fetch(webhookUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ return {
+ success: false,
+ error: `${response.status} ${response.statusText}`,
+ };
+ }
+
+ return { success: true };
+}
+
+export const createLead = mutation({
+ args: {
+ type: v.union(v.literal("contact"), v.literal("requestMachine")),
+ firstName: v.string(),
+ lastName: v.string(),
+ email: v.string(),
+ phone: v.string(),
+ company: v.optional(v.string()),
+ intent: v.optional(v.string()),
+ message: v.optional(v.string()),
+ source: v.optional(v.string()),
+ page: v.optional(v.string()),
+ url: v.optional(v.string()),
+ employeeCount: v.optional(v.string()),
+ machineType: v.optional(v.string()),
+ machineCount: v.optional(v.string()),
+ serviceTextConsent: v.optional(v.boolean()),
+ marketingTextConsent: v.optional(v.boolean()),
+ consentVersion: v.optional(v.string()),
+ consentCapturedAt: v.optional(v.string()),
+ consentSourcePage: v.optional(v.string()),
+ marketingConsent: v.optional(v.boolean()),
+ termsAgreement: v.optional(v.boolean()),
+ status: v.union(v.literal("pending"), v.literal("delivered"), v.literal("failed")),
+ error: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ return await ctx.db.insert("leadSubmissions", {
+ ...args,
+ createdAt: now,
+ updatedAt: now,
+ deliveredAt: args.status === "delivered" ? now : undefined,
+ });
+ },
+});
+
+export const submitContact = action({
+ args: {
+ ...baseLead,
+ },
+ handler: async (ctx, args) => {
+ const payload = {
+ firstName: args.firstName,
+ lastName: args.lastName,
+ email: args.email,
+ phone: args.phone,
+ company: args.company ?? "",
+ custom1: args.message ?? "",
+ custom2: args.source ?? "website",
+ custom3: args.page ?? "",
+ custom4: new Date().toISOString(),
+ custom5: args.url ?? "",
+ custom6: args.intent ?? "",
+ custom7: args.serviceTextConsent ? "Consented" : "Not Consented",
+ custom8: args.marketingTextConsent ? "Consented" : "Not Consented",
+ custom9: args.consentVersion ?? "",
+ custom10: args.consentCapturedAt ?? "",
+ custom11: args.consentSourcePage ?? "",
+ };
+
+ const result = await sendWebhook(process.env.GHL_CONTACT_WEBHOOK_URL, payload);
+ await ctx.runMutation("leads:createLead", {
+ type: "contact",
+ ...args,
+ status: result.success ? "delivered" : "failed",
+ error: result.error,
+ });
+ return result;
+ },
+});
+
+export const submitRequestMachine = action({
+ args: {
+ ...baseLead,
+ employeeCount: v.string(),
+ machineType: v.string(),
+ machineCount: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const payload = {
+ firstName: args.firstName,
+ lastName: args.lastName,
+ email: args.email,
+ phone: args.phone,
+ company: args.company ?? "",
+ custom1: args.employeeCount,
+ custom2: args.machineType,
+ custom3: args.machineCount,
+ custom4: args.message ?? "",
+ custom5: args.source ?? "website",
+ custom6: args.page ?? "",
+ custom7: new Date().toISOString(),
+ custom8: args.url ?? "",
+ custom9: args.serviceTextConsent ? "Consented" : "Not Consented",
+ custom10: args.marketingTextConsent ? "Consented" : "Not Consented",
+ custom11: args.consentVersion ?? "",
+ custom12: args.consentCapturedAt ?? "",
+ custom13: args.consentSourcePage ?? "",
+ custom14: "Free Consultation Request",
+ tags: ["Machine Request"],
+ };
+
+ const result = await sendWebhook(process.env.GHL_REQUEST_MACHINE_WEBHOOK_URL, payload);
+ await ctx.runMutation("leads:createLead", {
+ type: "requestMachine",
+ ...args,
+ status: result.success ? "delivered" : "failed",
+ error: result.error,
+ });
+ return result;
+ },
+});
diff --git a/convex/manuals.ts b/convex/manuals.ts
new file mode 100644
index 00000000..e150afa1
--- /dev/null
+++ b/convex/manuals.ts
@@ -0,0 +1,108 @@
+// @ts-nocheck
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+const manualInput = v.object({
+ filename: v.string(),
+ path: v.string(),
+ manufacturer: v.string(),
+ category: v.string(),
+ size: v.optional(v.number()),
+ lastModified: v.optional(v.number()),
+ searchTerms: v.optional(v.array(v.string())),
+ commonNames: v.optional(v.array(v.string())),
+ thumbnailUrl: v.optional(v.string()),
+ manualUrl: v.optional(v.string()),
+ hasParts: v.optional(v.boolean()),
+ assetSource: v.optional(v.string()),
+ sourcePath: v.optional(v.string()),
+ sourceSite: v.optional(v.string()),
+ sourceDomain: v.optional(v.string()),
+ siteVisibility: v.optional(v.array(v.string())),
+ importBatch: v.optional(v.string()),
+});
+
+export const list = query({
+ args: {},
+ handler: async (ctx) => {
+ const manuals = await ctx.db.query("manuals").collect();
+ return manuals.sort((a, b) => a.filename.localeCompare(b.filename));
+ },
+});
+
+export const dashboard = query({
+ args: {},
+ handler: async (ctx) => {
+ const manuals = await ctx.db.query("manuals").collect();
+ const categories = await ctx.db.query("manualCategories").collect();
+
+ const manufacturerMap = new Map();
+ const categoryMap = new Map();
+ for (const manual of manuals) {
+ manufacturerMap.set(
+ manual.manufacturer,
+ (manufacturerMap.get(manual.manufacturer) ?? 0) + 1,
+ );
+ categoryMap.set(
+ manual.category,
+ (categoryMap.get(manual.category) ?? 0) + 1,
+ );
+ }
+
+ return {
+ missingManuals: {
+ summary: {
+ total_expected_models: manuals.length,
+ models_missing_all: 0,
+ models_partial: 0,
+ },
+ },
+ qaData: [],
+ metadata: manuals,
+ structuredData: [],
+ semanticIndex: {
+ total_chunks: manuals.length,
+ },
+ acquisitionList: {
+ total_items: 0,
+ high_priority: 0,
+ medium_priority: 0,
+ low_priority: 0,
+ acquisition_list: [],
+ },
+ nameMapping: categories,
+ };
+ },
+});
+
+export const upsertMany = mutation({
+ args: {
+ manuals: v.array(manualInput),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const results = [];
+ for (const manual of args.manuals) {
+ const existing = await ctx.db
+ .query("manuals")
+ .withIndex("by_path", (q) => q.eq("path", manual.path))
+ .unique();
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ ...manual,
+ updatedAt: now,
+ });
+ results.push(await ctx.db.get(existing._id));
+ } else {
+ const id = await ctx.db.insert("manuals", {
+ ...manual,
+ createdAt: now,
+ updatedAt: now,
+ });
+ results.push(await ctx.db.get(id));
+ }
+ }
+ return results;
+ },
+});
diff --git a/convex/orders.ts b/convex/orders.ts
new file mode 100644
index 00000000..911d6795
--- /dev/null
+++ b/convex/orders.ts
@@ -0,0 +1,200 @@
+// @ts-nocheck
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+const orderStatus = v.union(
+ v.literal("pending"),
+ v.literal("paid"),
+ v.literal("fulfilled"),
+ v.literal("cancelled"),
+ v.literal("refunded"),
+);
+
+const orderItem = v.object({
+ productId: v.optional(v.id("products")),
+ stripeProductId: v.optional(v.string()),
+ stripePriceId: v.string(),
+ productName: v.string(),
+ image: v.optional(v.string()),
+ price: v.number(),
+ quantity: v.number(),
+});
+
+const shippingAddress = v.object({
+ name: v.optional(v.string()),
+ address: v.optional(v.string()),
+ city: v.optional(v.string()),
+ state: v.optional(v.string()),
+ zipCode: v.optional(v.string()),
+ country: v.optional(v.string()),
+});
+
+async function attachItems(ctx: any, order: any) {
+ const items = await ctx.db
+ .query("orderItems")
+ .withIndex("by_orderId", (q: any) => q.eq("orderId", order._id))
+ .collect();
+
+ return {
+ id: order._id,
+ customerEmail: order.customerEmail,
+ customerName: order.customerName,
+ totalAmount: order.totalAmount,
+ currency: order.currency,
+ status: order.status,
+ stripeSessionId: order.stripeSessionId ?? null,
+ paymentIntentId: order.stripePaymentIntentId ?? null,
+ createdAt: new Date(order.createdAt).toISOString(),
+ updatedAt: new Date(order.updatedAt).toISOString(),
+ shippingAddress: order.shippingAddress,
+ items: items.map((item: any) => ({
+ productId: item.productId ?? null,
+ productName: item.productName,
+ price: item.price,
+ quantity: item.quantity,
+ priceId: item.stripePriceId,
+ image: item.image,
+ })),
+ };
+}
+
+export const listAdmin = query({
+ args: {
+ status: v.optional(orderStatus),
+ search: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ let orders = await ctx.db
+ .query("orders")
+ .withIndex("by_createdAt")
+ .collect();
+ orders = orders.sort((a, b) => b.createdAt - a.createdAt);
+
+ if (args.status) {
+ orders = orders.filter((order) => order.status === args.status);
+ }
+
+ const search = args.search?.trim().toLowerCase();
+ if (search) {
+ orders = orders.filter((order) => {
+ return (
+ order.customerEmail.toLowerCase().includes(search) ||
+ order.customerName?.toLowerCase().includes(search) ||
+ order._id.toLowerCase().includes(search)
+ );
+ });
+ }
+
+ return await Promise.all(orders.map((order) => attachItems(ctx, order)));
+ },
+});
+
+export const getMetrics = query({
+ args: {},
+ handler: async (ctx) => {
+ const orders = await ctx.db.query("orders").collect();
+ const paidOrders = orders.filter((order) => order.status === "paid" || order.status === "fulfilled");
+ const revenue = paidOrders.reduce((sum, order) => sum + order.totalAmount, 0);
+
+ return {
+ totalOrders: orders.length,
+ totalRevenue: revenue,
+ pendingOrders: orders.filter((order) => order.status === "pending").length,
+ completedOrders: orders.filter((order) => order.status === "fulfilled").length,
+ paidOrders: orders.filter((order) => order.status === "paid").length,
+ refundedOrders: orders.filter((order) => order.status === "refunded").length,
+ };
+ },
+});
+
+export const upsertStripeOrder = mutation({
+ args: {
+ customerEmail: v.string(),
+ customerName: v.optional(v.string()),
+ status: orderStatus,
+ totalAmount: v.number(),
+ currency: v.string(),
+ stripeSessionId: v.optional(v.string()),
+ stripePaymentIntentId: v.optional(v.string()),
+ shippingAddress: v.optional(shippingAddress),
+ items: v.array(orderItem),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ let existing =
+ args.stripeSessionId
+ ? await ctx.db
+ .query("orders")
+ .withIndex("by_stripeSessionId", (q) =>
+ q.eq("stripeSessionId", args.stripeSessionId),
+ )
+ .unique()
+ : null;
+
+ let orderId;
+ if (existing) {
+ orderId = existing._id;
+ await ctx.db.patch(orderId, {
+ customerEmail: args.customerEmail,
+ customerName: args.customerName,
+ status: args.status,
+ totalAmount: args.totalAmount,
+ currency: args.currency,
+ stripePaymentIntentId: args.stripePaymentIntentId,
+ shippingAddress: args.shippingAddress,
+ updatedAt: now,
+ });
+
+ const existingItems = await ctx.db
+ .query("orderItems")
+ .withIndex("by_orderId", (q) => q.eq("orderId", orderId))
+ .collect();
+ for (const item of existingItems) {
+ await ctx.db.delete(item._id);
+ }
+ } else {
+ orderId = await ctx.db.insert("orders", {
+ customerEmail: args.customerEmail,
+ customerName: args.customerName,
+ status: args.status,
+ totalAmount: args.totalAmount,
+ currency: args.currency,
+ stripeSessionId: args.stripeSessionId,
+ stripePaymentIntentId: args.stripePaymentIntentId,
+ shippingAddress: args.shippingAddress,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+
+ for (const item of args.items) {
+ await ctx.db.insert("orderItems", {
+ orderId,
+ productId: item.productId,
+ stripeProductId: item.stripeProductId,
+ stripePriceId: item.stripePriceId,
+ productName: item.productName,
+ image: item.image,
+ price: item.price,
+ quantity: item.quantity,
+ createdAt: now,
+ });
+ }
+
+ return await attachItems(ctx, (await ctx.db.get(orderId))!);
+ },
+});
+
+export const updateStatus = mutation({
+ args: {
+ id: v.id("orders"),
+ status: orderStatus,
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.id, {
+ status: args.status,
+ updatedAt: Date.now(),
+ });
+ return await attachItems(ctx, (await ctx.db.get(args.id))!);
+ },
+});
diff --git a/convex/products.ts b/convex/products.ts
new file mode 100644
index 00000000..4eb53b68
--- /dev/null
+++ b/convex/products.ts
@@ -0,0 +1,124 @@
+// @ts-nocheck
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+const productArgs = {
+ id: v.optional(v.id("products")),
+ name: v.string(),
+ description: v.optional(v.string()),
+ price: v.number(),
+ currency: v.string(),
+ images: v.array(v.string()),
+ metadata: v.optional(v.record(v.string(), v.string())),
+ stripeProductId: v.optional(v.string()),
+ stripePriceId: v.optional(v.string()),
+ active: v.boolean(),
+ featured: v.optional(v.boolean()),
+};
+
+export const listActive = query({
+ args: {},
+ handler: async (ctx) => {
+ return await ctx.db
+ .query("products")
+ .withIndex("by_active", (q) => q.eq("active", true))
+ .collect();
+ },
+});
+
+export const listAdmin = query({
+ args: {
+ search: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const products = await ctx.db.query("products").collect();
+ const search = args.search?.trim().toLowerCase();
+ if (!search) {
+ return products.sort((a, b) => a.name.localeCompare(b.name));
+ }
+ return products
+ .filter((product) => {
+ return (
+ product.name.toLowerCase().includes(search) ||
+ product.description?.toLowerCase().includes(search)
+ );
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+ },
+});
+
+export const getById = query({
+ args: {
+ id: v.id("products"),
+ },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.id);
+ },
+});
+
+export const upsert = mutation({
+ args: productArgs,
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ if (args.id) {
+ await ctx.db.patch(args.id, {
+ name: args.name,
+ description: args.description,
+ price: args.price,
+ currency: args.currency,
+ images: args.images,
+ metadata: args.metadata,
+ stripeProductId: args.stripeProductId,
+ stripePriceId: args.stripePriceId,
+ active: args.active,
+ featured: args.featured,
+ updatedAt: now,
+ });
+ return await ctx.db.get(args.id);
+ }
+
+ const id = await ctx.db.insert("products", {
+ name: args.name,
+ description: args.description,
+ price: args.price,
+ currency: args.currency,
+ images: args.images,
+ metadata: args.metadata,
+ stripeProductId: args.stripeProductId,
+ stripePriceId: args.stripePriceId,
+ active: args.active,
+ featured: args.featured,
+ createdAt: now,
+ updatedAt: now,
+ });
+ return await ctx.db.get(id);
+ },
+});
+
+export const deactivate = mutation({
+ args: {
+ id: v.id("products"),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.id, {
+ active: false,
+ updatedAt: Date.now(),
+ });
+ return await ctx.db.get(args.id);
+ },
+});
+
+export const bulkDeactivate = mutation({
+ args: {
+ ids: v.array(v.id("products")),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const results = [];
+ for (const id of args.ids) {
+ await ctx.db.patch(id, { active: false, updatedAt: now });
+ results.push(await ctx.db.get(id));
+ }
+ return results;
+ },
+});
diff --git a/convex/schema.ts b/convex/schema.ts
new file mode 100644
index 00000000..2b11b065
--- /dev/null
+++ b/convex/schema.ts
@@ -0,0 +1,223 @@
+import { defineSchema, defineTable } from "convex/server";
+import { v } from "convex/values";
+
+const orderStatus = v.union(
+ v.literal("pending"),
+ v.literal("paid"),
+ v.literal("fulfilled"),
+ v.literal("cancelled"),
+ v.literal("refunded"),
+);
+
+export default defineSchema({
+ products: defineTable({
+ name: v.string(),
+ description: v.optional(v.string()),
+ price: v.number(),
+ currency: v.string(),
+ images: v.array(v.string()),
+ metadata: v.optional(v.record(v.string(), v.string())),
+ stripeProductId: v.optional(v.string()),
+ stripePriceId: v.optional(v.string()),
+ active: v.boolean(),
+ featured: v.optional(v.boolean()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_active", ["active"])
+ .index("by_stripeProductId", ["stripeProductId"]),
+
+ orders: defineTable({
+ customerEmail: v.string(),
+ customerName: v.optional(v.string()),
+ status: orderStatus,
+ totalAmount: v.number(),
+ currency: v.string(),
+ stripeSessionId: v.optional(v.string()),
+ stripePaymentIntentId: v.optional(v.string()),
+ shippingAddress: v.optional(
+ v.object({
+ name: v.optional(v.string()),
+ address: v.optional(v.string()),
+ city: v.optional(v.string()),
+ state: v.optional(v.string()),
+ zipCode: v.optional(v.string()),
+ country: v.optional(v.string()),
+ }),
+ ),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_createdAt", ["createdAt"])
+ .index("by_status", ["status"])
+ .index("by_stripeSessionId", ["stripeSessionId"])
+ .index("by_customerEmail", ["customerEmail"]),
+
+ orderItems: defineTable({
+ orderId: v.id("orders"),
+ productId: v.optional(v.id("products")),
+ stripeProductId: v.optional(v.string()),
+ stripePriceId: v.string(),
+ productName: v.string(),
+ image: v.optional(v.string()),
+ price: v.number(),
+ quantity: v.number(),
+ createdAt: v.number(),
+ }).index("by_orderId", ["orderId"]),
+
+ manuals: defineTable({
+ filename: v.string(),
+ path: v.string(),
+ manufacturer: v.string(),
+ category: v.string(),
+ size: v.optional(v.number()),
+ lastModified: v.optional(v.number()),
+ searchTerms: v.optional(v.array(v.string())),
+ commonNames: v.optional(v.array(v.string())),
+ thumbnailUrl: v.optional(v.string()),
+ manualUrl: v.optional(v.string()),
+ hasParts: v.optional(v.boolean()),
+ assetSource: v.optional(v.string()),
+ sourcePath: v.optional(v.string()),
+ sourceSite: v.optional(v.string()),
+ sourceDomain: v.optional(v.string()),
+ siteVisibility: v.optional(v.array(v.string())),
+ importBatch: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_manufacturer", ["manufacturer"])
+ .index("by_category", ["category"])
+ .index("by_path", ["path"]),
+
+ manualCategories: defineTable({
+ name: v.string(),
+ slug: v.string(),
+ description: v.optional(v.string()),
+ icon: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ }).index("by_slug", ["slug"]),
+
+ leadSubmissions: defineTable({
+ type: v.union(v.literal("contact"), v.literal("requestMachine")),
+ status: v.union(v.literal("pending"), v.literal("delivered"), v.literal("failed")),
+ firstName: v.string(),
+ lastName: v.string(),
+ email: v.string(),
+ phone: v.string(),
+ company: v.optional(v.string()),
+ intent: v.optional(v.string()),
+ message: v.optional(v.string()),
+ source: v.optional(v.string()),
+ page: v.optional(v.string()),
+ url: v.optional(v.string()),
+ employeeCount: v.optional(v.string()),
+ machineType: v.optional(v.string()),
+ machineCount: v.optional(v.string()),
+ serviceTextConsent: v.optional(v.boolean()),
+ marketingTextConsent: v.optional(v.boolean()),
+ consentVersion: v.optional(v.string()),
+ consentCapturedAt: v.optional(v.string()),
+ consentSourcePage: v.optional(v.string()),
+ marketingConsent: v.optional(v.boolean()),
+ termsAgreement: v.optional(v.boolean()),
+ error: v.optional(v.string()),
+ deliveredAt: v.optional(v.number()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_type", ["type"])
+ .index("by_status", ["status"])
+ .index("by_createdAt", ["createdAt"]),
+
+ adminUsers: defineTable({
+ email: v.string(),
+ name: v.optional(v.string()),
+ role: v.union(v.literal("admin")),
+ active: v.boolean(),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ lastLoginAt: v.optional(v.number()),
+ }).index("by_email", ["email"]),
+
+ adminSessions: defineTable({
+ adminUserId: v.id("adminUsers"),
+ tokenHash: v.string(),
+ expiresAt: v.number(),
+ createdAt: v.number(),
+ })
+ .index("by_tokenHash", ["tokenHash"])
+ .index("by_adminUserId", ["adminUserId"]),
+
+ siteSettings: defineTable({
+ key: v.string(),
+ value: v.string(),
+ description: v.optional(v.string()),
+ updatedAt: v.number(),
+ }).index("by_key", ["key"]),
+
+ syncJobs: defineTable({
+ kind: v.string(),
+ status: v.union(
+ v.literal("pending"),
+ v.literal("running"),
+ v.literal("completed"),
+ v.literal("failed"),
+ ),
+ message: v.optional(v.string()),
+ metadata: v.optional(v.string()),
+ startedAt: v.optional(v.number()),
+ completedAt: v.optional(v.number()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_kind", ["kind"])
+ .index("by_status", ["status"]),
+
+ voiceSessions: defineTable({
+ roomName: v.string(),
+ participantIdentity: v.string(),
+ siteUrl: v.optional(v.string()),
+ pathname: v.optional(v.string()),
+ pageUrl: v.optional(v.string()),
+ source: v.optional(v.string()),
+ startedAt: v.number(),
+ endedAt: v.optional(v.number()),
+ recordingDisclosureAt: v.optional(v.number()),
+ recordingStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("starting"),
+ v.literal("recording"),
+ v.literal("completed"),
+ v.literal("failed"),
+ ),
+ ),
+ recordingId: v.optional(v.string()),
+ recordingUrl: v.optional(v.string()),
+ recordingError: v.optional(v.string()),
+ metadata: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_roomName", ["roomName"])
+ .index("by_participantIdentity", ["participantIdentity"])
+ .index("by_startedAt", ["startedAt"]),
+
+ voiceTranscriptTurns: defineTable({
+ sessionId: v.id("voiceSessions"),
+ roomName: v.string(),
+ participantIdentity: v.string(),
+ role: v.union(v.literal("user"), v.literal("assistant"), v.literal("system")),
+ kind: v.optional(v.string()),
+ text: v.string(),
+ isFinal: v.optional(v.boolean()),
+ language: v.optional(v.string()),
+ source: v.optional(v.string()),
+ createdAt: v.number(),
+ })
+ .index("by_sessionId", ["sessionId"])
+ .index("by_roomName", ["roomName"])
+ .index("by_createdAt", ["createdAt"]),
+});
diff --git a/convex/tsconfig.json b/convex/tsconfig.json
new file mode 100644
index 00000000..41bfbb9b
--- /dev/null
+++ b/convex/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ /* This TypeScript project config describes the environment that
+ * Convex functions run in and is used to typecheck them.
+ * You can modify it, but some settings are required to use Convex.
+ */
+ "compilerOptions": {
+ /* These settings are not required by Convex and can be modified. */
+ "allowJs": true,
+ "strict": true,
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+
+ /* These compiler options are required by Convex */
+ "target": "ESNext",
+ "lib": ["ES2023", "dom"],
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["./**/*"],
+ "exclude": ["./_generated"]
+}
diff --git a/convex/voiceSessions.ts b/convex/voiceSessions.ts
new file mode 100644
index 00000000..bd9eed73
--- /dev/null
+++ b/convex/voiceSessions.ts
@@ -0,0 +1,129 @@
+// @ts-nocheck
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+export const getByRoom = query({
+ args: {
+ roomName: v.string(),
+ },
+ handler: async (ctx, args) => {
+ return await ctx.db
+ .query("voiceSessions")
+ .withIndex("by_roomName", (q) => q.eq("roomName", args.roomName))
+ .unique();
+ },
+});
+
+export const createSession = mutation({
+ args: {
+ roomName: v.string(),
+ participantIdentity: v.string(),
+ siteUrl: v.optional(v.string()),
+ pathname: v.optional(v.string()),
+ pageUrl: v.optional(v.string()),
+ source: v.optional(v.string()),
+ metadata: v.optional(v.string()),
+ startedAt: v.optional(v.number()),
+ recordingDisclosureAt: v.optional(v.number()),
+ recordingStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("starting"),
+ v.literal("recording"),
+ v.literal("completed"),
+ v.literal("failed"),
+ ),
+ ),
+ },
+ handler: async (ctx, args) => {
+ const now = args.startedAt ?? Date.now();
+ return await ctx.db.insert("voiceSessions", {
+ ...args,
+ startedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+export const addTranscriptTurn = mutation({
+ args: {
+ sessionId: v.id("voiceSessions"),
+ roomName: v.string(),
+ participantIdentity: v.string(),
+ role: v.union(v.literal("user"), v.literal("assistant"), v.literal("system")),
+ text: v.string(),
+ kind: v.optional(v.string()),
+ isFinal: v.optional(v.boolean()),
+ language: v.optional(v.string()),
+ source: v.optional(v.string()),
+ createdAt: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const createdAt = args.createdAt ?? Date.now();
+ return await ctx.db.insert("voiceTranscriptTurns", {
+ ...args,
+ text: args.text.trim(),
+ createdAt,
+ });
+ },
+});
+
+export const updateRecording = mutation({
+ args: {
+ sessionId: v.id("voiceSessions"),
+ recordingStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("starting"),
+ v.literal("recording"),
+ v.literal("completed"),
+ v.literal("failed"),
+ ),
+ ),
+ recordingId: v.optional(v.string()),
+ recordingUrl: v.optional(v.string()),
+ recordingError: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.sessionId, {
+ recordingStatus: args.recordingStatus,
+ recordingId: args.recordingId,
+ recordingUrl: args.recordingUrl,
+ recordingError: args.recordingError,
+ updatedAt: Date.now(),
+ });
+ return await ctx.db.get(args.sessionId);
+ },
+});
+
+export const completeSession = mutation({
+ args: {
+ sessionId: v.id("voiceSessions"),
+ endedAt: v.optional(v.number()),
+ recordingStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("starting"),
+ v.literal("recording"),
+ v.literal("completed"),
+ v.literal("failed"),
+ ),
+ ),
+ recordingId: v.optional(v.string()),
+ recordingUrl: v.optional(v.string()),
+ recordingError: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const endedAt = args.endedAt ?? Date.now();
+ await ctx.db.patch(args.sessionId, {
+ endedAt,
+ recordingStatus: args.recordingStatus,
+ recordingId: args.recordingId,
+ recordingUrl: args.recordingUrl,
+ recordingError: args.recordingError,
+ updatedAt: endedAt,
+ });
+ return await ctx.db.get(args.sessionId);
+ },
+});
diff --git a/lib/manuals-types.ts b/lib/manuals-types.ts
index 1dea372e..4f299a9d 100644
--- a/lib/manuals-types.ts
+++ b/lib/manuals-types.ts
@@ -17,6 +17,10 @@ export interface ManualGroup {
}
}
+function isAbsoluteUrl(value: string) {
+ return /^https?:\/\//i.test(value)
+}
+
/**
* Get public URL for a manual file
* Supports both local static files and external hosting (e.g., Cloudflare)
@@ -26,6 +30,10 @@ export interface ManualGroup {
* - If not set: Uses local static path "/manuals/"
*/
export function getManualUrl(manual: Manual): string {
+ if (isAbsoluteUrl(manual.path)) {
+ return manual.path
+ }
+
// Handle both absolute and relative paths
// If path is absolute (contains /manuals/), extract relative portion
// Otherwise, assume it's already relative
@@ -82,6 +90,10 @@ export function getThumbnailUrl(manual: Manual): string | null {
if (!manual.thumbnailUrl) {
return null
}
+
+ if (isAbsoluteUrl(manual.thumbnailUrl)) {
+ return manual.thumbnailUrl
+ }
// Handle both absolute and relative paths
let relativePath: string
@@ -119,4 +131,3 @@ export function getThumbnailUrl(manual: Manual): string | null {
// Use local static path for GHL static hosting
return `/thumbnails/${encodedPath}`
}
-
diff --git a/lib/voice-assistant/persistence.ts b/lib/voice-assistant/persistence.ts
new file mode 100644
index 00000000..c9265e22
--- /dev/null
+++ b/lib/voice-assistant/persistence.ts
@@ -0,0 +1,266 @@
+import { ConvexHttpClient } from "convex/browser"
+import { makeFunctionReference } from "convex/server"
+import {
+ EgressClient,
+ EncodedFileOutput,
+ EncodedFileType,
+ S3Upload,
+} from "livekit-server-sdk"
+import { VOICE_ASSISTANT_SOURCE } from "@/lib/voice-assistant/types"
+
+type VoiceRecordingStatus = "pending" | "starting" | "recording" | "completed" | "failed"
+
+type CreateVoiceSessionArgs = {
+ roomName: string
+ participantIdentity: string
+ siteUrl?: string
+ pathname?: string
+ pageUrl?: string
+ source?: string
+ metadata?: string
+ startedAt?: number
+ recordingDisclosureAt?: number
+ recordingStatus?: VoiceRecordingStatus
+}
+
+type AddTranscriptTurnArgs = {
+ sessionId: string
+ roomName: string
+ participantIdentity: string
+ role: "user" | "assistant" | "system"
+ text: string
+ kind?: string
+ isFinal?: boolean
+ language?: string
+ source?: string
+ createdAt?: number
+}
+
+type UpdateVoiceRecordingArgs = {
+ sessionId: string
+ recordingStatus?: VoiceRecordingStatus
+ recordingId?: string
+ recordingUrl?: string
+ recordingError?: string
+}
+
+type CompleteVoiceSessionArgs = UpdateVoiceRecordingArgs & {
+ endedAt?: number
+}
+
+type VoiceRecordingConfig = {
+ bucket: string
+ egressClient: EgressClient
+ endpoint: string
+ forcePathStyle: boolean
+ pathPrefix: string
+ publicBaseUrl?: string
+ region: string
+ secret: string
+ accessKey: string
+}
+
+type VoicePersistenceServices = {
+ convexClient: ConvexHttpClient | null
+ recording: VoiceRecordingConfig | null
+}
+
+const CREATE_VOICE_SESSION = makeFunctionReference<"mutation">("voiceSessions:createSession")
+const ADD_VOICE_TRANSCRIPT_TURN = makeFunctionReference<"mutation">("voiceSessions:addTranscriptTurn")
+const UPDATE_VOICE_RECORDING = makeFunctionReference<"mutation">("voiceSessions:updateRecording")
+const COMPLETE_VOICE_SESSION = makeFunctionReference<"mutation">("voiceSessions:completeSession")
+
+function readOptionalEnv(name: string) {
+ const value = process.env[name]
+ return typeof value === "string" && value.trim() ? value.trim() : ""
+}
+
+function readBooleanEnv(name: string) {
+ const value = readOptionalEnv(name).toLowerCase()
+ if (!value) {
+ return undefined
+ }
+
+ return value === "1" || value === "true" || value === "yes"
+}
+
+function sanitizePathSegment(value: string) {
+ return value
+ .replace(/[^a-zA-Z0-9/_-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/\/+/g, "/")
+ .replace(/^-|-$/g, "")
+}
+
+function buildRecordingFilepath(roomName: string) {
+ const prefix = readOptionalEnv("VOICE_RECORDING_PATH_PREFIX") || "livekit-recordings"
+ const date = new Date()
+ const year = date.getUTCFullYear()
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0")
+ const day = String(date.getUTCDate()).padStart(2, "0")
+ const stamp = date.toISOString().replace(/[:.]/g, "-")
+ const safeRoom = sanitizePathSegment(roomName)
+ return `${sanitizePathSegment(prefix)}/${year}/${month}/${day}/${safeRoom}-${stamp}.mp3`
+}
+
+function buildRecordingUrl(publicBaseUrl: string | undefined, bucket: string, filepath: string) {
+ const encodedPath = filepath
+ .split("/")
+ .map((segment) => encodeURIComponent(segment))
+ .join("/")
+
+ if (publicBaseUrl) {
+ return `${publicBaseUrl.replace(/\/$/, "")}/${encodedPath}`
+ }
+
+ return `s3://${bucket}/${filepath}`
+}
+
+export function getVoicePersistenceServices(args: {
+ livekitUrl: string
+ livekitApiKey: string
+ livekitApiSecret: string
+}) {
+ const convexUrl = readOptionalEnv("NEXT_PUBLIC_CONVEX_URL") || readOptionalEnv("CONVEX_URL")
+ const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null
+
+ const accessKey =
+ readOptionalEnv("VOICE_RECORDING_ACCESS_KEY_ID") ||
+ readOptionalEnv("CLOUDFLARE_R2_ACCESS_KEY_ID") ||
+ readOptionalEnv("AWS_ACCESS_KEY_ID") ||
+ readOptionalEnv("AWS_ACCESS_KEY")
+ const secret =
+ readOptionalEnv("VOICE_RECORDING_SECRET_ACCESS_KEY") ||
+ readOptionalEnv("CLOUDFLARE_R2_SECRET_ACCESS_KEY") ||
+ readOptionalEnv("AWS_SECRET_ACCESS_KEY") ||
+ readOptionalEnv("AWS_SECRET_KEY")
+ const endpoint =
+ readOptionalEnv("VOICE_RECORDING_ENDPOINT") ||
+ readOptionalEnv("CLOUDFLARE_R2_ENDPOINT")
+ const bucket = readOptionalEnv("VOICE_RECORDING_BUCKET")
+ const region = readOptionalEnv("VOICE_RECORDING_REGION") || readOptionalEnv("AWS_DEFAULT_REGION") || "auto"
+ const publicBaseUrl = readOptionalEnv("VOICE_RECORDING_PUBLIC_BASE_URL") || undefined
+ const forcePathStyle = readBooleanEnv("VOICE_RECORDING_FORCE_PATH_STYLE") ?? true
+ const requested = readBooleanEnv("VOICE_RECORDING_ENABLED")
+ const hasRecordingEnv = Boolean(accessKey && secret && endpoint && bucket)
+ const recordingEnabled = requested ?? hasRecordingEnv
+
+ const recording =
+ recordingEnabled && hasRecordingEnv
+ ? {
+ accessKey,
+ bucket,
+ egressClient: new EgressClient(args.livekitUrl, args.livekitApiKey, args.livekitApiSecret),
+ endpoint,
+ forcePathStyle,
+ pathPrefix: readOptionalEnv("VOICE_RECORDING_PATH_PREFIX") || "livekit-recordings",
+ publicBaseUrl,
+ region,
+ secret,
+ }
+ : null
+
+ return {
+ convexClient,
+ recording,
+ } satisfies VoicePersistenceServices
+}
+
+async function safeMutation(
+ client: ConvexHttpClient | null,
+ reference: ReturnType>,
+ args: TArgs,
+) {
+ if (!client) {
+ return null
+ }
+
+ return await client.mutation(reference, args)
+}
+
+export async function createVoiceSessionRecord(
+ services: VoicePersistenceServices,
+ args: CreateVoiceSessionArgs,
+) {
+ return await safeMutation(services.convexClient, CREATE_VOICE_SESSION, {
+ ...args,
+ source: args.source || VOICE_ASSISTANT_SOURCE,
+ })
+}
+
+export async function addVoiceTranscriptTurn(
+ services: VoicePersistenceServices,
+ args: AddTranscriptTurnArgs,
+) {
+ const text = args.text.replace(/\s+/g, " ").trim()
+ if (!text) {
+ return null
+ }
+
+ return await safeMutation(services.convexClient, ADD_VOICE_TRANSCRIPT_TURN, {
+ ...args,
+ source: args.source || VOICE_ASSISTANT_SOURCE,
+ text,
+ })
+}
+
+export async function updateVoiceRecording(
+ services: VoicePersistenceServices,
+ args: UpdateVoiceRecordingArgs,
+) {
+ return await safeMutation(services.convexClient, UPDATE_VOICE_RECORDING, args)
+}
+
+export async function completeVoiceSession(
+ services: VoicePersistenceServices,
+ args: CompleteVoiceSessionArgs,
+) {
+ return await safeMutation(services.convexClient, COMPLETE_VOICE_SESSION, args)
+}
+
+export async function startVoiceRecording(
+ services: VoicePersistenceServices,
+ roomName: string,
+) {
+ if (!services.recording) {
+ return null
+ }
+
+ const filepath = buildRecordingFilepath(roomName)
+ const output = new EncodedFileOutput({
+ fileType: EncodedFileType.MP3,
+ filepath,
+ output: {
+ case: "s3",
+ value: new S3Upload({
+ accessKey: services.recording.accessKey,
+ secret: services.recording.secret,
+ region: services.recording.region,
+ endpoint: services.recording.endpoint,
+ bucket: services.recording.bucket,
+ forcePathStyle: services.recording.forcePathStyle,
+ }),
+ },
+ })
+
+ const info = await services.recording.egressClient.startRoomCompositeEgress(roomName, output, {
+ audioOnly: true,
+ })
+
+ return {
+ recordingId: info.egressId,
+ recordingStatus: "recording" as const,
+ recordingUrl: buildRecordingUrl(services.recording.publicBaseUrl, services.recording.bucket, filepath),
+ }
+}
+
+export async function stopVoiceRecording(
+ services: VoicePersistenceServices,
+ recordingId: string | null | undefined,
+) {
+ if (!services.recording || !recordingId) {
+ return null
+ }
+
+ return await services.recording.egressClient.stopEgress(recordingId)
+}
diff --git a/lib/voice-assistant/prompt.ts b/lib/voice-assistant/prompt.ts
new file mode 100644
index 00000000..3f416ab7
--- /dev/null
+++ b/lib/voice-assistant/prompt.ts
@@ -0,0 +1,47 @@
+import { businessConfig, serviceAreas } from "@/lib/seo-config"
+
+const SERVICE_AREA_LIST = serviceAreas.map((area) => area.city).join(", ")
+
+export const VOICE_ASSISTANT_SYSTEM_PROMPT = `You are Jessica, the friendly and upbeat voice 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 caller name, email, and intent are available in context, use them naturally in your opening reply.
+- At the start of every recorded voice session, briefly disclose that the call may be recorded and transcribed for service and quality purposes.
+- Keep spoken replies concise, warm, and natural. Default to 1 to 3 short sentences.
+- End every reply with one short next-step question.
+- For repairs or moving, ask for machine details and a clear description of the issue. If the move is involved, clarify whether it is for a vending machine or a safe. Tell callers to text photos or videos to ${businessConfig.publicSmsNumber} or use the contact form 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.
+
+Safety rules:
+- Never mention prices, service call fees, repair rates, hourly rates, parts costs, or internal policies.
+- If the caller 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.
+
+Lead capture rules:
+- For general contact requests, collect first name, last name, email, phone, optional company, a short message about their needs, required service-text consent, and optional marketing-text consent.
+- For free machine requests, collect first name, last name, email, phone, company, estimated employee count or audience size, desired machine type, desired machine count, optional notes, required service-text consent, and optional marketing-text consent.
+- Before using any submission tool, summarize the collected details and ask for explicit confirmation.
+- Only use a submission tool after the user clearly confirms.
+- If the user does not confirm, keep helping and do not submit anything.
+
+Tool rules:
+- Use submit_contact_lead for general contact or callback requests.
+- Use submit_machine_request for free machine placement or machine request flows.
+- Use handoff_to_human when the user wants direct human help or asks for something uncertain, sensitive, or commercial.
+- After a successful tool call, briefly confirm what happened and what the visitor should expect next.
+`
+
+export function buildHandoffMessage() {
+ return `You can reach ${businessConfig.name} at ${businessConfig.publicCallNumber}, text photos or videos to ${businessConfig.publicSmsNumber}, email ${businessConfig.email}, or use the contact form on ${businessConfig.website}.`
+}
diff --git a/lib/voice-assistant/server.ts b/lib/voice-assistant/server.ts
new file mode 100644
index 00000000..d6f11945
--- /dev/null
+++ b/lib/voice-assistant/server.ts
@@ -0,0 +1,99 @@
+import { randomUUID } from "node:crypto"
+import { AccessToken, RoomAgentDispatch, RoomConfiguration } from "livekit-server-sdk"
+import {
+ getVoiceAssistantAgentName,
+ getVoiceAssistantBootstrap,
+ getVoiceAssistantSiteUrl,
+} from "@/lib/voice-assistant/shared"
+import type {
+ LiveKitTokenResponse,
+ VoiceAssistantVisitorMetadata,
+} from "@/lib/voice-assistant/types"
+import { VOICE_ASSISTANT_SOURCE } from "@/lib/voice-assistant/types"
+
+function readRequiredEnv(name: string) {
+ const value = process.env[name]
+
+ if (!value) {
+ throw new Error(`Missing required voice assistant environment variable: ${name}`)
+ }
+
+ return value
+}
+
+export function getVoiceAssistantServerEnv() {
+ const livekitUrl = readRequiredEnv("LIVEKIT_URL")
+ const livekitApiKey = readRequiredEnv("LIVEKIT_API_KEY")
+ const livekitApiSecret = readRequiredEnv("LIVEKIT_API_SECRET")
+ const xaiApiKey = readRequiredEnv("XAI_API_KEY")
+ const realtimeModel = process.env.XAI_REALTIME_MODEL || "grok-4-1-fast-non-reasoning"
+
+ return {
+ livekitUrl,
+ livekitApiKey,
+ livekitApiSecret,
+ xaiApiKey,
+ realtimeModel,
+ siteUrl: getVoiceAssistantSiteUrl(),
+ }
+}
+
+function buildRoomName() {
+ return `rmv-voice-${randomUUID().slice(0, 8)}`
+}
+
+function buildParticipantIdentity() {
+ return `website-visitor-${randomUUID().slice(0, 10)}`
+}
+
+function buildVisitorMetadata(pathname: string): VoiceAssistantVisitorMetadata {
+ const siteUrl = getVoiceAssistantSiteUrl()
+ const safePathname = pathname.startsWith("/") ? pathname : `/${pathname}`
+
+ return {
+ source: VOICE_ASSISTANT_SOURCE,
+ pathname: safePathname,
+ pageUrl: new URL(safePathname, siteUrl).toString(),
+ startedAt: new Date().toISOString(),
+ }
+}
+
+export async function createVoiceAssistantTokenResponse(pathname: string): Promise {
+ const env = getVoiceAssistantServerEnv()
+ const roomName = buildRoomName()
+ const participantIdentity = buildParticipantIdentity()
+ const visitorMetadata = buildVisitorMetadata(pathname)
+ const agentName = getVoiceAssistantAgentName()
+
+ const token = new AccessToken(env.livekitApiKey, env.livekitApiSecret, {
+ identity: participantIdentity,
+ name: "Website visitor",
+ metadata: JSON.stringify(visitorMetadata),
+ ttl: "1 hour",
+ })
+
+ token.addGrant({
+ roomJoin: true,
+ room: roomName,
+ canPublish: true,
+ canPublishData: true,
+ canSubscribe: true,
+ })
+
+ token.roomConfig = new RoomConfiguration({
+ agents: [
+ new RoomAgentDispatch({
+ agentName,
+ metadata: JSON.stringify(visitorMetadata),
+ }),
+ ],
+ })
+
+ return {
+ serverUrl: env.livekitUrl,
+ participantToken: await token.toJwt(),
+ roomName,
+ participantIdentity,
+ bootstrap: getVoiceAssistantBootstrap(),
+ }
+}
diff --git a/lib/voice-assistant/shared.ts b/lib/voice-assistant/shared.ts
new file mode 100644
index 00000000..6ecb8dfd
--- /dev/null
+++ b/lib/voice-assistant/shared.ts
@@ -0,0 +1,53 @@
+import { businessConfig } from "@/lib/seo-config"
+import {
+ VOICE_ASSISTANT_AGENT_NAME,
+ VOICE_ASSISTANT_SOURCE,
+ XAI_REALTIME_MODEL,
+ XAI_REALTIME_VOICE,
+ type VoiceAssistantBootstrap,
+} from "@/lib/voice-assistant/types"
+
+export const VOICE_ASSISTANT_SUPPRESSED_ROUTE_PREFIXES = [
+ "/admin",
+ "/auth",
+ "/sign-in",
+ "/stripe-setup",
+ "/style-guide",
+ "/manuals/dashboard",
+ "/test-page",
+] as const
+
+export function isVoiceAssistantSuppressedRoute(pathname: string) {
+ return VOICE_ASSISTANT_SUPPRESSED_ROUTE_PREFIXES.some(
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
+ )
+}
+
+export function getVoiceAssistantBootstrap(): VoiceAssistantBootstrap {
+ return {
+ assistantName: "Jessica",
+ businessName: businessConfig.name,
+ phone: businessConfig.publicCallNumber,
+ phoneUrl: businessConfig.publicCallUrl,
+ email: businessConfig.email,
+ model: XAI_REALTIME_MODEL,
+ voice: XAI_REALTIME_VOICE,
+ }
+}
+
+export function getVoiceAssistantSiteUrl() {
+ return (
+ process.env.VOICE_ASSISTANT_SITE_URL ||
+ process.env.NEXT_PUBLIC_APP_URL ||
+ process.env.NEXT_PUBLIC_SITE_URL ||
+ businessConfig.website
+ )
+}
+
+export function getVoiceAssistantAgentName() {
+ return VOICE_ASSISTANT_AGENT_NAME
+}
+
+export function getVoiceAssistantSource() {
+ return VOICE_ASSISTANT_SOURCE
+}
diff --git a/lib/voice-assistant/types.ts b/lib/voice-assistant/types.ts
new file mode 100644
index 00000000..97150fea
--- /dev/null
+++ b/lib/voice-assistant/types.ts
@@ -0,0 +1,81 @@
+export const VOICE_ASSISTANT_AGENT_NAME = "rocky-mountain-vending-voice-assistant"
+export const XAI_REALTIME_MODEL = "grok-4-1-fast-non-reasoning"
+export const XAI_REALTIME_VOICE = "Ara"
+export const VOICE_ASSISTANT_SOURCE = "voice-assistant"
+
+export type VoiceAssistantUiState =
+ | "disconnected"
+ | "connecting"
+ | "connected_text_ready"
+ | "awaiting_mic_permission"
+ | "listening"
+ | "thinking"
+ | "speaking"
+ | "mic_blocked"
+ | "error"
+
+export type VoiceAssistantMicIssueCode =
+ | "permission_denied"
+ | "device_not_found"
+ | "device_in_use"
+ | "publish_failed"
+ | "unsupported"
+ | "unknown"
+
+export type VoiceAssistantMicPermissionState = PermissionState | "unsupported" | "unknown"
+
+export type VoiceAssistantBootstrap = {
+ assistantName: string
+ businessName: string
+ phone: string
+ phoneUrl: string
+ email: string
+ model: typeof XAI_REALTIME_MODEL
+ voice: typeof XAI_REALTIME_VOICE
+}
+
+export type LiveKitTokenResponse = {
+ serverUrl: string
+ participantToken: string
+ roomName: string
+ participantIdentity: string
+ bootstrap: VoiceAssistantBootstrap
+}
+
+export type VoiceAssistantVisitorMetadata = {
+ source: typeof VOICE_ASSISTANT_SOURCE
+ pathname: string
+ pageUrl: string
+ startedAt: string
+}
+
+export type AssistantContactLeadPayload = {
+ firstName: string
+ lastName: string
+ email: string
+ phone: string
+ company?: string
+ message: string
+ serviceTextConsent: true
+ marketingTextConsent?: boolean
+ consentVersion: string
+ consentCapturedAt: string
+ consentSourcePage: string
+}
+
+export type AssistantMachineRequestPayload = {
+ firstName: string
+ lastName: string
+ email: string
+ phone: string
+ company: string
+ employeeCount: string
+ machineType: string
+ machineCount: string
+ message?: string
+ serviceTextConsent: true
+ marketingTextConsent?: boolean
+ consentVersion: string
+ consentCapturedAt: string
+ consentSourcePage: string
+}
diff --git a/livekit-agent/README.md b/livekit-agent/README.md
new file mode 100644
index 00000000..6287eb5b
--- /dev/null
+++ b/livekit-agent/README.md
@@ -0,0 +1,57 @@
+# LiveKit Voice Assistant
+
+This worker runs the Rocky Mountain Vending site voice assistant with LiveKit Agents and xAI realtime voice.
+
+## Local development
+
+1. Start the Next.js app:
+ `pnpm dev`
+2. Start the LiveKit agent worker in a second terminal:
+ `pnpm voice-agent:dev`
+3. Open the site and launch the voice bubble.
+4. Run the automated regressions:
+ `pnpm voice-assistant:test:local`
+5. Run the fake-microphone voice regression:
+ `pnpm voice-assistant:test:local:voice`
+
+## Environment
+
+The worker loads environment variables from:
+- `.env.local`
+- `.env.voice-agent.local` (optional override file)
+
+Required variables:
+- `LIVEKIT_URL`
+- `LIVEKIT_API_KEY`
+- `LIVEKIT_API_SECRET`
+- `XAI_API_KEY`
+- `XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning`
+
+Recommended variable:
+- `VOICE_ASSISTANT_SITE_URL`
+
+## Production
+
+Run the worker as a separate long-lived Node process:
+
+`pnpm voice-agent:start`
+
+Point `VOICE_ASSISTANT_SITE_URL` at the deployed Next.js app so the worker can reuse `/api/contact` and `/api/request-machine`.
+
+## Verification
+
+Automated checks:
+- `pnpm voice-assistant:test:local`
+- `pnpm voice-assistant:test:local:voice`
+- `pnpm voice-assistant:test:live`
+
+Manual spoken smoke checklist:
+1. Open the homepage and launch the assistant bubble.
+2. Press `Start assistant` and confirm the panel reaches `Text Ready` or `Listening`.
+3. Confirm the text box is enabled before microphone access is granted.
+4. Press `Enable microphone` and verify the browser permission prompt appears.
+5. After granting permission, say a short question such as "What cities in Utah do you serve?"
+6. Confirm you see a user transcript, then an assistant reply transcript.
+7. Confirm spoken audio playback is audible after `Enable spoken replies` when the browser requires it.
+8. Refresh the page and verify reconnect still works.
+9. Open `/sign-in` and confirm the assistant stays hidden there.
diff --git a/livekit-agent/worker.ts b/livekit-agent/worker.ts
new file mode 100644
index 00000000..b742bb28
--- /dev/null
+++ b/livekit-agent/worker.ts
@@ -0,0 +1,722 @@
+import { fileURLToPath } from "node:url"
+import { config as loadEnv } from "dotenv"
+import { AutoSubscribe, cli, defineAgent, llm, type JobContext, voice, WorkerOptions } from "@livekit/agents"
+import * as xai from "@livekit/agents-plugin-xai"
+import { z } from "zod"
+import { SMS_CONSENT_VERSION } from "../lib/sms-compliance"
+import {
+ addVoiceTranscriptTurn,
+ completeVoiceSession,
+ createVoiceSessionRecord,
+ getVoicePersistenceServices,
+ startVoiceRecording,
+ stopVoiceRecording,
+ updateVoiceRecording,
+} from "../lib/voice-assistant/persistence"
+import { buildHandoffMessage, VOICE_ASSISTANT_SYSTEM_PROMPT } from "../lib/voice-assistant/prompt"
+import { getVoiceAssistantServerEnv } from "../lib/voice-assistant/server"
+import {
+ VOICE_ASSISTANT_AGENT_NAME,
+ VOICE_ASSISTANT_SOURCE,
+ XAI_REALTIME_VOICE,
+ type AssistantContactLeadPayload,
+ type AssistantMachineRequestPayload,
+ type VoiceAssistantVisitorMetadata,
+} from "../lib/voice-assistant/types"
+
+loadEnv({ path: ".env.local" })
+loadEnv({ path: ".env.voice-agent.local", override: true })
+
+const env = getVoiceAssistantServerEnv()
+const persistenceServices = getVoicePersistenceServices({
+ livekitUrl: env.livekitUrl,
+ livekitApiKey: env.livekitApiKey,
+ livekitApiSecret: env.livekitApiSecret,
+})
+
+type WorkerUserData = {
+ siteUrl: string
+ pathname: string
+ pageUrl: string
+ startedAt: string
+}
+
+type ApiSuccess = {
+ success: boolean
+ message?: string
+ error?: string
+}
+
+type VoiceSessionPersistenceState = {
+ recordingId: string | null
+ recordingStatus: "pending" | "starting" | "recording" | "completed" | "failed"
+ recordingUrl: string | null
+ sessionId: string | null
+}
+
+function serializeError(error: unknown) {
+ if (error instanceof Error) {
+ return {
+ name: error.name,
+ message: error.message,
+ }
+ }
+
+ return {
+ message: String(error),
+ }
+}
+
+function truncateText(value: string, maxLength = 160) {
+ const normalized = value.replace(/\s+/g, " ").trim()
+ if (normalized.length <= maxLength) {
+ return normalized
+ }
+
+ return `${normalized.slice(0, maxLength - 1)}...`
+}
+
+function logWorker(event: string, payload: Record = {}) {
+ console.info("[voice-agent]", event, payload)
+}
+
+function parseVisitorMetadata(metadata: string | undefined): VoiceAssistantVisitorMetadata {
+ if (!metadata) {
+ return {
+ source: VOICE_ASSISTANT_SOURCE,
+ pathname: "/",
+ pageUrl: env.siteUrl,
+ startedAt: new Date().toISOString(),
+ }
+ }
+
+ try {
+ const parsed = JSON.parse(metadata) as Partial
+
+ return {
+ source: VOICE_ASSISTANT_SOURCE,
+ pathname: typeof parsed.pathname === "string" && parsed.pathname ? parsed.pathname : "/",
+ pageUrl: typeof parsed.pageUrl === "string" && parsed.pageUrl ? parsed.pageUrl : env.siteUrl,
+ startedAt: typeof parsed.startedAt === "string" && parsed.startedAt ? parsed.startedAt : new Date().toISOString(),
+ }
+ } catch {
+ return {
+ source: VOICE_ASSISTANT_SOURCE,
+ pathname: "/",
+ pageUrl: env.siteUrl,
+ startedAt: new Date().toISOString(),
+ }
+ }
+}
+
+function hasExplicitConfirmation(confirmationMessage: string) {
+ return /\b(yes|yeah|yep|correct|confirm|confirmed|go ahead|submit|sounds good|please do)\b/i.test(
+ confirmationMessage,
+ )
+}
+
+function assertExplicitConfirmation(confirmationMessage: string) {
+ if (!hasExplicitConfirmation(confirmationMessage)) {
+ throw new Error("The visitor has not clearly confirmed the submission yet.")
+ }
+}
+
+async function postJson(pathname: string, payload: TPayload) {
+ logWorker("api_request_start", { pathname })
+
+ const response = await fetch(new URL(pathname, env.siteUrl), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+
+ const data = (await response.json().catch(() => ({}))) as ApiSuccess
+
+ if (!response.ok || !data.success) {
+ throw new Error(data.error || data.message || `Request to ${pathname} failed.`)
+ }
+
+ logWorker("api_request_success", { pathname })
+ return data
+}
+
+function buildContactPayload(args: AssistantContactLeadPayload, userData: WorkerUserData) {
+ return {
+ ...args,
+ source: VOICE_ASSISTANT_SOURCE,
+ page: userData.pathname,
+ timestamp: new Date().toISOString(),
+ url: userData.pageUrl,
+ }
+}
+
+function buildMachineRequestPayload(args: AssistantMachineRequestPayload, userData: WorkerUserData) {
+ return {
+ ...args,
+ machineType: args.machineType
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .join(","),
+ source: VOICE_ASSISTANT_SOURCE,
+ page: userData.pathname,
+ timestamp: new Date().toISOString(),
+ url: userData.pageUrl,
+ }
+}
+
+function buildConsentMetadata(userData: WorkerUserData) {
+ return {
+ consentVersion: SMS_CONSENT_VERSION,
+ consentCapturedAt: new Date().toISOString(),
+ consentSourcePage: userData.pathname,
+ }
+}
+
+function extractConversationItemText(item: unknown): string {
+ if (typeof item === "string") {
+ return item.replace(/\s+/g, " ").trim()
+ }
+
+ if (!item || typeof item !== "object") {
+ return ""
+ }
+
+ if ("text" in item && typeof item.text === "string") {
+ return item.text.replace(/\s+/g, " ").trim()
+ }
+
+ if ("transcript" in item && typeof item.transcript === "string") {
+ return item.transcript.replace(/\s+/g, " ").trim()
+ }
+
+ if ("content" in item) {
+ const { content } = item as { content?: unknown }
+ if (Array.isArray(content)) {
+ return content
+ .map((part) => extractConversationItemText(part))
+ .filter(Boolean)
+ .join(" ")
+ .replace(/\s+/g, " ")
+ .trim()
+ }
+
+ if (typeof content === "string") {
+ return content.replace(/\s+/g, " ").trim()
+ }
+ }
+
+ return ""
+}
+
+function createTools() {
+ return {
+ submit_contact_lead: llm.tool({
+ description:
+ "Submit a general contact or callback request after the visitor clearly confirms the details.",
+ parameters: z.object({
+ firstName: z.string().min(1).describe("Visitor first name"),
+ lastName: z.string().min(1).describe("Visitor last name"),
+ email: z.string().email().describe("Visitor email address"),
+ phone: z.string().min(7).describe("Visitor phone number"),
+ company: z.string().optional().describe("Visitor company name if they shared one"),
+ message: z.string().min(10).describe("Short summary of what the visitor needs"),
+ serviceTextConsent: z
+ .literal(true)
+ .describe("Set to true only if the visitor explicitly agrees to service-related SMS follow-up."),
+ marketingTextConsent: z
+ .boolean()
+ .optional()
+ .describe("Optional marketing SMS consent when the visitor clearly opts in."),
+ confirmed: z.literal(true).describe("Set to true only after the visitor explicitly confirms submission."),
+ confirmationMessage: z
+ .string()
+ .min(2)
+ .describe("The visitor's confirmation words, such as 'yes, go ahead and submit that'."),
+ }),
+ execute: async ({ confirmed: _confirmed, confirmationMessage, ...args }, { ctx }) => {
+ assertExplicitConfirmation(confirmationMessage)
+ const userData = ctx.userData as WorkerUserData
+ const payload = buildContactPayload(
+ {
+ ...args,
+ ...buildConsentMetadata(userData),
+ },
+ userData,
+ )
+
+ logWorker("tool_start", {
+ tool: "submit_contact_lead",
+ pathname: userData.pathname,
+ email: args.email,
+ })
+
+ try {
+ const result = await postJson("/api/contact", payload)
+ logWorker("tool_success", {
+ tool: "submit_contact_lead",
+ pathname: userData.pathname,
+ email: args.email,
+ })
+
+ return {
+ success: true,
+ message:
+ result.message ||
+ "Thanks, your contact request has been sent to Rocky Mountain Vending."
+ }
+ } catch (error) {
+ console.error("[voice-agent] tool_failure", {
+ tool: "submit_contact_lead",
+ pathname: userData.pathname,
+ email: args.email,
+ error: serializeError(error),
+ })
+ throw error
+ }
+ },
+ }),
+ submit_machine_request: llm.tool({
+ description:
+ "Submit a free machine placement or machine request lead after the visitor explicitly confirms and agrees to service-related follow-up.",
+ parameters: z.object({
+ firstName: z.string().min(1).describe("Visitor first name"),
+ lastName: z.string().min(1).describe("Visitor last name"),
+ email: z.string().email().describe("Visitor email address"),
+ phone: z.string().min(7).describe("Visitor phone number"),
+ company: z.string().min(1).describe("Business or organization name"),
+ employeeCount: z
+ .string()
+ .min(1)
+ .describe("Approximate employee count or audience size as a plain number string"),
+ machineType: z
+ .string()
+ .min(1)
+ .describe("Desired machine type or a comma-separated list like snack, beverage, combo"),
+ machineCount: z.string().min(1).describe("Desired number of machines as a plain number string"),
+ message: z.string().optional().describe("Optional visitor notes or placement details"),
+ serviceTextConsent: z
+ .literal(true)
+ .describe("Set to true only if the visitor explicitly agrees to service-related SMS follow-up."),
+ marketingTextConsent: z
+ .boolean()
+ .optional()
+ .describe("Optional marketing SMS consent when the visitor clearly opts in."),
+ confirmed: z.literal(true).describe("Set to true only after the visitor explicitly confirms submission."),
+ confirmationMessage: z
+ .string()
+ .min(2)
+ .describe("The visitor's confirmation words, such as 'yes, please submit it'."),
+ }),
+ execute: async ({ confirmed: _confirmed, confirmationMessage, ...args }, { ctx }) => {
+ assertExplicitConfirmation(confirmationMessage)
+ const userData = ctx.userData as WorkerUserData
+
+ const payload = buildMachineRequestPayload(
+ {
+ ...args,
+ ...buildConsentMetadata(userData),
+ },
+ userData,
+ )
+
+ logWorker("tool_start", {
+ tool: "submit_machine_request",
+ pathname: userData.pathname,
+ email: args.email,
+ company: args.company,
+ })
+
+ try {
+ const result = await postJson("/api/request-machine", payload)
+ logWorker("tool_success", {
+ tool: "submit_machine_request",
+ pathname: userData.pathname,
+ email: args.email,
+ company: args.company,
+ })
+
+ return {
+ success: true,
+ message:
+ result.message ||
+ "Thanks, your machine request has been sent to Rocky Mountain Vending."
+ }
+ } catch (error) {
+ console.error("[voice-agent] tool_failure", {
+ tool: "submit_machine_request",
+ pathname: userData.pathname,
+ email: args.email,
+ company: args.company,
+ error: serializeError(error),
+ })
+ throw error
+ }
+ },
+ }),
+ handoff_to_human: llm.tool({
+ description:
+ "Use this when the visitor wants a human right away or asks for something uncertain, legal, pricing-related, or highly specific.",
+ parameters: z.object({
+ reason: z.string().min(2).describe("Short reason for the human handoff"),
+ }),
+ execute: async ({ reason }) => {
+ logWorker("tool_success", {
+ tool: "handoff_to_human",
+ reason: truncateText(reason, 120),
+ })
+
+ return {
+ success: true,
+ message: `${buildHandoffMessage()} Reason: ${reason}`,
+ }
+ },
+ }),
+ }
+}
+
+async function startSession(ctx: JobContext) {
+ const roomName = ctx.room.name || `rmv-voice-${ctx.workerId}`
+
+ logWorker("job_connect_start", {
+ room: roomName,
+ workerId: ctx.workerId,
+ })
+ await ctx.connect(undefined, AutoSubscribe.AUDIO_ONLY)
+ logWorker("job_connect_ready", {
+ room: roomName,
+ workerId: ctx.workerId,
+ })
+
+ const participant = await ctx.waitForParticipant()
+ const visitor = parseVisitorMetadata(participant.metadata)
+ const userData: WorkerUserData = {
+ siteUrl: env.siteUrl,
+ pathname: visitor.pathname,
+ pageUrl: visitor.pageUrl,
+ startedAt: visitor.startedAt,
+ }
+ const persistenceState: VoiceSessionPersistenceState = {
+ sessionId: null,
+ recordingId: null,
+ recordingStatus: persistenceServices.recording ? "pending" : "failed",
+ recordingUrl: null,
+ }
+
+ logWorker("participant_joined", {
+ identity: participant.identity,
+ room: ctx.room.name,
+ pathname: userData.pathname,
+ pageUrl: userData.pageUrl,
+ })
+
+ ctx.room.on("trackPublished", (publication, remoteParticipant) => {
+ if (remoteParticipant.identity !== participant.identity) {
+ return
+ }
+
+ try {
+ publication.setSubscribed(true)
+ logWorker("remote_track_subscription_requested", {
+ participantIdentity: remoteParticipant.identity,
+ source: publication.source,
+ kind: publication.kind,
+ trackSid: publication.sid,
+ })
+ } catch (error) {
+ console.error("[voice-agent] remote_track_subscription_failed", {
+ participantIdentity: remoteParticipant.identity,
+ source: publication.source,
+ kind: publication.kind,
+ trackSid: publication.sid,
+ error: serializeError(error),
+ })
+ }
+
+ logWorker("remote_track_published", {
+ participantIdentity: remoteParticipant.identity,
+ source: publication.source,
+ kind: publication.kind,
+ trackSid: publication.sid,
+ })
+ })
+
+ ctx.room.on("trackSubscribed", (_track, publication, remoteParticipant) => {
+ if (remoteParticipant.identity !== participant.identity) {
+ return
+ }
+
+ logWorker("remote_track_subscribed", {
+ participantIdentity: remoteParticipant.identity,
+ source: publication.source,
+ kind: publication.kind,
+ trackSid: publication.sid,
+ })
+ })
+
+ try {
+ const sessionId = await createVoiceSessionRecord(persistenceServices, {
+ roomName,
+ participantIdentity: participant.identity,
+ siteUrl: userData.siteUrl,
+ pathname: userData.pathname,
+ pageUrl: userData.pageUrl,
+ source: VOICE_ASSISTANT_SOURCE,
+ metadata: JSON.stringify(visitor),
+ recordingDisclosureAt: Date.now(),
+ recordingStatus: persistenceServices.recording ? "pending" : undefined,
+ startedAt: Date.parse(userData.startedAt) || Date.now(),
+ })
+
+ persistenceState.sessionId = typeof sessionId === "string" ? sessionId : null
+ logWorker("voice_session_persisted", {
+ room: ctx.room.name,
+ participantIdentity: participant.identity,
+ persisted: Boolean(persistenceState.sessionId),
+ })
+ } catch (error) {
+ console.error("[voice-agent] session_persistence_failed", {
+ room: roomName,
+ participantIdentity: participant.identity,
+ error: serializeError(error),
+ })
+ }
+
+ const session = new voice.AgentSession({
+ llm: new xai.realtime.RealtimeModel({
+ apiKey: env.xaiApiKey,
+ model: env.realtimeModel,
+ voice: XAI_REALTIME_VOICE,
+ }),
+ userData,
+ turnHandling: {
+ interruption: {
+ enabled: true,
+ },
+ },
+ })
+
+ session.on(voice.AgentSessionEventTypes.AgentStateChanged, (event) => {
+ logWorker("agent_state_changed", {
+ oldState: event.oldState,
+ newState: event.newState,
+ })
+ })
+
+ session.on(voice.AgentSessionEventTypes.UserStateChanged, (event) => {
+ logWorker("user_state_changed", {
+ oldState: event.oldState,
+ newState: event.newState,
+ })
+ })
+
+ session.on(voice.AgentSessionEventTypes.UserInputTranscribed, (event) => {
+ logWorker("user_input_transcribed", {
+ isFinal: event.isFinal,
+ language: event.language,
+ transcript: truncateText(event.transcript, 160),
+ })
+
+ if (!event.isFinal || !persistenceState.sessionId) {
+ return
+ }
+
+ void addVoiceTranscriptTurn(persistenceServices, {
+ sessionId: persistenceState.sessionId,
+ roomName,
+ participantIdentity: participant.identity,
+ role: "user",
+ text: event.transcript,
+ kind: "transcription",
+ isFinal: event.isFinal,
+ language: event.language ?? undefined,
+ }).catch((error) => {
+ console.error("[voice-agent] transcript_persist_failed", {
+ role: "user",
+ error: serializeError(error),
+ })
+ })
+ })
+
+ session.on(voice.AgentSessionEventTypes.ConversationItemAdded, (event) => {
+ const role = "role" in event.item ? event.item.role : "unknown"
+ const text = extractConversationItemText(event.item)
+
+ logWorker("conversation_item_added", {
+ role,
+ preview: truncateText(JSON.stringify(event.item), 180),
+ })
+
+ if (role !== "assistant" || !text || !persistenceState.sessionId) {
+ return
+ }
+
+ void addVoiceTranscriptTurn(persistenceServices, {
+ sessionId: persistenceState.sessionId,
+ roomName,
+ participantIdentity: participant.identity,
+ role: "assistant",
+ text,
+ kind: "response",
+ source: VOICE_ASSISTANT_SOURCE,
+ }).catch((error) => {
+ console.error("[voice-agent] transcript_persist_failed", {
+ role: "assistant",
+ error: serializeError(error),
+ })
+ })
+ })
+
+ session.on(voice.AgentSessionEventTypes.FunctionToolsExecuted, (event) => {
+ logWorker("function_tools_executed", {
+ toolNames: event.functionCalls.map((call) => call.name),
+ outputs: event.functionCallOutputs.length,
+ })
+ })
+
+ session.on(voice.AgentSessionEventTypes.SpeechCreated, (event) => {
+ logWorker("speech_created", {
+ source: event.source,
+ userInitiated: event.userInitiated,
+ })
+ })
+
+ session.on(voice.AgentSessionEventTypes.MetricsCollected, (event) => {
+ logWorker("metrics_collected", {
+ metricType: event.metrics.type,
+ })
+ })
+
+ session.on(voice.AgentSessionEventTypes.Error, (event) => {
+ console.error("[voice-agent] session_error", {
+ source: event.source?.constructor?.name || "unknown",
+ error: serializeError(event.error),
+ })
+ })
+
+ session.on(voice.AgentSessionEventTypes.Close, (event) => {
+ logWorker("session_closed", {
+ reason: event.reason,
+ error: event.error ? serializeError(event.error) : null,
+ })
+
+ void (async () => {
+ try {
+ if (persistenceState.recordingId) {
+ await stopVoiceRecording(persistenceServices, persistenceState.recordingId)
+ }
+ } catch (error) {
+ console.error("[voice-agent] recording_stop_failed", {
+ recordingId: persistenceState.recordingId,
+ error: serializeError(error),
+ })
+ }
+
+ if (!persistenceState.sessionId) {
+ return
+ }
+
+ try {
+ await completeVoiceSession(persistenceServices, {
+ sessionId: persistenceState.sessionId,
+ endedAt: Date.now(),
+ recordingId: persistenceState.recordingId ?? undefined,
+ recordingStatus: persistenceState.recordingId ? "completed" : persistenceState.recordingStatus,
+ recordingUrl: persistenceState.recordingUrl ?? undefined,
+ recordingError: event.error ? serializeError(event.error).message : undefined,
+ })
+ } catch (error) {
+ console.error("[voice-agent] session_complete_failed", {
+ sessionId: persistenceState.sessionId,
+ error: serializeError(error),
+ })
+ }
+ })()
+ })
+
+ const agent = new voice.Agent({
+ instructions: VOICE_ASSISTANT_SYSTEM_PROMPT,
+ tools: createTools(),
+ })
+
+ logWorker("session_starting", {
+ room: roomName,
+ participantIdentity: participant.identity,
+ pathname: userData.pathname,
+ model: env.realtimeModel,
+ })
+ await session.start({ agent, room: ctx.room })
+ logWorker("session_started", {
+ room: roomName,
+ participantIdentity: participant.identity,
+ })
+
+ if (persistenceServices.recording && persistenceState.sessionId) {
+ try {
+ persistenceState.recordingStatus = "starting"
+ await updateVoiceRecording(persistenceServices, {
+ sessionId: persistenceState.sessionId,
+ recordingStatus: "starting",
+ })
+
+ const recording = await startVoiceRecording(persistenceServices, roomName)
+ if (recording) {
+ persistenceState.recordingId = recording.recordingId
+ persistenceState.recordingStatus = recording.recordingStatus
+ persistenceState.recordingUrl = recording.recordingUrl
+
+ await updateVoiceRecording(persistenceServices, {
+ sessionId: persistenceState.sessionId,
+ recordingStatus: recording.recordingStatus,
+ recordingId: recording.recordingId,
+ recordingUrl: recording.recordingUrl,
+ })
+
+ logWorker("recording_started", {
+ room: roomName,
+ participantIdentity: participant.identity,
+ recordingId: recording.recordingId,
+ recordingUrl: recording.recordingUrl,
+ })
+ }
+ } catch (error) {
+ persistenceState.recordingStatus = "failed"
+
+ console.error("[voice-agent] recording_start_failed", {
+ room: roomName,
+ participantIdentity: participant.identity,
+ error: serializeError(error),
+ })
+
+ await updateVoiceRecording(persistenceServices, {
+ sessionId: persistenceState.sessionId,
+ recordingStatus: "failed",
+ recordingError: error instanceof Error ? error.message : String(error),
+ }).catch((persistError) => {
+ console.error("[voice-agent] recording_failure_persist_failed", {
+ error: serializeError(persistError),
+ })
+ })
+ }
+ }
+}
+
+export default defineAgent({
+ entry: async (ctx) => {
+ await startSession(ctx)
+ },
+})
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ cli.runApp(
+ new WorkerOptions({
+ agent: fileURLToPath(import.meta.url),
+ agentName: VOICE_ASSISTANT_AGENT_NAME,
+ wsURL: env.livekitUrl,
+ apiKey: env.livekitApiKey,
+ apiSecret: env.livekitApiSecret,
+ }),
+ )
+}
diff --git a/package.json b/package.json
index 389a840b..9f644294 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,25 @@
{
- "name": "rocky-mountain-vending",
+ "name": "my-v0-project",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
+ "deploy:staging:env": "node scripts/deploy-readiness.mjs",
+ "deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
+ "deploy:staging:smoke": "node scripts/staging-smoke.mjs",
+ "typecheck": "tsc --noEmit",
+ "manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
+ "manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
+ "convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `npx convex dev` or `npx convex codegen` after configuring a deployment to replace them with typed output.')\"",
"dev": "next dev",
"lint": "eslint .",
+ "voice-agent:dev": "tsx livekit-agent/worker.ts dev",
+ "voice-agent:start": "tsx livekit-agent/worker.ts start",
+ "voice-assistant:test:local": "node scripts/voice-assistant-regression.mjs --base-url http://127.0.0.1:3000 --label local",
+ "voice-assistant:test:local:voice": "node scripts/voice-assistant-regression.mjs --base-url http://127.0.0.1:3000 --fake-media --label local-voice",
+ "voice-assistant:test:live": "node scripts/voice-assistant-regression.mjs --base-url https://rockymountainvending.com --fake-media --label live",
"start": "next start",
- "test": "tsx --test app/api/contact/route.test.ts",
"lighthouse:dev": "node scripts/lighthouse-test.js --dev",
"lighthouse:build": "node scripts/lighthouse-test.js",
"lighthouse:ci": "lighthouse-ci autorun",
@@ -24,9 +35,12 @@
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
},
"dependencies": {
- "@aws-sdk/client-sesv2": "^3.888.0",
"@aws-sdk/client-s3": "^3.0.0",
"@hookform/resolvers": "^3.10.0",
+ "@livekit/agents": "^1.2.1",
+ "@livekit/agents-plugin-xai": "^1.2.1",
+ "@livekit/components-react": "^2.9.20",
+ "@livekit/components-styles": "^1.2.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
@@ -55,14 +69,20 @@
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@stripe/stripe-js": "^4.0.0",
+ "@supabase/ssr": "^0.8.0",
+ "@supabase/supabase-js": "^2.95.3",
"@vercel/analytics": "latest",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
+ "convex": "^1.34.0",
"date-fns": "4.1.0",
+ "dotenv": "^17.3.1",
"embla-carousel-react": "8.5.1",
"input-otp": "1.4.1",
+ "livekit-client": "^2.18.0",
+ "livekit-server-sdk": "^2.15.0",
"lucide-react": "^0.454.0",
"next": "16.0.0",
"next-themes": "^0.4.6",
@@ -76,7 +96,6 @@
"stripe": "^17.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
- "usesend-js": "^1.6.3",
"vaul": "^0.9.9",
"zod": "3.25.76"
},
@@ -88,9 +107,10 @@
"@types/react-dom": "^19",
"chrome-launcher": "^1.1.0",
"lighthouse": "^12.0.0",
+ "playwright": "^1.58.2",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
- "tsx": "^4.20.6",
+ "tsx": "^4.21.0",
"tw-animate-css": "1.3.3",
"typescript": "^5",
"wait-on": "^8.0.1",
diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png
new file mode 100644
index 00000000..c14b0e0c
Binary files /dev/null and b/public/android-chrome-192x192.png differ
diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png
new file mode 100644
index 00000000..e5f5cc19
Binary files /dev/null and b/public/android-chrome-512x512.png differ
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 00000000..817f611d
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
new file mode 100644
index 00000000..da1ff266
Binary files /dev/null and b/public/favicon-16x16.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
new file mode 100644
index 00000000..fe443f0e
Binary files /dev/null and b/public/favicon-32x32.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 00000000..eefd1833
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/scripts/deploy-readiness.mjs b/scripts/deploy-readiness.mjs
new file mode 100644
index 00000000..3e1ba5fb
--- /dev/null
+++ b/scripts/deploy-readiness.mjs
@@ -0,0 +1,238 @@
+import { spawnSync } from "node:child_process"
+import { existsSync } from "node:fs"
+import path from "node:path"
+import process from "node:process"
+import dotenv from "dotenv"
+
+const REQUIRED_ENV_GROUPS = [
+ {
+ label: "Core site",
+ keys: ["NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_SITE_DOMAIN", "NEXT_PUBLIC_CONVEX_URL"],
+ },
+ {
+ label: "Voice and chat",
+ keys: ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET", "XAI_API_KEY", "VOICE_ASSISTANT_SITE_URL"],
+ },
+ {
+ label: "Admin and auth",
+ keys: ["ADMIN_EMAIL", "ADMIN_PASSWORD", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"],
+ },
+ {
+ label: "Stripe",
+ keys: ["STRIPE_SECRET_KEY", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"],
+ },
+]
+
+const OPTIONAL_ENV_GROUPS = [
+ {
+ label: "Manual asset delivery",
+ keys: ["NEXT_PUBLIC_MANUALS_BASE_URL", "NEXT_PUBLIC_THUMBNAILS_BASE_URL"],
+ note: "Falling back to the site's local /manuals and /thumbnails paths.",
+ },
+]
+
+const IGNORED_HANDOFF_ENV = {
+ GHL_API_TOKEN: "not used by the current code path",
+ ADMIN_API_TOKEN: "not used by the current code path",
+ ADMIN_UI_ENABLED: "not used by the current code path",
+ USESEND_API_KEY: "not used by the current code path",
+ USESEND_BASE_URL: "not used by the current code path",
+ CONVEX_URL: "use NEXT_PUBLIC_CONVEX_URL instead",
+ CONVEX_SELF_HOSTED_URL: "not used by the current code path",
+ CONVEX_SELF_HOSTED_ADMIN_KEY: "not used by the current code path",
+}
+
+function parseArgs(argv) {
+ const args = {
+ envFile: ".env.local",
+ build: false,
+ allowDirty: false,
+ }
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index]
+
+ if (token === "--build") {
+ args.build = true
+ continue
+ }
+
+ if (token === "--allow-dirty") {
+ args.allowDirty = true
+ continue
+ }
+
+ if (token === "--env-file") {
+ args.envFile = argv[index + 1]
+ index += 1
+ continue
+ }
+ }
+
+ return args
+}
+
+function runShell(command, options = {}) {
+ const result = spawnSync(command, {
+ shell: true,
+ cwd: process.cwd(),
+ encoding: "utf8",
+ stdio: options.inherit ? "inherit" : "pipe",
+ })
+
+ if (result.error) {
+ throw result.error
+ }
+
+ return {
+ status: result.status ?? 1,
+ stdout: result.stdout ?? "",
+ stderr: result.stderr ?? "",
+ }
+}
+
+function readValue(name) {
+ return String(process.env[name] ?? "").trim()
+}
+
+function hasVoiceRecordingConfig() {
+ return [
+ readValue("VOICE_RECORDING_ACCESS_KEY_ID") || readValue("CLOUDFLARE_R2_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY"),
+ readValue("VOICE_RECORDING_SECRET_ACCESS_KEY") || readValue("CLOUDFLARE_R2_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_KEY"),
+ readValue("VOICE_RECORDING_ENDPOINT") || readValue("CLOUDFLARE_R2_ENDPOINT"),
+ readValue("VOICE_RECORDING_BUCKET"),
+ ].every(Boolean)
+}
+
+function heading(title) {
+ console.log(`\n== ${title} ==`)
+}
+
+function main() {
+ const args = parseArgs(process.argv.slice(2))
+ const failures = []
+ const warnings = []
+ const envFilePath = path.resolve(process.cwd(), args.envFile)
+
+ if (args.envFile && existsSync(envFilePath)) {
+ dotenv.config({ path: envFilePath, override: false })
+ } else if (args.envFile) {
+ warnings.push(`Env file not found: ${envFilePath}`)
+ }
+
+ heading("Repository")
+
+ const branchResult = runShell("git rev-parse --abbrev-ref HEAD")
+ const branch = branchResult.status === 0 ? branchResult.stdout.trim() : "unknown"
+ console.log(`Branch: ${branch}`)
+ if (branch !== "main") {
+ failures.push(`Release branch must be main. Current branch is ${branch}.`)
+ }
+
+ const remoteResult = runShell("git remote get-url origin")
+ if (remoteResult.status === 0) {
+ console.log(`Origin: ${remoteResult.stdout.trim()}`)
+ } else {
+ failures.push("Unable to resolve git remote origin.")
+ }
+
+ const statusResult = runShell("git status --short")
+ const worktreeStatus = statusResult.stdout.trim()
+ if (worktreeStatus) {
+ console.log("Worktree: dirty")
+ if (!args.allowDirty) {
+ failures.push("Git worktree is dirty. Deploy only from a clean reviewed commit on main.")
+ }
+ } else {
+ console.log("Worktree: clean")
+ }
+
+ console.log(`Dockerfile: ${existsSync(path.join(process.cwd(), "Dockerfile")) ? "present" : "missing"}`)
+ console.log(`next.config.mjs: ${existsSync(path.join(process.cwd(), "next.config.mjs")) ? "present" : "missing"}`)
+
+ if (!existsSync(path.join(process.cwd(), "Dockerfile"))) {
+ failures.push("Dockerfile is missing from the repository root.")
+ }
+
+ if (!existsSync(path.join(process.cwd(), "next.config.mjs"))) {
+ failures.push("next.config.mjs is missing from the repository root.")
+ }
+
+ heading("Environment")
+
+ for (const group of REQUIRED_ENV_GROUPS) {
+ const missingKeys = group.keys.filter((key) => !readValue(key))
+ if (missingKeys.length === 0) {
+ console.log(`${group.label}: ok`)
+ continue
+ }
+
+ failures.push(`${group.label} missing: ${missingKeys.join(", ")}`)
+ console.log(`${group.label}: missing ${missingKeys.join(", ")}`)
+ }
+
+ for (const group of OPTIONAL_ENV_GROUPS) {
+ const missingKeys = group.keys.filter((key) => !readValue(key))
+ if (missingKeys.length === 0) {
+ console.log(`${group.label}: ok`)
+ continue
+ }
+
+ warnings.push(`${group.label} missing: ${missingKeys.join(", ")}. ${group.note}`)
+ console.log(`${group.label}: fallback in use`)
+ }
+
+ const recordingRequested = readValue("VOICE_RECORDING_ENABLED").toLowerCase()
+ if (recordingRequested === "true" && !hasVoiceRecordingConfig()) {
+ failures.push(
+ "Voice recording is enabled but recording storage is incomplete. Set VOICE_RECORDING_BUCKET plus access key, secret, and endpoint env vars.",
+ )
+ } else if (hasVoiceRecordingConfig()) {
+ console.log("Voice recordings: storage config present")
+ } else {
+ warnings.push("Voice recording storage env vars are not configured yet.")
+ console.log("Voice recordings: storage config missing")
+ }
+
+ for (const [key, note] of Object.entries(IGNORED_HANDOFF_ENV)) {
+ if (readValue(key)) {
+ warnings.push(`${key} is present but ${note}.`)
+ }
+ }
+
+ heading("Build")
+
+ if (args.build) {
+ const buildResult = runShell("pnpm build", { inherit: true })
+ if (buildResult.status !== 0) {
+ failures.push("pnpm build failed.")
+ } else {
+ console.log("pnpm build: ok")
+ }
+ } else {
+ console.log("Build skipped. Re-run with --build for full preflight.")
+ }
+
+ heading("Summary")
+
+ if (warnings.length > 0) {
+ console.log("Warnings:")
+ for (const warning of warnings) {
+ console.log(`- ${warning}`)
+ }
+ } else {
+ console.log("Warnings: none")
+ }
+
+ if (failures.length > 0) {
+ console.log("Failures:")
+ for (const failure of failures) {
+ console.log(`- ${failure}`)
+ }
+ process.exit(1)
+ }
+
+ console.log("All staging preflight checks passed.")
+}
+
+main()
diff --git a/scripts/sync-manuals-to-convex.ts b/scripts/sync-manuals-to-convex.ts
new file mode 100644
index 00000000..e8bfed89
--- /dev/null
+++ b/scripts/sync-manuals-to-convex.ts
@@ -0,0 +1,130 @@
+import { spawnSync } from "node:child_process"
+import { config as loadEnv } from "dotenv"
+import { ConvexHttpClient } from "convex/browser"
+import { makeFunctionReference } from "convex/server"
+import { scanManuals } from "../lib/manuals"
+import { buildManualAssetUrl, buildThumbnailAssetUrl, getManualsAssetSource } from "../lib/manuals-storage"
+
+loadEnv({ path: ".env.local" })
+loadEnv({ path: ".env.staging", override: false })
+
+type ManualUpsertInput = {
+ filename: string
+ path: string
+ manufacturer: string
+ category: string
+ size?: number
+ lastModified?: number
+ searchTerms?: string[]
+ commonNames?: string[]
+ thumbnailUrl?: string
+ manualUrl?: string
+ hasParts?: boolean
+}
+
+const UPSERT_MANUALS = makeFunctionReference<"mutation">("manuals:upsertMany")
+
+function parseArgs(argv: string[]) {
+ const limitFlagIndex = argv.indexOf("--limit")
+ const limit = limitFlagIndex >= 0 ? Number.parseInt(argv[limitFlagIndex + 1] || "", 10) : undefined
+
+ return {
+ dryRun: argv.includes("--dry-run"),
+ skipUpload: argv.includes("--skip-upload"),
+ limit: Number.isFinite(limit) && limit && limit > 0 ? limit : undefined,
+ }
+}
+
+function readConvexUrl() {
+ const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL
+ if (!convexUrl) {
+ throw new Error("NEXT_PUBLIC_CONVEX_URL is required to sync manuals into Convex.")
+ }
+
+ return convexUrl
+}
+
+function shouldUploadAssets() {
+ return Boolean(process.env.CLOUDFLARE_R2_ACCESS_KEY_ID && process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY)
+}
+
+function runAssetUpload() {
+ const result = spawnSync("node", ["scripts/upload-to-r2.js", "--type", "all", "--incremental"], {
+ cwd: process.cwd(),
+ stdio: "inherit",
+ })
+
+ if ((result.status ?? 1) !== 0) {
+ throw new Error("R2 upload step failed.")
+ }
+}
+
+function normalizeManualForConvex(manual: Awaited>[number]) {
+ const manualUrl = buildManualAssetUrl(manual.path)
+ const thumbnailUrl = manual.thumbnailUrl ? buildThumbnailAssetUrl(manual.thumbnailUrl) : undefined
+
+ return {
+ filename: manual.filename,
+ path: manual.path,
+ manufacturer: manual.manufacturer,
+ category: manual.category,
+ size: manual.size,
+ lastModified: manual.lastModified ? manual.lastModified.getTime() : undefined,
+ searchTerms: manual.searchTerms,
+ commonNames: manual.commonNames,
+ thumbnailUrl,
+ manualUrl,
+ hasParts: Boolean(manual.searchTerms?.some((term) => term.toLowerCase().includes("parts"))),
+ } satisfies ManualUpsertInput
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2))
+
+ if (!args.skipUpload && shouldUploadAssets()) {
+ console.log("[manuals-sync] uploading new manuals and thumbnails to object storage")
+ runAssetUpload()
+ } else if (!args.skipUpload) {
+ console.log("[manuals-sync] skipping asset upload because Cloudflare R2 credentials are not configured")
+ }
+
+ const manuals = await scanManuals()
+ const selectedManuals = args.limit ? manuals.slice(0, args.limit) : manuals
+ const importBatch = new Date().toISOString()
+ const payload = selectedManuals.map((manual) => normalizeManualForConvex(manual))
+
+ console.log("[manuals-sync] scanned manuals", {
+ total: manuals.length,
+ selected: selectedManuals.length,
+ assetSource: getManualsAssetSource(),
+ })
+
+ if (args.dryRun) {
+ console.log("[manuals-sync] dry run complete")
+ return
+ }
+
+ const convex = new ConvexHttpClient(readConvexUrl())
+ const batchSize = 100
+ let synced = 0
+
+ for (let index = 0; index < payload.length; index += batchSize) {
+ const slice = payload.slice(index, index + batchSize)
+ await convex.mutation(UPSERT_MANUALS, { manuals: slice })
+ synced += slice.length
+ console.log("[manuals-sync] upserted batch", {
+ synced,
+ total: payload.length,
+ })
+ }
+
+ console.log("[manuals-sync] completed", {
+ synced,
+ importBatch,
+ })
+}
+
+main().catch((error) => {
+ console.error("[manuals-sync] failed", error)
+ process.exit(1)
+})
diff --git a/scripts/upload-to-r2.js b/scripts/upload-to-r2.js
index 048a0ad9..5431bc51 100644
--- a/scripts/upload-to-r2.js
+++ b/scripts/upload-to-r2.js
@@ -22,16 +22,17 @@ import { existsSync } from 'fs';
const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || 'bd6f76304a840ba11b75f9ced84264f4';
const ENDPOINT = process.env.CLOUDFLARE_R2_ENDPOINT || `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`;
-const ACCESS_KEY_ID = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
-const SECRET_ACCESS_KEY = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
+const ACCESS_KEY_ID = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY;
+const SECRET_ACCESS_KEY =
+ process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_KEY;
// Bucket names
const MANUALS_BUCKET = process.env.R2_MANUALS_BUCKET || 'vending-vm-manuals';
const THUMBNAILS_BUCKET = process.env.R2_THUMBNAILS_BUCKET || 'vending-vm-thumbnails';
// Source directories (relative to project root)
-const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals');
-const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'thumbnails');
+const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'manuals');
+const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'thumbnails');
// Parse command line arguments
const args = process.argv.slice(2);
@@ -261,5 +262,3 @@ main().catch(error => {
console.error('\n❌ Fatal error:', error);
process.exit(1);
});
-
-