Compare commits

..

No commits in common. "main" and "codex/repo-cleanup-standardization" have entirely different histories.

97 changed files with 984 additions and 11664 deletions

View file

@ -33,15 +33,6 @@ ADMIN_EMAIL=
# Direct phone-call visibility
PHONE_AGENT_INTERNAL_TOKEN=
PHONE_CALL_SUMMARY_FROM_EMAIL=
ENABLE_GHL_SYNC=false
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
GOOGLE_CALENDAR_REFRESH_TOKEN=
GOOGLE_CALENDAR_ID=
GOOGLE_CALENDAR_TIMEZONE=America/Denver
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
# Placeholder for a later LiveKit rollout
LIVEKIT_URL=

View file

@ -33,15 +33,6 @@ ADMIN_EMAIL=
ADMIN_PASSWORD=
RESEND_API_KEY=
PHONE_CALL_SUMMARY_FROM_EMAIL=
ENABLE_GHL_SYNC=false
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
GOOGLE_CALENDAR_REFRESH_TOKEN=
GOOGLE_CALENDAR_ID=
GOOGLE_CALENDAR_TIMEZONE=America/Denver
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=

View file

@ -10,18 +10,10 @@ import { FAQSection } from "@/components/faq-section"
import { ContactPage } from "@/components/contact-page"
import { AboutPage } from "@/components/about-page"
import { WhoWeServePage } from "@/components/who-we-serve-page"
import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicProse,
PublicSurface,
} from "@/components/public-surface"
import {
generateLocationPageMetadata,
LocationLandingPage,
} from "@/components/location-landing-page"
import Link from "next/link"
// Required for static export - ensures this route is statically generated
export const dynamic = "force-static"
@ -56,7 +48,6 @@ const routeMapping: Record<string, string> = {
// Food & Beverage
"food-and-beverage/healthy-options": "healthy-vending",
"food-and-beverage/snack-and-drink-delivery": "snack-and-drink-delivery",
"food-and-beverage/traditional-options": "traditional-vending",
"food-and-beverage/suppliers":
"diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts",
@ -357,7 +348,6 @@ export default async function WordPressPage({ params }: PageProps) {
"vending-machines-for-your-car-wash",
]
const isWhoWeServePage = whoWeServeSlugs.includes(pageSlug)
const routePath = `/${slugArray.join("/")}`
return (
<>
@ -387,58 +377,13 @@ export default async function WordPressPage({ params }: PageProps) {
pageSlug !== "contact-us" &&
pageSlug !== "about-us" &&
!isWhoWeServePage && (
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
<Breadcrumbs
className="mb-6"
items={[
{ label: "Home", href: "/" },
{ label: page.title || "Page", href: routePath },
]}
/>
<PublicPageHeader
eyebrow={pageSlug.startsWith("blog") ? "Article" : "Information"}
title={page.title || "Page"}
description={
page.seoDescription ||
page.excerpt ||
"Explore the details, service guidance, and next steps from Rocky Mountain Vending."
}
className="mx-auto mb-10 max-w-3xl text-center"
align="center"
/>
<PublicSurface className="p-5 md:p-7 lg:p-9">
<PublicProse className="mx-auto max-w-3xl">{content}</PublicProse>
</PublicSurface>
<PublicInset className="mx-auto mt-8 max-w-4xl border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.84))] p-5 md:p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Need Help Choosing The Right Next Step?
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
Talk with Rocky Mountain Vending
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Reach out about placement, machine sales, repairs, moving help,
manuals, or parts and we&apos;ll point you in the right direction.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Link
href="/contact-us#contact-form"
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
Talk to Our Team
</Link>
<Link
href="/#request-machine"
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
>
See If You Qualify
</Link>
</div>
</div>
</PublicInset>
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
<header className="mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-6">
{page.title || "Page"}
</h1>
</header>
{content}
</article>
)}
</>

View file

@ -50,8 +50,7 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
Phone Call Detail
</h1>
<p className="text-muted-foreground">
{detail.call.contactDisplayName ||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
{normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity}
</p>
</div>
@ -99,22 +98,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
{detail.call.participantIdentity || "Unknown"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Caller Phone
</p>
<p className="font-medium">
{detail.call.callerPhone ||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
"Unknown"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Company
</p>
<p className="font-medium">{detail.call.contactCompany || "—"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Call Status
@ -152,22 +135,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</p>
<p className="font-medium">{detail.call.notificationStatus}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Reminder
</p>
<p className="font-medium">
{detail.call.reminderStatus || "none"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Warm Transfer
</p>
<p className="font-medium">
{detail.call.warmTransferStatus || "none"}
</p>
</div>
<div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Summary
@ -190,26 +157,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</p>
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
</div>
{detail.call.reminderStartAt ? (
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Reminder Time
</p>
<p className="font-medium">
{formatPhoneCallTimestamp(detail.call.reminderStartAt)}
</p>
</div>
) : null}
{detail.call.warmTransferFailureReason ? (
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Transfer Detail
</p>
<p className="font-medium">
{detail.call.warmTransferFailureReason}
</p>
</div>
) : null}
{detail.call.recordingUrl ? (
<div className="md:col-span-2">
<Link
@ -232,18 +179,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</p>
</div>
) : null}
{detail.call.reminderCalendarHtmlLink ? (
<div className="md:col-span-2">
<Link
href={detail.call.reminderCalendarHtmlLink}
target="_blank"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
Open reminder
<ExternalLink className="h-4 w-4" />
</Link>
</div>
) : null}
</CardContent>
</Card>
@ -302,23 +237,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
lead.
</p>
)}
{detail.contactProfile ? (
<div className="border-t pt-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Contact Profile
</p>
<p className="font-medium">
{detail.contactProfile.displayName ||
[detail.contactProfile.firstName, detail.contactProfile.lastName]
.filter(Boolean)
.join(" ") ||
"Known caller"}
</p>
<p className="text-sm text-muted-foreground">
{detail.contactProfile.company || detail.contactProfile.email || "No company or email yet"}
</p>
</div>
) : null}
</CardContent>
</Card>
</div>

View file

@ -104,7 +104,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[1240px] text-sm">
<table className="w-full min-w-[1050px] text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-3 pr-4 font-medium">Caller</th>
@ -116,8 +116,6 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<th className="py-3 pr-4 font-medium">Recording</th>
<th className="py-3 pr-4 font-medium">Lead</th>
<th className="py-3 pr-4 font-medium">Email</th>
<th className="py-3 pr-4 font-medium">Reminder</th>
<th className="py-3 pr-4 font-medium">Transfer</th>
<th className="py-3 pr-4 font-medium">Summary</th>
<th className="py-3 font-medium">Open</th>
</tr>
@ -126,7 +124,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
{data.items.length === 0 ? (
<tr>
<td
colSpan={13}
colSpan={11}
className="py-8 text-center text-muted-foreground"
>
No phone calls matched this filter.
@ -140,18 +138,12 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
>
<td className="py-3 pr-4 font-medium">
<div>
{call.contactDisplayName ||
normalizePhoneFromIdentity(
call.participantIdentity
) ||
call.participantIdentity}
{normalizePhoneFromIdentity(
call.participantIdentity
) || call.participantIdentity}
</div>
<div className="text-xs text-muted-foreground">
{call.contactCompany ||
normalizePhoneFromIdentity(
call.participantIdentity
) ||
call.roomName}
{call.roomName}
</div>
</td>
<td className="py-3 pr-4">
@ -180,16 +172,6 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
</td>
<td className="py-3 pr-4">{call.notificationStatus}</td>
<td className="py-3 pr-4">
{call.reminderStatus === "none"
? "—"
: call.reminderStatus}
</td>
<td className="py-3 pr-4">
{call.warmTransferStatus === "none"
? "—"
: call.warmTransferStatus}
</td>
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
<span className="line-clamp-2">
{call.summaryText || "No summary yet"}

View file

@ -1,199 +0,0 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { fetchQuery } from "convex/nextjs"
import { ArrowLeft, ContactRound, MessageSquare } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type PageProps = {
params: Promise<{
id: string
}>
}
function formatTimestamp(value?: number) {
if (!value) {
return "—"
}
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export default async function AdminContactDetailPage({ params }: PageProps) {
const { id } = await params
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
contactId: id,
})
if (!detail) {
notFound()
}
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-8">
<div className="space-y-2">
<Link
href="/admin/contacts"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to contacts
</Link>
<h1 className="text-4xl font-bold tracking-tight text-balance">
{detail.contact.displayName}
</h1>
<p className="text-muted-foreground">
Contact details and activity history.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ContactRound className="h-5 w-5" />
Contact Profile
</CardTitle>
<CardDescription>Basic details and connected records.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Email
</p>
<p className="font-medium break-all">
{detail.contact.email || "—"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Phone
</p>
<p className="font-medium">{detail.contact.phone || "—"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Company
</p>
<p className="font-medium">{detail.contact.company || "—"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Status
</p>
<Badge className="mt-1" variant="secondary">
{detail.contact.status}
</Badge>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
GHL Contact ID
</p>
<p className="font-medium break-all">
{detail.contact.ghlContactId || "—"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Last Activity
</p>
<p className="font-medium">
{formatTimestamp(detail.contact.lastActivityAt)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Conversations
</CardTitle>
<CardDescription>
Conversations linked to this contact.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{detail.conversations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No conversations are linked to this contact yet.
</p>
) : (
detail.conversations.map((conversation: any) => (
<div key={conversation.id} className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">
{conversation.title || detail.contact.displayName}
</p>
<p className="text-xs text-muted-foreground">
{conversation.channel} {" "}
{formatTimestamp(conversation.lastMessageAt)}
</p>
</div>
<Link href={`/admin/conversations/${conversation.id}`}>
<Badge variant="outline">{conversation.status}</Badge>
</Link>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{conversation.lastMessagePreview || "No preview yet"}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Timeline</CardTitle>
<CardDescription>
Calls, messages, recordings, and lead events in one stream.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{detail.timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
No timeline activity for this contact yet.
</p>
) : (
detail.timeline.map((item: any) => (
<div key={`${item.type}-${item.id}`} className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="uppercase tracking-wide">{item.type}</span>
<span>{formatTimestamp(item.timestamp)}</span>
</div>
<p className="mt-1 font-medium">{item.title || "Untitled"}</p>
<p className="mt-1 text-sm text-muted-foreground whitespace-pre-wrap">
{item.body || "—"}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
)
}
export const metadata = {
title: "Contact Detail | Admin",
description: "Review a contact and full interaction timeline",
}

View file

@ -1,202 +0,0 @@
import Link from "next/link"
import { fetchQuery } from "convex/nextjs"
import { ContactRound, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
type PageProps = {
searchParams: Promise<{
search?: string
page?: string
}>
}
function formatTimestamp(value?: number) {
if (!value) {
return "—"
}
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
function getSyncMessage(sync: any) {
if (!sync.ghlConfigured) {
return "Connect GHL to load contacts and conversations."
}
if (sync.stages.contacts.status === "running") {
return "Contacts are syncing now."
}
if (sync.stages.contacts.error) {
return "Contacts could not be loaded from GHL yet."
}
if (!sync.latestSyncAt) {
return "No contacts yet."
}
return "Your contact list stays up to date from forms, calls, and GHL."
}
export default async function AdminContactsPage({ searchParams }: PageProps) {
const params = await searchParams
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
const search = params.search?.trim() || undefined
const data = await fetchQuery(api.crm.listAdminContacts, {
search,
page,
limit: 25,
})
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight text-balance">
Contacts
</h1>
<p className="mt-2 text-muted-foreground">
All customer contacts in one place.
</p>
</div>
<Link href="/admin">
<Button variant="outline">Back to Admin</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Sync Status</CardTitle>
<CardDescription>{getSyncMessage(data.sync)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<Badge variant="outline">{data.sync.overallStatus}</Badge>
<span>
Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}
</span>
{!data.sync.ghlConfigured ? (
<span>GHL is not connected.</span>
) : null}
{data.sync.stages.contacts.error ? (
<span>{data.sync.stages.contacts.error}</span>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ContactRound className="h-5 w-5" />
Contact Directory
</CardTitle>
<CardDescription>
Search by name, email, phone, company, or tag.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
name="search"
defaultValue={search || ""}
placeholder="Search contacts"
className="pl-9"
/>
</div>
<Button type="submit">Filter</Button>
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-3 pr-4 font-medium">Contact</th>
<th className="py-3 pr-4 font-medium">Company</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 pr-4 font-medium">Conversations</th>
<th className="py-3 pr-4 font-medium">Leads</th>
<th className="py-3 pr-4 font-medium">Last Activity</th>
<th className="py-3 font-medium">Open</th>
</tr>
</thead>
<tbody>
{data.items.length === 0 ? (
<tr>
<td
colSpan={7}
className="py-8 text-center text-muted-foreground"
>
{search
? "No contacts matched this search."
: getSyncMessage(data.sync)}
</td>
</tr>
) : (
data.items.map((contact: any) => (
<tr
key={contact.id}
className="border-b align-top last:border-b-0"
>
<td className="py-3 pr-4">
<div className="font-medium">{contact.displayName}</div>
{contact.email ? (
<div className="text-xs text-muted-foreground">
{contact.email}
</div>
) : null}
{contact.phone ? (
<div className="text-xs text-muted-foreground">
{contact.phone}
</div>
) : null}
</td>
<td className="py-3 pr-4">
{contact.company || "—"}
</td>
<td className="py-3 pr-4">
<Badge variant="secondary">{contact.status}</Badge>
</td>
<td className="py-3 pr-4">{contact.conversationCount}</td>
<td className="py-3 pr-4">{contact.leadCount}</td>
<td className="py-3 pr-4">
{formatTimestamp(contact.lastActivityAt)}
</td>
<td className="py-3">
<Link href={`/admin/contacts/${contact.id}`}>
<Button size="sm" variant="outline">
View
</Button>
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
export const metadata = {
title: "Contacts | Admin",
description: "View Rocky customer contacts",
}

View file

@ -1,19 +0,0 @@
import { redirect } from "next/navigation"
type PageProps = {
params: Promise<{
id: string
}>
}
export default async function AdminConversationDetailRedirect({
params,
}: PageProps) {
const { id } = await params
redirect(`/admin/conversations?conversationId=${encodeURIComponent(id)}`)
}
export const metadata = {
title: "Conversation Detail | Admin",
description: "Open a conversation in the inbox view",
}

View file

@ -1,518 +0,0 @@
import Link from "next/link"
import { fetchAction, fetchQuery } from "convex/nextjs"
import { MessageSquare, Phone, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
type PageProps = {
searchParams: Promise<{
search?: string
channel?: "call" | "sms" | "chat" | "unknown"
status?: "open" | "closed" | "archived"
conversationId?: string
error?: string
page?: string
}>
}
function formatTimestamp(value?: number) {
if (!value) {
return "—"
}
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
}
function formatSidebarTimestamp(value?: number) {
if (!value) {
return ""
}
const date = new Date(value)
const now = new Date()
const sameDay = date.toDateString() === now.toDateString()
return sameDay
? date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})
: date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}
function formatDuration(value?: number) {
if (!value) {
return "—"
}
const totalSeconds = Math.max(0, Math.round(value / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${String(seconds).padStart(2, "0")}`
}
function getSyncMessage(sync: any) {
if (!sync.ghlConfigured) {
return "Connect GHL to load contacts and conversations."
}
if (sync.stages.conversations.status === "running") {
return "Conversations are syncing now."
}
if (sync.stages.conversations.error) {
return "Conversations could not be loaded from GHL yet."
}
if (!sync.latestSyncAt) {
return "No conversations yet."
}
return "Browse contacts and conversations in one inbox."
}
function getInitials(value?: string) {
const text = String(value || "").trim()
if (!text) {
return "RM"
}
const parts = text.split(/\s+/).filter(Boolean)
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase()
}
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase()
}
function buildConversationHref(params: {
search?: string
channel?: string
status?: string
conversationId?: string
}) {
const nextParams = new URLSearchParams()
if (params.search) {
nextParams.set("search", params.search)
}
if (params.channel) {
nextParams.set("channel", params.channel)
}
if (params.status) {
nextParams.set("status", params.status)
}
if (params.conversationId) {
nextParams.set("conversationId", params.conversationId)
}
const query = nextParams.toString()
return query ? `/admin/conversations?${query}` : "/admin/conversations"
}
export default async function AdminConversationsPage({
searchParams,
}: PageProps) {
const params = await searchParams
const search = params.search?.trim() || undefined
const data = await fetchQuery(api.crm.listAdminConversations, {
search,
page: 1,
limit: 100,
channel: params.channel,
status: params.status,
})
const selectedConversationId =
(params.conversationId &&
data.items.find((item: any) => item.id === params.conversationId)?.id) ||
data.items[0]?.id
const detail = selectedConversationId
? await fetchQuery(api.crm.getAdminConversationDetail, {
conversationId: selectedConversationId,
})
: null
const hydratedDetail =
detail &&
detail.messages.length === 0 &&
detail.conversation.ghlConversationId
? await fetchAction(api.crm.hydrateConversationHistory, {
conversationId: detail.conversation.id,
}).then(async (result) => {
if (result?.imported) {
return await fetchQuery(api.crm.getAdminConversationDetail, {
conversationId: detail.conversation.id,
})
}
return detail
})
: detail
const timeline = hydratedDetail
? [
...hydratedDetail.messages.map((message: any) => ({
id: `message-${message.id}`,
type: "message" as const,
timestamp: message.sentAt || 0,
message,
})),
...hydratedDetail.recordings.map((recording: any) => ({
id: `recording-${recording.id}`,
type: "recording" as const,
timestamp: recording.startedAt || recording.endedAt || 0,
recording,
})),
].sort((a, b) => a.timestamp - b.timestamp)
: []
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight text-balance">
Conversations
</h1>
<p className="mt-2 text-muted-foreground">
Review calls and messages in one inbox.
</p>
</div>
<Link href="/admin">
<Button variant="outline">Back to Admin</Button>
</Link>
</div>
<Card className="rounded-[2rem]">
<CardContent className="flex flex-wrap items-center gap-3 px-6 py-4 text-sm text-muted-foreground">
<Badge variant="outline">{data.sync.overallStatus}</Badge>
<span>{getSyncMessage(data.sync)}</span>
<span>Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}</span>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[2rem] p-0">
<div className="grid min-h-[720px] lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="border-b bg-white lg:border-b-0 lg:border-r">
<div className="space-y-4 border-b px-5 py-5">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="font-semibold">Conversation Inbox</h2>
<p className="text-sm text-muted-foreground">
Search and pick a conversation to review.
</p>
</div>
</div>
<form className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
name="search"
defaultValue={search || ""}
placeholder="Search contacts or messages"
className="pl-9"
/>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<select
name="channel"
defaultValue={params.channel || ""}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">All channels</option>
<option value="call">Call</option>
<option value="sms">SMS</option>
<option value="chat">Chat</option>
<option value="unknown">Unknown</option>
</select>
<select
name="status"
defaultValue={params.status || ""}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="archived">Archived</option>
</select>
<Button type="submit">Filter</Button>
</div>
</form>
</div>
<ScrollArea className="h-[520px] lg:h-[640px]">
<div className="divide-y">
{data.items.length === 0 ? (
<div className="px-5 py-8 text-sm text-muted-foreground">
{search || params.channel || params.status
? "No conversations matched this search."
: getSyncMessage(data.sync)}
</div>
) : (
data.items.map((conversation: any) => {
const isSelected = conversation.id === selectedConversationId
return (
<Link
key={conversation.id}
href={buildConversationHref({
search,
channel: params.channel,
status: params.status,
conversationId: conversation.id,
})}
className={[
"flex gap-3 px-5 py-4 transition-colors",
isSelected
? "bg-primary/5 ring-1 ring-inset ring-primary/20"
: "hover:bg-muted/40",
].join(" ")}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{getInitials(conversation.displayName)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium">
{conversation.displayName}
</p>
{conversation.secondaryLine ? (
<p className="truncate text-xs text-muted-foreground">
{conversation.secondaryLine}
</p>
) : null}
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{formatSidebarTimestamp(conversation.lastMessageAt)}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{conversation.lastMessagePreview ||
"No messages or call notes yet."}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge variant="outline">{conversation.channel}</Badge>
<Badge variant="secondary">{conversation.status}</Badge>
{conversation.recordingCount ? (
<Badge variant="outline">
{conversation.recordingCount} recording
{conversation.recordingCount === 1 ? "" : "s"}
</Badge>
) : null}
</div>
</div>
</Link>
)
})
)}
</div>
</ScrollArea>
</div>
<div className="bg-[#faf8f3]">
{hydratedDetail ? (
<div className="flex h-full flex-col">
<div className="border-b bg-white px-6 py-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div>
<h2 className="text-2xl font-semibold">
{hydratedDetail.contact?.name ||
hydratedDetail.conversation.title ||
"Conversation"}
</h2>
{hydratedDetail.contact?.secondaryLine ||
hydratedDetail.contact?.email ||
hydratedDetail.contact?.phone ? (
<p className="text-sm text-muted-foreground">
{hydratedDetail.contact?.secondaryLine ||
hydratedDetail.contact?.phone ||
hydratedDetail.contact?.email}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
{hydratedDetail.conversation.channel}
</Badge>
<Badge variant="secondary">
{hydratedDetail.conversation.status}
</Badge>
<Badge variant="outline">
{timeline.filter((item) => item.type === "message").length}{" "}
messages
</Badge>
{hydratedDetail.recordings.length ? (
<Badge variant="outline">
{hydratedDetail.recordings.length} recording
{hydratedDetail.recordings.length === 1 ? "" : "s"}
</Badge>
) : null}
</div>
</div>
<div className="text-sm text-muted-foreground">
Last activity:{" "}
{formatTimestamp(hydratedDetail.conversation.lastMessageAt)}
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<form
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/sync`}
method="post"
>
<Button type="submit" variant="outline" size="sm">
Refresh history
</Button>
</form>
{params.error === "send" ? (
<p className="text-sm text-destructive">
Rocky could not send that message through GHL.
</p>
) : null}
{params.error === "sync" ? (
<p className="text-sm text-destructive">
Rocky could not refresh that conversation from GHL.
</p>
) : null}
</div>
</div>
<ScrollArea className="h-[520px] px-4 py-5 lg:h-[640px] lg:px-6">
<div className="space-y-4 pb-2">
{timeline.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white/70 px-6 py-10 text-center text-sm text-muted-foreground">
No messages or recordings have been mirrored into this
conversation yet. Use refresh history to pull the latest
thread from GHL.
</div>
) : (
timeline.map((item: any) => {
if (item.type === "recording") {
const recording = item.recording
return (
<div key={item.id} className="max-w-2xl rounded-2xl border bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Phone className="h-4 w-4 text-muted-foreground" />
Call recording
<Badge variant="outline" className="ml-2">
{recording.recordingStatus || "recording"}
</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
<span>{formatTimestamp(recording.startedAt)}</span>
<span>Duration: {formatDuration(recording.durationMs)}</span>
</div>
{recording.recordingUrl ? (
<div className="mt-3">
<a
href={recording.recordingUrl}
target="_blank"
className="text-sm font-medium text-primary hover:underline"
>
Open recording
</a>
</div>
) : null}
{recording.transcriptionText ? (
<div className="mt-3 rounded-xl border bg-muted/30 p-3 text-sm whitespace-pre-wrap text-foreground/90">
{recording.transcriptionText}
</div>
) : null}
</div>
)
}
const message = item.message
const isOutbound = message.direction === "outbound"
return (
<div
key={item.id}
className={`flex ${isOutbound ? "justify-end" : "justify-start"}`}
>
<div
className={[
"max-w-[85%] rounded-3xl px-4 py-3 shadow-sm",
isOutbound
? "bg-primary text-primary-foreground"
: "border bg-white",
].join(" ")}
>
<div className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-wide opacity-70">
<span>{message.channel}</span>
<span>{message.direction}</span>
{message.status ? <span>{message.status}</span> : null}
</div>
<p className="whitespace-pre-wrap text-sm leading-6">
{message.body}
</p>
<div className="mt-2 text-right text-xs opacity-70">
{formatTimestamp(message.sentAt)}
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
<div className="border-t bg-white px-4 py-4 lg:px-6">
<form
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/messages`}
method="post"
className="space-y-3"
>
<textarea
name="body"
rows={4}
placeholder="Reply to this conversation"
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 min-h-24 w-full rounded-2xl border bg-background px-4 py-3 text-sm shadow-sm outline-none focus-visible:ring-[3px]"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Sends through GHL and mirrors the reply back into Rocky.
</p>
<Button type="submit">Send message</Button>
</div>
</form>
</div>
</div>
) : (
<div className="flex h-full min-h-[520px] items-center justify-center px-6 py-16">
<div className="max-w-md text-center">
<h2 className="text-2xl font-semibold">No conversation selected</h2>
<p className="mt-2 text-sm text-muted-foreground">
Choose a conversation from the left to open the full thread.
</p>
</div>
</div>
)}
</div>
</div>
</Card>
</div>
</div>
)
}
export const metadata = {
title: "Conversations | Admin",
description: "View Rocky customer conversations",
}

View file

@ -1,9 +1,5 @@
import Link from "next/link"
import { redirect } from "next/navigation"
import {
getAdminUserFromCookies,
isAdminUiEnabled,
} from "@/lib/server/admin-auth"
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
export default async function AdminLayout({
children,
@ -14,29 +10,5 @@ export default async function AdminLayout({
redirect("/")
}
const adminUser = await getAdminUserFromCookies()
if (!adminUser) {
redirect("/sign-in")
}
return (
<div className="min-h-screen bg-muted/30">
<div className="border-b bg-background">
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3">
<Link href="/admin" className="font-semibold hover:text-primary">
Rocky Admin
</Link>
<span className="text-muted-foreground">{adminUser.email}</span>
</div>
<form action="/api/admin/auth/logout" method="post">
<button className="text-muted-foreground hover:text-foreground">
Sign out
</button>
</form>
</div>
</div>
{children}
</div>
)
return <>{children}</>
}

View file

@ -1,7 +1,5 @@
import Link from "next/link"
import { fetchQuery } from "convex/nextjs"
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import {
Card,
CardContent,
@ -23,8 +21,6 @@ import {
Settings,
BarChart3,
Phone,
MessageSquare,
ContactRound,
} from "lucide-react"
import { fetchAllProducts } from "@/lib/stripe/products"
@ -60,25 +56,10 @@ async function getOrdersCount() {
return mockAnalytics.totalOrders
}
function formatTimestamp(value?: number | null) {
if (!value) {
return "—"
}
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export default async function AdminDashboard() {
const [productsCount, ordersCount, sync] = await Promise.all([
const [productsCount, ordersCount] = await Promise.all([
getProductsCount(),
getOrdersCount(),
fetchQuery(api.crm.getAdminSyncOverview, {}),
])
const dashboardCards = [
@ -211,22 +192,10 @@ export default async function AdminDashboard() {
Admin Dashboard
</h1>
<p className="text-muted-foreground mt-2">
Manage orders, contacts, conversations, and calls
Overview of your store performance and management tools
</p>
</div>
<div className="flex gap-2">
<Link href="/admin/contacts">
<Button variant="outline">
<ContactRound className="h-4 w-4 mr-2" />
Contacts
</Button>
</Link>
<Link href="/admin/conversations">
<Button variant="outline">
<MessageSquare className="h-4 w-4 mr-2" />
Conversations
</Button>
</Link>
<Link href="/admin/calls">
<Button variant="outline">
<Phone className="h-4 w-4 mr-2" />
@ -243,25 +212,6 @@ export default async function AdminDashboard() {
</div>
</div>
<Card>
<CardHeader>
<CardTitle>CRM Sync Status</CardTitle>
<CardDescription>
{!sync.ghlConfigured
? "Connect GHL to load contacts and conversations."
: "Customer data is mirrored here from GHL and your call flows."}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<Badge variant="outline">{sync.overallStatus}</Badge>
<span>Last sync: {formatTimestamp(sync.latestSyncAt)}</span>
{!sync.ghlConfigured ? <span>GHL is not connected.</span> : null}
{!sync.livekitConfigured ? (
<span>LiveKit recordings are not connected yet.</span>
) : null}
</CardContent>
</Card>
{/* Main Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{dashboardCards.map((card, index) => {

View file

@ -1,70 +0,0 @@
import { headers } from "next/headers"
import { NextResponse } from "next/server"
import {
ADMIN_SESSION_COOKIE,
createAdminSession,
isAdminCredentialLoginConfigured,
isAdminCredentialMatch,
} from "@/lib/server/admin-auth"
export async function POST(request: Request) {
if (!isAdminCredentialLoginConfigured()) {
return NextResponse.redirect(
new URL("/sign-in?error=config", await getPublicOrigin(request))
)
}
const formData = await request.formData()
const email = String(formData.get("email") || "")
.trim()
.toLowerCase()
const password = String(formData.get("password") || "")
if (!isAdminCredentialMatch(email, password)) {
return NextResponse.redirect(
new URL("/sign-in?error=invalid", await getPublicOrigin(request))
)
}
const session = await createAdminSession(email)
const response = NextResponse.redirect(
new URL("/admin", await getPublicOrigin(request))
)
response.cookies.set(ADMIN_SESSION_COOKIE, session.token, {
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
expires: new Date(session.expiresAt),
})
return response
}
async function getPublicOrigin(request: Request) {
const headerStore = await headers()
const origin = headerStore.get("origin")
if (origin) {
return origin
}
const referer = headerStore.get("referer")
if (referer) {
return new URL(referer).origin
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
if (siteUrl) {
return siteUrl
}
const forwardedProto = headerStore.get("x-forwarded-proto")
const forwardedHost = headerStore.get("x-forwarded-host")
const host = forwardedHost || headerStore.get("host")
if (host) {
return `${forwardedProto || "https"}://${host}`
}
return new URL(request.url).origin
}

View file

@ -1,53 +0,0 @@
import { NextResponse } from "next/server"
import { cookies, headers } from "next/headers"
import {
ADMIN_SESSION_COOKIE,
destroyAdminSession,
} from "@/lib/server/admin-auth"
export async function POST(request: Request) {
const cookieStore = await cookies()
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value || null
await destroyAdminSession(rawToken)
const response = NextResponse.redirect(
new URL("/sign-in", await getPublicOrigin(request))
)
response.cookies.set(ADMIN_SESSION_COOKIE, "", {
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
expires: new Date(0),
})
return response
}
async function getPublicOrigin(request: Request) {
const headerStore = await headers()
const origin = headerStore.get("origin")
if (origin) {
return origin
}
const referer = headerStore.get("referer")
if (referer) {
return new URL(referer).origin
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
if (siteUrl) {
return siteUrl
}
const forwardedProto = headerStore.get("x-forwarded-proto")
const forwardedHost = headerStore.get("x-forwarded-host")
const host = forwardedHost || headerStore.get("host")
if (host) {
return `${forwardedProto || "https"}://${host}`
}
return new URL(request.url).origin
}

View file

@ -1,36 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
type RouteContext = {
params: Promise<{
id: string
}>
}
export async function GET(request: Request, { params }: RouteContext) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { id } = await params
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
contactId: id,
})
if (!detail) {
return NextResponse.json({ error: "Contact not found" }, { status: 404 })
}
return NextResponse.json(detail)
} catch (error) {
console.error("Failed to load admin contact detail:", error)
return NextResponse.json(
{ error: "Failed to load contact detail" },
{ status: 500 }
)
}
}

View file

@ -1,32 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function GET(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { searchParams } = new URL(request.url)
const search = searchParams.get("search")?.trim() || undefined
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
const data = await fetchQuery(api.crm.listAdminContacts, {
search,
page,
limit,
})
return NextResponse.json(data)
} catch (error) {
console.error("Failed to load admin contacts:", error)
return NextResponse.json(
{ error: "Failed to load contacts" },
{ status: 500 }
)
}
}

View file

@ -1,49 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminSession } from "@/lib/server/admin-auth"
type RouteContext = {
params: Promise<{
id: string
}>
}
export async function POST(request: Request, { params }: RouteContext) {
const adminUser = await requireAdminSession(request)
if (!adminUser) {
return NextResponse.redirect(new URL("/sign-in", request.url))
}
const { id } = await params
const formData = await request.formData()
const body = String(formData.get("body") || "").trim()
if (!body) {
return NextResponse.redirect(
new URL(`/admin/conversations?conversationId=${encodeURIComponent(id)}`, request.url)
)
}
try {
await fetchAction(api.crm.sendAdminConversationMessage, {
conversationId: id,
body,
})
return NextResponse.redirect(
new URL(
`/admin/conversations?conversationId=${encodeURIComponent(id)}`,
request.url
)
)
} catch (error) {
console.error("Failed to send admin conversation message:", error)
return NextResponse.redirect(
new URL(
`/admin/conversations?conversationId=${encodeURIComponent(id)}&error=send`,
request.url
)
)
}
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
type RouteContext = {
params: Promise<{
id: string
}>
}
export async function GET(request: Request, { params }: RouteContext) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { id } = await params
const detail = await fetchQuery(api.crm.getAdminConversationDetail, {
conversationId: id,
})
if (!detail) {
return NextResponse.json(
{ error: "Conversation not found" },
{ status: 404 }
)
}
return NextResponse.json(detail)
} catch (error) {
console.error("Failed to load admin conversation detail:", error)
return NextResponse.json(
{ error: "Failed to load conversation detail" },
{ status: 500 }
)
}
}

View file

@ -1,40 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminSession } from "@/lib/server/admin-auth"
type RouteContext = {
params: Promise<{
id: string
}>
}
export async function POST(request: Request, { params }: RouteContext) {
const adminUser = await requireAdminSession(request)
if (!adminUser) {
return NextResponse.redirect(new URL("/sign-in", request.url))
}
const { id } = await params
try {
await fetchAction(api.crm.hydrateConversationHistory, {
conversationId: id,
})
return NextResponse.redirect(
new URL(
`/admin/conversations?conversationId=${encodeURIComponent(id)}`,
request.url
)
)
} catch (error) {
console.error("Failed to refresh conversation history:", error)
return NextResponse.redirect(
new URL(
`/admin/conversations?conversationId=${encodeURIComponent(id)}&error=sync`,
request.url
)
)
}
}

View file

@ -1,45 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function GET(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { searchParams } = new URL(request.url)
const search = searchParams.get("search")?.trim() || undefined
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
const channel = searchParams.get("channel")
const status = searchParams.get("status")
const data = await fetchQuery(api.crm.listAdminConversations, {
search,
page,
limit,
channel:
channel === "call" ||
channel === "sms" ||
channel === "chat" ||
channel === "unknown"
? channel
: undefined,
status:
status === "open" || status === "closed" || status === "archived"
? status
: undefined,
})
return NextResponse.json(data)
} catch (error) {
console.error("Failed to load admin conversations:", error)
return NextResponse.json(
{ error: "Failed to load conversations" },
{ status: 500 }
)
}
}

View file

@ -1,31 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function POST(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const result = await fetchAction(api.ebay.refreshCache, {
reason: "admin",
force: true,
})
return NextResponse.json(result)
} catch (error) {
console.error("Failed to refresh eBay cache:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to refresh eBay cache",
},
{ status: 500 }
)
}
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function POST(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const result = await fetchAction(api.crm.runGhlMirror, {
reason: "admin",
forceFullBackfill: Boolean(body.forceFullBackfill),
maxPagesPerRun:
typeof body.maxPagesPerRun === "number" ? body.maxPagesPerRun : undefined,
contactsLimit:
typeof body.contactsLimit === "number" ? body.contactsLimit : undefined,
messagesLimit:
typeof body.messagesLimit === "number" ? body.messagesLimit : undefined,
recordingsPageSize:
typeof body.recordingsPageSize === "number"
? body.recordingsPageSize
: undefined,
})
return NextResponse.json(result)
} catch (error) {
console.error("Failed to run admin GHL sync:", error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to run GHL sync",
},
{ status: 500 }
)
}
}

View file

@ -1,213 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import {
filterTrustedEbayListings,
rankListingsForPart,
type CachedEbayListing,
type EbayCacheState,
type ManualPartInput,
} from "@/lib/ebay-parts-match"
type MatchPart = ManualPartInput & {
key?: string
ebayListings?: CachedEbayListing[]
}
type ManualPartsMatchResponse = {
manualFilename: string
parts: Array<
MatchPart & {
ebayListings: CachedEbayListing[]
}
>
cache: EbayCacheState
cacheSource: "convex" | "fallback"
error?: string
}
type ManualPartsRequest = {
manualFilename?: string
parts?: unknown[]
limit?: number
}
function getDisabledCacheState(message: string): EbayCacheState {
return {
key: "manual-parts",
status: "disabled",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
}
}
function getErrorCacheState(message: string): EbayCacheState {
const now = Date.now()
return {
key: "manual-parts",
status: "error",
lastSuccessfulAt: null,
lastAttemptAt: now,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
}
}
function createEmptyListingsParts(parts: MatchPart[]) {
return parts.map((part) => ({
...part,
ebayListings: [],
}))
}
function normalizePartInput(value: unknown): MatchPart | null {
if (!value || typeof value !== "object") {
return null
}
const part = value as Record<string, unknown>
const partNumber = typeof part.partNumber === "string" ? part.partNumber.trim() : ""
const description = typeof part.description === "string" ? part.description.trim() : ""
if (!partNumber && !description) {
return null
}
return {
key: typeof part.key === "string" ? part.key : undefined,
partNumber,
description,
manufacturer:
typeof part.manufacturer === "string" ? part.manufacturer.trim() : undefined,
category:
typeof part.category === "string" ? part.category.trim() : undefined,
manualFilename:
typeof part.manualFilename === "string"
? part.manualFilename.trim()
: undefined,
ebayListings: Array.isArray(part.ebayListings)
? (part.ebayListings as CachedEbayListing[])
: undefined,
}
}
export async function POST(request: Request) {
let payload: ManualPartsRequest | null = null
try {
payload = (await request.json()) as ManualPartsRequest
} catch {
payload = null
}
const manualFilename = payload?.manualFilename?.trim() || ""
const limit = Math.min(
Math.max(Number.parseInt(String(payload?.limit ?? 5), 10) || 5, 1),
10
)
const parts: MatchPart[] = (payload?.parts || [])
.map(normalizePartInput)
.filter((part): part is MatchPart => Boolean(part))
if (!manualFilename) {
return NextResponse.json(
{ error: "manualFilename is required" },
{ status: 400 }
)
}
if (!parts.length) {
const message = "No manual parts were provided."
return NextResponse.json({
manualFilename,
parts: [],
cache: getDisabledCacheState(message),
cacheSource: "fallback",
error: message,
} satisfies ManualPartsMatchResponse)
}
if (!hasConvexUrl()) {
const message =
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
return NextResponse.json({
manualFilename,
parts: createEmptyListingsParts(parts),
cache: getDisabledCacheState(message),
cacheSource: "fallback",
error: message,
} satisfies ManualPartsMatchResponse)
}
try {
const [overview, listings] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
])
const trustedListings = filterTrustedEbayListings(
listings as CachedEbayListing[]
)
const rankedParts = parts
.map((part) => ({
...part,
ebayListings: rankListingsForPart(part, trustedListings, limit),
}))
.sort((a, b) => {
const aCount = a.ebayListings.length
const bCount = b.ebayListings.length
if (aCount !== bCount) {
return bCount - aCount
}
const aFreshness = a.ebayListings[0]?.lastSeenAt ?? a.ebayListings[0]?.fetchedAt ?? 0
const bFreshness = b.ebayListings[0]?.lastSeenAt ?? b.ebayListings[0]?.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
return NextResponse.json({
manualFilename,
parts: rankedParts,
cache: overview,
cacheSource: "convex",
} satisfies ManualPartsMatchResponse)
} catch (error) {
console.error("Failed to load cached eBay matches:", error)
const message =
error instanceof Error
? `Cached eBay listings are unavailable: ${error.message}`
: "Cached eBay listings are unavailable."
return NextResponse.json(
{
manualFilename,
parts: createEmptyListingsParts(parts),
cache: getErrorCacheState(message),
cacheSource: "fallback",
error: message,
} satisfies ManualPartsMatchResponse,
{ status: 200 }
)
}
}

View file

@ -1,63 +1,167 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import {
filterTrustedEbayListings,
rankListingsForQuery,
type CachedEbayListing,
type EbayCacheState,
} from "@/lib/ebay-parts-match"
import { NextRequest, NextResponse } from "next/server"
type CacheSource = "convex" | "fallback"
/**
* eBay API Proxy Route
* Proxies requests to eBay Finding API to avoid CORS issues
*/
function getDisabledCacheState(message: string): EbayCacheState {
return {
key: "manual-parts",
status: "disabled",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
interface eBaySearchParams {
keywords: string
categoryId?: string
sortOrder?: string
maxResults?: number
}
interface eBaySearchResult {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}
type MaybeArray<T> = T | T[]
const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
const searchResponseCache = new Map<
string,
{ results: eBaySearchResult[]; timestamp: number }
>()
const inFlightSearchResponses = new Map<string, Promise<eBaySearchResult[]>>()
// Affiliate campaign ID for generating links
const AFFILIATE_CAMPAIGN_ID =
process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
// Generate eBay affiliate link
function generateAffiliateLink(viewItemUrl: string): string {
if (!AFFILIATE_CAMPAIGN_ID) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", AFFILIATE_CAMPAIGN_ID)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
function getErrorCacheState(message: string): EbayCacheState {
const now = Date.now()
function first<T>(value: MaybeArray<T> | undefined): T | undefined {
if (!value) {
return undefined
}
return Array.isArray(value) ? value[0] : value
}
function normalizeItem(item: any): eBaySearchResult {
const currentPrice = first(item.sellingStatus?.currentPrice)
const shippingCost = first(item.shippingInfo?.shippingServiceCost)
const condition = first(item.condition)
const viewItemUrl = item.viewItemURL || item.viewItemUrl || ""
return {
key: "manual-parts",
status: "error",
lastSuccessfulAt: null,
lastAttemptAt: now,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
itemId: item.itemId || "",
title: item.title || "Unknown Item",
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: first(item.galleryURL) || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: undefined,
affiliateLink: generateAffiliateLink(viewItemUrl),
}
}
export async function GET(request: Request) {
async function readEbayErrorMessage(response: Response) {
const text = await response.text().catch(() => "")
if (!text.trim()) {
return `eBay API error: ${response.status}`
}
try {
const parsed = JSON.parse(text) as any
const messages = parsed?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(messages?.message)
? messages.message[0]
: messages?.message
if (typeof message === "string" && message.trim()) {
const errorId = Array.isArray(messages?.errorId)
? messages.errorId[0]
: messages?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
} catch {
// Fall through to returning the raw text below.
}
return text.trim() || `eBay API error: ${response.status}`
}
function buildCacheKey(
keywords: string,
categoryId: string | undefined,
sortOrder: string,
maxResults: number
): string {
return [
keywords.trim().toLowerCase(),
categoryId || "",
sortOrder || "BestMatch",
String(maxResults),
].join("|")
}
function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null {
const cached = searchResponseCache.get(cacheKey)
if (!cached) {
return null
}
if (Date.now() - cached.timestamp > SEARCH_CACHE_TTL) {
searchResponseCache.delete(cacheKey)
return null
}
return cached.results
}
function setCachedSearchResults(cacheKey: string, results: eBaySearchResult[]) {
searchResponseCache.set(cacheKey, {
results,
timestamp: Date.now(),
})
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const keywords = searchParams.get("keywords")?.trim() || ""
const maxResults = Math.min(
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
20
const keywords = searchParams.get("keywords")
const categoryId = searchParams.get("categoryId") || undefined
const sortOrder = searchParams.get("sortOrder") || "BestMatch"
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
const cacheKey = buildCacheKey(
keywords || "",
categoryId,
sortOrder,
maxResults
)
if (!keywords) {
@ -67,54 +171,114 @@ export async function GET(request: Request) {
)
}
if (!hasConvexUrl()) {
const message =
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
return NextResponse.json({
query: keywords,
results: [],
cache: getDisabledCacheState(message),
cacheSource: "fallback" satisfies CacheSource,
error: message,
})
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
console.error("EBAY_APP_ID not configured")
return NextResponse.json(
{
error:
"eBay API not configured. Please set EBAY_APP_ID environment variable.",
},
{ status: 503 }
)
}
const cachedResults = getCachedSearchResults(cacheKey)
if (cachedResults) {
return NextResponse.json(cachedResults)
}
const inFlight = inFlightSearchResponses.get(cacheKey)
if (inFlight) {
try {
const results = await inFlight
return NextResponse.json(results)
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch products from eBay",
},
{ status: 500 }
)
}
}
// Build eBay Finding API URL
const baseUrl = "https://svcs.ebay.com/services/search/FindingService/v1"
const url = new URL(baseUrl)
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
url.searchParams.set("SERVICE-VERSION", "1.0.0")
url.searchParams.set("SECURITY-APPNAME", appId)
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
url.searchParams.set("REST-PAYLOAD", "true")
url.searchParams.set("keywords", keywords)
url.searchParams.set("sortOrder", sortOrder)
url.searchParams.set("paginationInput.entriesPerPage", maxResults.toString())
if (categoryId) {
url.searchParams.set("categoryId", categoryId)
}
try {
const [overview, listings] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
])
const request = (async () => {
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
})
const trustedListings = filterTrustedEbayListings(
listings as CachedEbayListing[]
)
const ranked = rankListingsForQuery(
keywords,
trustedListings,
maxResults
)
if (!response.ok) {
const errorMessage = await readEbayErrorMessage(response)
throw new Error(errorMessage)
}
return NextResponse.json({
query: keywords,
results: ranked,
cache: overview,
cacheSource: "convex" satisfies CacheSource,
})
const data = await response.json()
// Parse eBay API response
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
if (!findItemsAdvancedResponse) {
return []
}
const searchResult = findItemsAdvancedResponse.searchResult?.[0]
if (
!searchResult ||
!searchResult.item ||
searchResult.item.length === 0
) {
return []
}
const items = Array.isArray(searchResult.item)
? searchResult.item
: [searchResult.item]
return items.map((item: any) => normalizeItem(item))
})()
inFlightSearchResponses.set(cacheKey, request)
const results = await request
setCachedSearchResults(cacheKey, results)
return NextResponse.json(results)
} catch (error) {
console.error("Failed to load cached eBay listings:", error)
const message =
error instanceof Error
? `Cached eBay listings are unavailable: ${error.message}`
: "Cached eBay listings are unavailable."
console.error("Error fetching from eBay API:", error)
return NextResponse.json(
{
query: keywords,
results: [],
cache: getErrorCacheState(message),
cacheSource: "fallback" satisfies CacheSource,
error: message,
error:
error instanceof Error
? error.message
: "Failed to fetch products from eBay",
},
{ status: 200 }
{ status: 500 }
)
} finally {
inFlightSearchResponses.delete(cacheKey)
}
}

View file

@ -1,51 +0,0 @@
import { timingSafeEqual } from "node:crypto"
import { NextResponse } from "next/server"
import { hasConvexUrl } from "@/lib/convex-config"
function readBearerToken(request: Request) {
const authHeader = request.headers.get("authorization") || ""
if (!authHeader.toLowerCase().startsWith("bearer ")) {
return ""
}
return authHeader.slice("bearer ".length).trim()
}
function tokensMatch(expected: string, provided: string) {
const expectedBuffer = Buffer.from(expected)
const providedBuffer = Buffer.from(provided)
if (expectedBuffer.length !== providedBuffer.length) {
return false
}
return timingSafeEqual(expectedBuffer, providedBuffer)
}
export function getGhlSyncToken() {
return String(process.env.GHL_SYNC_CRON_TOKEN || "").trim()
}
export async function requireGhlSyncAuth(request: Request) {
if (!hasConvexUrl()) {
return NextResponse.json(
{ error: "Convex is not configured for GHL sync" },
{ status: 503 }
)
}
const configuredToken = getGhlSyncToken()
if (!configuredToken) {
return NextResponse.json(
{ error: "GHL sync token is not configured" },
{ status: 503 }
)
}
const providedToken = readBearerToken(request)
if (!providedToken || !tokensMatch(configuredToken, providedToken)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
return null
}

View file

@ -1,60 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
import { fetchGhlContacts } from "@/lib/server/ghl-sync"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const providedItems = Array.isArray(body.items) ? body.items : null
const fetched = providedItems
? {
items: providedItems,
nextCursor:
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
}
: await fetchGhlContacts({
limit: typeof body.limit === "number" ? body.limit : undefined,
cursor: body.cursor ? String(body.cursor) : undefined,
})
const imported = []
for (const item of fetched.items) {
const result = await fetchMutation(api.crm.importContact, {
provider: "ghl",
entityId: String(item.id || ""),
payload: item,
})
imported.push(result?._id || result?.id || null)
}
await fetchMutation(api.crm.updateSyncCheckpoint, {
provider: "ghl",
entityType: "contacts",
entityId: "contacts",
cursor: fetched.nextCursor,
status: "synced",
metadata: JSON.stringify({
imported: imported.length,
}),
})
return NextResponse.json({
success: true,
imported: imported.length,
nextCursor: fetched.nextCursor,
})
} catch (error) {
console.error("Failed to sync GHL contacts:", error)
return NextResponse.json(
{ error: "Failed to sync GHL contacts" },
{ status: 500 }
)
}
}

View file

@ -1,70 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const providedItems = Array.isArray(body.items) ? body.items : null
const fetched = providedItems
? {
items: providedItems,
nextCursor:
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
}
: await fetchGhlMessages({
limit: typeof body.limit === "number" ? body.limit : undefined,
cursor: body.cursor ? String(body.cursor) : undefined,
channel: body.channel === "Call" ? "Call" : "SMS",
})
const grouped = new Map<string, any>()
for (const item of fetched.items) {
const conversationId = String(item.conversationId || item.id || "")
if (!conversationId || grouped.has(conversationId)) {
continue
}
grouped.set(conversationId, item)
}
let imported = 0
for (const [entityId, item] of grouped.entries()) {
await fetchMutation(api.crm.importConversation, {
provider: "ghl",
entityId,
payload: item,
})
imported += 1
}
await fetchMutation(api.crm.updateSyncCheckpoint, {
provider: "ghl",
entityType: "conversations",
entityId: "conversations",
cursor: fetched.nextCursor,
status: "synced",
metadata: JSON.stringify({
imported,
}),
})
return NextResponse.json({
success: true,
imported,
nextCursor: fetched.nextCursor,
})
} catch (error) {
console.error("Failed to sync GHL conversations:", error)
return NextResponse.json(
{ error: "Failed to sync GHL conversations" },
{ status: 500 }
)
}
}

View file

@ -1,61 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const providedItems = Array.isArray(body.items) ? body.items : null
const fetched = providedItems
? {
items: providedItems,
nextCursor:
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
}
: await fetchGhlMessages({
limit: typeof body.limit === "number" ? body.limit : undefined,
cursor: body.cursor ? String(body.cursor) : undefined,
channel: body.channel === "Call" ? "Call" : "SMS",
})
let imported = 0
for (const item of fetched.items) {
await fetchMutation(api.crm.importMessage, {
provider: "ghl",
entityId: String(item.id || ""),
payload: item,
})
imported += 1
}
await fetchMutation(api.crm.updateSyncCheckpoint, {
provider: "ghl",
entityType: "messages",
entityId: "messages",
cursor: fetched.nextCursor,
status: "synced",
metadata: JSON.stringify({
imported,
}),
})
return NextResponse.json({
success: true,
imported,
nextCursor: fetched.nextCursor,
})
} catch (error) {
console.error("Failed to sync GHL messages:", error)
return NextResponse.json(
{ error: "Failed to sync GHL messages" },
{ status: 500 }
)
}
}

View file

@ -1,29 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const result = await fetchMutation(api.crm.reconcileExternalState, {
provider: body.provider ? String(body.provider) : "ghl",
})
return NextResponse.json({
success: true,
...result,
})
} catch (error) {
console.error("Failed to reconcile mirrored external state:", error)
return NextResponse.json(
{ error: "Failed to reconcile mirrored external state" },
{ status: 500 }
)
}
}

View file

@ -1,69 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
import { fetchGhlCallLogs } from "@/lib/server/ghl-sync"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const providedItems = Array.isArray(body.items) ? body.items : null
const fetched = providedItems
? {
items: providedItems,
page: typeof body.page === "number" ? body.page : 1,
total: providedItems.length,
pageSize: providedItems.length,
}
: await fetchGhlCallLogs({
page: typeof body.page === "number" ? body.page : undefined,
pageSize: typeof body.pageSize === "number" ? body.pageSize : undefined,
})
let imported = 0
for (const item of fetched.items) {
await fetchMutation(api.crm.importRecording, {
provider: "ghl",
entityId: String(item.id || item.messageId || ""),
payload: {
...item,
recordingId: item.messageId || item.id,
transcript: item.transcript,
recordingUrl: item.recordingUrl,
recordingStatus: item.transcript ? "completed" : "pending",
},
})
imported += 1
}
await fetchMutation(api.crm.updateSyncCheckpoint, {
provider: "ghl",
entityType: "recordings",
entityId: "recordings",
cursor: `${fetched.page}`,
status: "synced",
metadata: JSON.stringify({
imported,
total: fetched.total,
}),
})
return NextResponse.json({
success: true,
imported,
page: fetched.page,
total: fetched.total,
})
} catch (error) {
console.error("Failed to sync GHL recordings:", error)
return NextResponse.json(
{ error: "Failed to sync GHL recordings" },
{ status: 500 }
)
}
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const result = await fetchAction(api.crm.runGhlMirror, {
reason: body.reason ? String(body.reason) : "internal",
forceFullBackfill: Boolean(body.forceFullBackfill),
maxPagesPerRun:
typeof body.maxPagesPerRun === "number" ? body.maxPagesPerRun : undefined,
contactsLimit:
typeof body.contactsLimit === "number" ? body.contactsLimit : undefined,
messagesLimit:
typeof body.messagesLimit === "number" ? body.messagesLimit : undefined,
recordingsPageSize:
typeof body.recordingsPageSize === "number"
? body.recordingsPageSize
: undefined,
})
return NextResponse.json(result)
} catch (error) {
console.error("Failed to run GHL sync:", error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to run GHL sync",
},
{ status: 500 }
)
}
}

View file

@ -1,40 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { normalizePhoneE164 } from "@/lib/phone-normalization"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json()
const normalizedPhone = normalizePhoneE164(body.phone)
if (!normalizedPhone) {
return NextResponse.json(
{ error: "phone is required" },
{ status: 400 }
)
}
const context = await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
normalizedPhone,
})
return NextResponse.json({
success: true,
normalizedPhone,
...context,
})
} catch (error) {
console.error("Failed to look up phone agent contact context:", error)
return NextResponse.json(
{ error: "Failed to look up phone agent contact context" },
{ status: 500 }
)
}
}

View file

@ -1,179 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import {
buildSameDayReminderWindow,
createFollowupReminderEvent,
isGoogleCalendarConfigured,
} from "@/lib/google-calendar"
import { normalizePhoneE164, splitDisplayName } from "@/lib/phone-normalization"
function buildReminderTitle(args: {
kind: "scheduled" | "same-day"
callerName?: string
company?: string
phone?: string
}) {
const label = args.kind === "same-day" ? "Same-day callback" : "Callback reminder"
const identity = [args.callerName, args.company, args.phone]
.map((value) => String(value || "").trim())
.filter(Boolean)
.join(" | ")
return identity ? `${label}: ${identity}` : label
}
function buildReminderDescription(args: {
callerName?: string
company?: string
phone?: string
reason?: string
summaryText?: string
adminCallUrl: string
}) {
return [
args.callerName ? `Caller: ${args.callerName}` : "",
args.company ? `Company: ${args.company}` : "",
args.phone ? `Phone: ${args.phone}` : "",
args.reason ? `Reason: ${args.reason}` : "",
args.summaryText ? `Summary: ${args.summaryText}` : "",
`RMV admin call detail: ${args.adminCallUrl}`,
]
.filter(Boolean)
.join("\n")
}
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json()
const sessionId = String(body.sessionId || "").trim()
const kind =
body.kind === "same-day" ? ("same-day" as const) : ("scheduled" as const)
if (!sessionId) {
return NextResponse.json(
{ error: "sessionId is required" },
{ status: 400 }
)
}
const url = new URL(request.url)
const adminCallUrl = `${url.origin}/admin/calls/${sessionId}`
const normalizedPhone = normalizePhoneE164(body.phone)
const callerName = String(body.callerName || "").trim()
const company = String(body.company || "").trim()
const reason = String(body.reason || "").trim()
const summaryText = String(body.summaryText || "").trim()
const calendarConfigured = isGoogleCalendarConfigured()
let startAt: Date
let endAt: Date
if (kind === "same-day") {
const reminderWindow = buildSameDayReminderWindow()
startAt = reminderWindow.startAt
endAt = reminderWindow.endAt
} else {
startAt = new Date(String(body.startAt || ""))
endAt = new Date(String(body.endAt || ""))
if (Number.isNaN(endAt.getTime()) && !Number.isNaN(startAt.getTime())) {
endAt = new Date(startAt.getTime() + 15 * 60 * 1000)
}
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || startAt.getTime() <= Date.now()) {
return NextResponse.json(
{ error: "A future startAt and endAt are required" },
{ status: 400 }
)
}
}
if (kind === "scheduled" && !calendarConfigured) {
return NextResponse.json(
{ error: "Google Calendar follow-up scheduling is not configured" },
{ status: 503 }
)
}
const reminder = calendarConfigured
? await createFollowupReminderEvent({
title: buildReminderTitle({
kind,
callerName,
company,
phone: normalizedPhone || String(body.phone || "").trim(),
}),
description: buildReminderDescription({
callerName,
company,
phone: normalizedPhone || String(body.phone || "").trim(),
reason,
summaryText,
adminCallUrl,
}),
startAt,
endAt,
})
: {
eventId: "",
htmlLink: "",
}
let contactProfileId: string | undefined
if (normalizedPhone) {
const nameParts = splitDisplayName(callerName)
const profile = await fetchMutation(api.contactProfiles.upsertByPhone, {
normalizedPhone,
displayName: callerName || undefined,
firstName: nameParts.firstName || undefined,
lastName: nameParts.lastName || undefined,
company: company || undefined,
lastSummaryText: summaryText || reason || undefined,
lastReminderAt: Date.now(),
reminderNotes: reason || undefined,
source: "phone-agent",
})
contactProfileId = profile?._id
}
const call = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
sessionId,
contactProfileId,
contactDisplayName: callerName || undefined,
contactCompany: company || undefined,
reminderStatus: kind === "same-day" ? "sameDay" : "scheduled",
reminderRequestedAt: Date.now(),
reminderStartAt: startAt.getTime(),
reminderEndAt: endAt.getTime(),
reminderCalendarEventId: reminder.eventId || undefined,
reminderCalendarHtmlLink: reminder.htmlLink || undefined,
reminderNote:
reason ||
summaryText ||
(!calendarConfigured ? "Manual follow-up reminder created without Google Calendar." : undefined),
})
return NextResponse.json({
success: true,
calendarConfigured,
reminder: {
kind,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
eventId: reminder.eventId || null,
htmlLink: reminder.htmlLink || null,
},
call,
})
} catch (error) {
console.error("Failed to create phone agent follow-up reminder:", error)
return NextResponse.json(
{ error: "Failed to create phone agent follow-up reminder" },
{ status: 500 }
)
}
}

View file

@ -1,42 +0,0 @@
import { NextResponse } from "next/server"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import {
isGoogleCalendarConfigured,
listFutureCallbackSlots,
} from "@/lib/google-calendar"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const limit =
typeof body.limit === "number" && body.limit > 0
? Math.min(body.limit, 5)
: 3
if (!isGoogleCalendarConfigured()) {
return NextResponse.json({
success: true,
calendarConfigured: false,
slots: [],
})
}
const slots = await listFutureCallbackSlots(limit)
return NextResponse.json({
success: true,
calendarConfigured: true,
slots,
})
} catch (error) {
console.error("Failed to list phone agent callback slots:", error)
return NextResponse.json(
{ error: "Failed to list phone agent callback slots" },
{ status: 500 }
)
}
}

View file

@ -1,37 +0,0 @@
import { NextResponse } from "next/server"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { searchServiceKnowledge } from "@/lib/service-knowledge"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json()
const query = String(body.query || "").trim()
if (!query) {
return NextResponse.json({ error: "query is required" }, { status: 400 })
}
const results = await searchServiceKnowledge({
query,
limit:
typeof body.limit === "number" && body.limit > 0
? Math.min(body.limit, 6)
: 4,
})
return NextResponse.json({
success: true,
results,
})
} catch (error) {
console.error("Failed to search phone agent service knowledge:", error)
return NextResponse.json(
{ error: "Failed to search phone agent service knowledge" },
{ status: 500 }
)
}
}

View file

@ -14,13 +14,6 @@ export async function POST(request: Request) {
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
sessionId: body.sessionId,
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
contactProfileId: body.contactProfileId || undefined,
contactDisplayName: body.contactDisplayName
? String(body.contactDisplayName)
: undefined,
contactCompany: body.contactCompany
? String(body.contactCompany)
: undefined,
leadOutcome: body.leadOutcome || "none",
handoffRequested:
typeof body.handoffRequested === "boolean"
@ -29,41 +22,6 @@ export async function POST(request: Request) {
handoffReason: body.handoffReason
? String(body.handoffReason)
: undefined,
reminderStatus: body.reminderStatus || undefined,
reminderRequestedAt:
typeof body.reminderRequestedAt === "number"
? body.reminderRequestedAt
: undefined,
reminderStartAt:
typeof body.reminderStartAt === "number"
? body.reminderStartAt
: undefined,
reminderEndAt:
typeof body.reminderEndAt === "number"
? body.reminderEndAt
: undefined,
reminderCalendarEventId: body.reminderCalendarEventId
? String(body.reminderCalendarEventId)
: undefined,
reminderCalendarHtmlLink: body.reminderCalendarHtmlLink
? String(body.reminderCalendarHtmlLink)
: undefined,
reminderNote: body.reminderNote ? String(body.reminderNote) : undefined,
warmTransferStatus: body.warmTransferStatus || undefined,
warmTransferTarget: body.warmTransferTarget
? String(body.warmTransferTarget)
: undefined,
warmTransferAttemptedAt:
typeof body.warmTransferAttemptedAt === "number"
? body.warmTransferAttemptedAt
: undefined,
warmTransferConnectedAt:
typeof body.warmTransferConnectedAt === "number"
? body.warmTransferConnectedAt
: undefined,
warmTransferFailureReason: body.warmTransferFailureReason
? String(body.warmTransferFailureReason)
: undefined,
})
return NextResponse.json({ success: true, call: result })

View file

@ -1,8 +1,7 @@
import { NextResponse } from "next/server"
import { fetchMutation, fetchQuery } from "convex/nextjs"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { normalizePhoneE164 } from "@/lib/phone-normalization"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
@ -12,44 +11,16 @@ export async function POST(request: Request) {
try {
const body = await request.json()
let metadata: Record<string, unknown> = {}
if (typeof body.metadata === "string" && body.metadata.trim()) {
try {
metadata = JSON.parse(body.metadata)
} catch {
metadata = {}
}
}
const callerPhone = normalizePhoneE164(
metadata.participantPhone || body.participantIdentity
)
const contactContext = callerPhone
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
normalizedPhone: callerPhone,
})
: null
const result = await fetchMutation(
api.voiceSessions.upsertPhoneCallSession,
{
roomName: String(body.roomName || ""),
participantIdentity: String(body.participantIdentity || ""),
callerPhone: callerPhone || undefined,
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
pathname: body.pathname ? String(body.pathname) : undefined,
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
source: "phone-agent",
metadata: body.metadata ? String(body.metadata) : undefined,
contactProfileId: contactContext?.contactProfile?.id,
contactDisplayName:
contactContext?.contactProfile?.displayName ||
(contactContext?.recentLead
? `${contactContext.recentLead.firstName} ${contactContext.recentLead.lastName}`.trim()
: undefined),
contactCompany:
contactContext?.contactProfile?.company ||
contactContext?.recentLead?.company ||
undefined,
startedAt:
typeof body.startedAt === "number" ? body.startedAt : undefined,
recordingDisclosureAt:
@ -64,10 +35,6 @@ export async function POST(request: Request) {
success: true,
sessionId: result?._id,
roomName: result?.roomName,
callerPhone,
contactProfile: contactContext?.contactProfile || null,
recentLead: contactContext?.recentLead || null,
recentSession: contactContext?.recentSession || null,
})
} catch (error) {
console.error("Failed to start phone call sync:", error)

View file

@ -5,14 +5,7 @@ import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicProse,
PublicSurface,
} from "@/components/public-surface"
import type { Metadata } from "next"
import Link from "next/link"
const WORDPRESS_SLUG = "abandoned-vending-machines"
@ -88,61 +81,17 @@ export default async function AbandonedVendingMachinesPage() {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
<Breadcrumbs
className="mb-6"
items={[
{ label: "Blog", href: "/blog" },
{
label: page.title || "Abandoned Vending Machines",
href: "/blog/abandoned-vending-machines",
},
]}
/>
<PublicPageHeader
eyebrow="Article"
title={page.title || "Abandoned Vending Machines"}
description={
page.seoDescription ||
page.excerpt ||
"Guidance, next steps, and practical considerations from Rocky Mountain Vending."
}
align="center"
className="mx-auto mb-10 max-w-3xl"
/>
<PublicSurface className="p-5 md:p-7 lg:p-9">
<PublicProse className="mx-auto max-w-3xl">{content}</PublicProse>
</PublicSurface>
<PublicInset className="mx-auto mt-8 max-w-4xl border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.84))] p-5 md:p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Need Help With A Machine Situation?
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
Get the right kind of support quickly
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Reach out if you need help with abandoned machines, service questions,
moving help, or figuring out the right next step for your location.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Link
href="/contact-us#contact-form"
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
Talk to Our Team
</Link>
<Link
href="/services/repairs"
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
>
Explore Repair Help
</Link>
</div>
</div>
</PublicInset>
<Breadcrumbs
items={[
{ label: "Blog", href: "/blog" },
{
label: page.title || "Abandoned Vending Machines",
href: "/blog/abandoned-vending-machines",
},
]}
/>
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
{content}
</article>
</>
)

View file

@ -183,8 +183,7 @@
transition:
color 0.2s ease,
text-decoration-color 0.2s ease,
opacity 0.2s ease,
transform 0.2s ease;
opacity 0.2s ease;
}
a:hover,
@ -203,8 +202,7 @@
transition:
color 0.2s ease,
text-decoration-color 0.2s ease,
opacity 0.2s ease,
transform 0.2s ease;
opacity 0.2s ease;
}
a[href]:hover,
@ -213,29 +211,6 @@
background-color: transparent;
}
button a,
[role="button"] a,
.bg-primary a,
.text-primary-foreground a {
color: inherit;
}
button a:hover,
button a:focus,
[role="button"] a:hover,
[role="button"] a:focus,
.bg-primary a:hover,
.bg-primary a:focus,
.text-primary-foreground a:hover,
.text-primary-foreground a:focus {
color: inherit;
}
p,
li {
text-wrap: pretty;
}
a:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,

View file

@ -1,9 +1,8 @@
export const dynamic = "force-dynamic"
import { createHash } from "node:crypto"
import { existsSync } from "node:fs"
import { existsSync } from "fs"
import { join } from "path"
import { Metadata } from "next"
import { headers } from "next/headers"
import { businessConfig } from "@/lib/seo-config"
import { ManualsPageExperience } from "@/components/manuals-page-experience"
import { listConvexManuals } from "@/lib/convex-service"
@ -11,8 +10,6 @@ import { scanManuals } from "@/lib/manuals"
import { selectManualsForSite } from "@/lib/manuals-site-selection"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
export const metadata: Metadata = generateSEOMetadata({
title: "Vending Machine Manuals | Rocky Mountain Vending",
@ -34,59 +31,29 @@ export const metadata: Metadata = generateSEOMetadata({
})
export default async function ManualsPage() {
const requestHeaders = await headers()
const requestHost =
requestHeaders.get("x-forwarded-host") || requestHeaders.get("host")
const manualsDomain = resolveManualsTenantDomain({
requestHost,
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
})
const convexManuals = manualsDomain
? await listConvexManuals(manualsDomain)
: []
const isLocalDevelopment = process.env.NODE_ENV === "development"
const shouldUseFilesystemFallback = isLocalDevelopment
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
const convexManuals = await listConvexManuals()
const allManuals =
convexManuals.length > 0 || !shouldUseFilesystemFallback
? convexManuals
: await scanManuals()
convexManuals.length > 0 ? convexManuals : await scanManuals()
let manuals =
convexManuals.length > 0
? convexManuals
: shouldUseFilesystemFallback
? selectManualsForSite(allManuals, manualsDomain).manuals
: []
: selectManualsForSite(allManuals).manuals
const shouldShowDegradedState =
!shouldUseFilesystemFallback && manuals.length === 0
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
const thumbnailsRoot = getManualsThumbnailsRoot()
manuals = manuals.map((manual) => {
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
return manual
}
if (shouldShowDegradedState) {
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || ""
const convexUrlHash = convexUrl
? createHash("sha256").update(convexUrl).digest("hex").slice(0, 12)
: "missing"
const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/")
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "")
: manual.thumbnailUrl
console.error(
JSON.stringify({
event: "manuals.degraded_empty_tenant",
severity: "error",
domain: manualsDomain || "missing",
host: requestHost || "missing",
manualCount: manuals.length,
convexManualCount: convexManuals.length,
convexUrlHash,
})
)
}
manuals = sanitizeManualThumbnailsForRuntime(manuals, {
isLocalDevelopment,
thumbnailsRoot: getManualsThumbnailsRoot(),
fileExists: existsSync,
return existsSync(join(thumbnailsRoot, relativeThumbnailPath))
? manual
: { ...manual, thumbnailUrl: undefined }
})
// Generate structured data for SEO
@ -132,22 +99,8 @@ export default async function ManualsPage() {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
/>
<div className="public-page" data-manuals-domain={manualsDomain}>
{shouldShowDegradedState ? (
<div className="mx-auto max-w-[var(--public-shell-max)] px-4 py-10 sm:px-5 lg:px-6">
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-6">
<h1 className="text-xl font-semibold text-foreground">
Manuals Library Temporarily Unavailable
</h1>
<p className="mt-2 text-sm text-muted-foreground">
We are restoring tenant catalog data for this domain. Please
refresh shortly or contact support if this persists.
</p>
</div>
</div>
) : (
<ManualsPageExperience initialManuals={manuals} />
)}
<div className="public-page">
<ManualsPageExperience initialManuals={manuals} />
</div>
</>
)

View file

@ -37,10 +37,10 @@ function LocationCard({
}) {
return (
<Link href={href} className="group block h-full">
<PublicInset className="h-full p-4 transition-all hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[0_20px_48px_rgba(0,0,0,0.09)] md:p-5">
<PublicSurface className="h-full p-5 transition-all hover:-translate-y-0.5 hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)] md:p-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground transition-colors group-hover:text-primary">
<h3 className="text-xl font-semibold text-foreground transition-colors group-hover:text-primary">
{city}
</h3>
<p className="text-sm text-muted-foreground">{zipCode}</p>
@ -49,15 +49,15 @@ function LocationCard({
<ArrowRight className="h-4 w-4" />
</div>
</div>
<div className="mt-4 rounded-[1.1rem] border border-border/45 bg-background/60 p-3">
<PublicInset className="mt-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/75">
Popular Areas
</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{neighborhoods.slice(0, 2).join(", ")}
</p>
</div>
</PublicInset>
</PublicInset>
</PublicSurface>
</Link>
)
}
@ -222,7 +222,7 @@ export default function ServiceAreasPage() {
</PublicSurface>
</section>
<section className="mt-12 space-y-8">
<section className="mt-12 space-y-12">
{[
{
title: "Salt Lake County",
@ -243,24 +243,19 @@ export default function ServiceAreasPage() {
items: utahCounty,
},
].map((section) => (
<PublicSurface key={section.title} className="p-5 md:p-6">
<div className="flex flex-col gap-4 border-b border-border/55 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Coverage Area
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-balance">
{section.title}
</h2>
<p className="mt-2 max-w-3xl text-base leading-relaxed text-muted-foreground">
{section.description}
</p>
</div>
<div className="w-fit rounded-full border border-border/55 bg-background/70 px-3 py-1 text-sm text-muted-foreground">
{section.items.length} cities
</div>
<div key={section.title} className="space-y-5">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Coverage Area
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-balance">
{section.title}
</h2>
<p className="mt-2 max-w-3xl text-base leading-relaxed text-muted-foreground">
{section.description}
</p>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{section.items.map((location) => (
<LocationCard
key={location.slug}
@ -271,11 +266,11 @@ export default function ServiceAreasPage() {
/>
))}
</div>
</PublicSurface>
</div>
))}
</section>
<section className="mt-12 grid gap-6 lg:grid-cols-2">
<section className="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<PublicSurface>
<h2 className="text-3xl font-semibold tracking-tight text-balance">
Why businesses choose Rocky Mountain Vending

View file

@ -1,9 +1,11 @@
import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import {
generateRegistryMetadata,
generateRegistryStructuredData,
} from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { FAQSection } from "@/components/faq-section"
import { ServiceAreasSection } from "@/components/service-areas-section"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@ -56,13 +58,21 @@ export default async function RepairsPage() {
notFound()
}
let imageMapping: any = {}
try {
imageMapping = loadImageMapping()
} catch (e) {
imageMapping = {}
}
// Extract FAQs from content
const faqs: Array<{ question: string; answer: string }> = []
let contentWithoutFAQs = page.content || ""
let contentWithoutVirtualServices = ""
let virtualServicesContent = ""
if (page.content) {
const contentStr = String(page.content)
let strippedContent = contentStr
// Extract FAQ items from accordion structure
const questionMatches = contentStr.matchAll(
@ -102,28 +112,40 @@ export default async function RepairsPage() {
if (faqs.length > 0) {
const faqSectionRegex =
/<h2[^>]*>.*?Answers\s+To\s+Common\s+Questions.*?<\/h2>[\s\S]*?(?=<h2[^>]*>.*?Virtual\s+Services|<h2[^>]*>.*?Service\s+Area|$)/i
strippedContent = contentStr.replace(faqSectionRegex, "").trim()
contentWithoutFAQs = contentStr.replace(faqSectionRegex, "").trim()
}
// Extract Virtual Services section
const virtualServicesRegex =
/<h2[^>]*>.*?Virtual\s+Services.*?<\/h2>([\s\S]*?)(?=<h2[^>]*>.*?Service\s+Area|$)/i
const virtualMatch = strippedContent.match(virtualServicesRegex)
const virtualMatch = contentStr.match(virtualServicesRegex)
if (virtualMatch) {
virtualServicesContent = virtualMatch[1]
// Remove Virtual Services from main content
contentWithoutVirtualServices = contentWithoutFAQs
.replace(virtualServicesRegex, "")
.trim()
} else {
contentWithoutVirtualServices = contentWithoutFAQs
}
}
const content = contentWithoutVirtualServices ? (
<div className="max-w-none">
{cleanWordPressContent(String(contentWithoutVirtualServices), {
imageMapping,
pageTitle: page.title,
})}
</div>
) : (
<p className="text-muted-foreground">No content available.</p>
)
const structuredData = generateRegistryStructuredData("repairs", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
const excerpt = String(page.excerpt || "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
const surfaceCardClass =
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] shadow-[var(--public-surface-shadow)]"
const insetCardClass =
@ -149,14 +171,37 @@ export default async function RepairsPage() {
align="center"
className="mb-8"
eyebrow="Repair Services"
title="Vending machine repairs and service for Utah businesses"
title={page.title || "Vending Machine Repairs and Service"}
description={
"Get help with payment issues, refrigeration problems, machine errors, and ongoing maintenance from a local vending service team."
"Rocky Mountain Vending delivers expert vending machine repair and maintenance services to keep your business thriving."
}
>
<p className="mx-auto max-w-3xl text-base leading-relaxed text-muted-foreground md:text-lg">
{excerpt ||
"Rocky Mountain Vending helps businesses across Davis, Salt Lake, and Utah counties keep machines running with practical repair, maintenance, and support guidance."}
Rocky Mountain Vending delivers expert{" "}
<Link
href="/services/repairs"
className="text-primary hover:underline font-semibold"
>
vending machine repair
</Link>{" "}
and maintenance services to keep your business thriving. From
resolving jammed coin slots and refrigeration issues to fixing
non-dispensing machines, our skilled technicians ensure reliable
performance. For all your{" "}
<Link
href="/services/parts"
className="text-primary hover:underline"
>
vending machine parts
</Link>{" "}
needs and professional{" "}
<Link
href="/services/moving"
className="text-primary hover:underline"
>
vending machine moving
</Link>{" "}
services, contact us today for fast, professional solutions!
</p>
</PublicPageHeader>
{/* Images Carousel */}
@ -166,71 +211,15 @@ export default async function RepairsPage() {
</div>
</section>
<section className="py-16 md:py-20 bg-background">
<div className="container mx-auto grid max-w-6xl gap-6 px-4 lg:grid-cols-[1.08fr_0.92fr]">
<PublicSurface className="h-full">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Repair Overview
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
Clear next steps when a machine is down, rejecting payments, or
needs service.
</h2>
<p className="mt-4 text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
We help with common vending issues like bill acceptor problems,
refrigeration failures, card reader troubleshooting, controller
errors, and recurring maintenance needs. If the issue can be
handled virtually, we can talk through that too.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Link
href="#request-service"
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:opacity-95"
>
Request Service
</Link>
<Link
href="/services/parts"
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-background px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
>
Need Parts Instead?
</Link>
</div>
</PublicSurface>
<PublicInset className="h-full p-5 md:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Before You Reach Out
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
The more detail you share, the faster we can point you in the
right direction.
</h2>
<ul className="mt-5 space-y-3">
<li className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<span className="text-sm leading-6 text-muted-foreground">
Include the machine model, brand, and whether the issue is
intermittent or constant.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<span className="text-sm leading-6 text-muted-foreground">
Tell us if the problem is payment-related, refrigeration,
dispensing, display errors, or a recent setup change.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<span className="text-sm leading-6 text-muted-foreground">
Photos or short videos can make remote triage much easier
before an on-site visit is scheduled.
</span>
</li>
</ul>
</PublicInset>
</div>
</section>
{contentWithoutVirtualServices ? (
<section className="py-16 md:py-20 bg-background">
<div className="container mx-auto px-4 max-w-4xl">
<PublicSurface>
<div className="max-w-none">{content}</div>
</PublicSurface>
</div>
</section>
) : null}
{/* Services Section */}
<section className="py-20 md:py-28 bg-muted/30">

View file

@ -1,35 +1,12 @@
import { redirect } from "next/navigation"
import {
getAdminUserFromCookies,
isAdminCredentialLoginConfigured,
isAdminUiEnabled,
} from "@/lib/server/admin-auth"
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
type PageProps = {
searchParams: Promise<{
error?: string
}>
}
export default async function SignInPage({ searchParams }: PageProps) {
export default function SignInPage() {
if (!isAdminUiEnabled()) {
redirect("/")
}
const adminUser = await getAdminUserFromCookies()
if (adminUser) {
redirect("/admin")
}
const params = await searchParams
const errorMessage =
params.error === "invalid"
? "That email or password was not accepted."
: params.error === "config"
? "Admin access is not available right now."
: ""
return (
<div className="px-4 py-8 md:py-12">
<div className="mx-auto flex min-h-[calc(100dvh-7rem)] max-w-3xl items-start justify-center md:items-center">
@ -42,55 +19,13 @@ export default async function SignInPage({ searchParams }: PageProps) {
/>
<PublicSurface className="p-6 text-center md:p-8">
{isAdminCredentialLoginConfigured() ? (
<form
action="/api/admin/auth/login"
method="post"
className="mx-auto max-w-sm space-y-4 text-left"
>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
required
/>
</div>
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
<button className="inline-flex h-11 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">
Sign in
</button>
</form>
) : (
<>
<h2 className="text-2xl font-semibold">
Admin sign-in is not configured
</h2>
<p className="mt-3 text-sm text-muted-foreground">
Admin access is not available right now.
</p>
</>
)}
<h2 className="text-2xl font-semibold">
Admin sign-in is not configured
</h2>
<p className="mt-3 text-sm text-muted-foreground">
Enable the admin UI and connect an auth provider before using this
area.
</p>
</PublicSurface>
</div>
</div>

View file

@ -1,74 +1,16 @@
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import {
CheckCircle2,
CreditCard,
Refrigerator,
ShoppingCart,
} from "lucide-react"
import { loadImageMapping } from "@/lib/wordpress-content"
import type { ImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import {
PublicInset,
PublicPageHeader,
PublicSectionHeader,
PublicSurface,
} from "@/components/public-surface"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import type { Metadata } from "next"
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
import { Breadcrumbs } from "@/components/breadcrumbs"
import { Button } from "@/components/ui/button"
const WORDPRESS_SLUG = "vending-machines-for-sale-in-utah"
const machineHighlights = [
"Snack, drink, combo, and card-reader-ready equipment options",
"New and used machine guidance based on traffic, budget, and product mix",
"Local help with payment hardware, installation planning, and next-step questions",
]
const machineOptions = [
{
title: "Snack and drink machines",
description:
"Traditional snack, beverage, and combo machines for breakrooms, customer spaces, and mixed-traffic locations.",
icon: ShoppingCart,
},
{
title: "Cashless payment hardware",
description:
"Card reader and mobile-payment options that help modernize older machines or support new installs.",
icon: CreditCard,
},
{
title: "Refrigerated equipment",
description:
"Cold drink and refrigerated machine options for workplaces that need dependable temperature-controlled service.",
icon: Refrigerator,
},
]
const buyingSteps = [
{
title: "Tell us about the location",
body: "We learn about traffic, available space, product needs, and whether you are comparing free placement with a direct purchase.",
},
{
title: "Compare the right machine options",
body: "We help narrow down machine styles, payment hardware, and new-versus-used tradeoffs so you are looking at realistic fits.",
},
{
title: "Plan install and support",
body: "Once you know what you want, we can talk through delivery, setup, payment configuration, and any follow-up service needs.",
},
]
function normalizeWpImageUrl(url?: string) {
if (!url) return null
return url.replace("https:///", "https://rockymountainvending.com/")
}
export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG)
@ -97,6 +39,13 @@ export default async function MachinesForSalePage() {
notFound()
}
let imageMapping: ImageMapping = {}
try {
imageMapping = loadImageMapping()
} catch {
imageMapping = {}
}
const structuredData = (() => {
try {
return generateStructuredData({
@ -121,16 +70,6 @@ export default async function MachinesForSalePage() {
}
})()
const heroImage =
normalizeWpImageUrl(page.images?.[0]?.url) ??
"https://rockymountainvending.com/wp-content/uploads/2024/01/EH0A1551-HDR.webp"
const comboImage =
normalizeWpImageUrl(page.images?.[1]?.url) ??
"https://rockymountainvending.com/wp-content/uploads/2022/06/Seage-HY900-Combo.webp"
const paymentImage =
normalizeWpImageUrl(page.images?.[2]?.url) ??
"https://rockymountainvending.com/wp-content/uploads/2024/01/Parlevel-Pay-Plus.jpg"
return (
<>
<script
@ -149,168 +88,19 @@ export default async function MachinesForSalePage() {
]}
/>
<PublicPageHeader
align="center"
eyebrow="Machine Sales"
title="Compare vending machines, payment hardware, and purchase options with a local Utah team."
description="If you are looking at buying equipment instead of free placement, we can help you compare machine styles, payment systems, and next-step support without sending you through a generic catalog dump."
>
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
<Button asChild size="lg" className="min-h-11 rounded-full px-6">
<Link href="/contact-us#contact-form">Ask About Sales</Link>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="min-h-11 rounded-full px-6"
>
<Link href="/products">Browse Product Listings</Link>
</Button>
</div>
</PublicPageHeader>
title={page.title || "Vending Machines for Sale in Utah"}
description="Compare machine options, payment hardware, and support with help from the Rocky Mountain Vending team."
/>
<section className="mt-10 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<PublicSurface className="flex h-full flex-col justify-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Sales Overview
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
Buying a machine should feel clear before you spend money.
</h2>
<p className="mt-4 text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
We help Utah businesses sort through machine type, payment
hardware, and install considerations so you can decide whether a
direct purchase is the right move for your location.
</p>
<ul className="mt-6 space-y-3">
{machineHighlights.map((highlight) => (
<li key={highlight} className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<span className="text-sm leading-6 text-foreground md:text-base">
{highlight}
</span>
</li>
))}
</ul>
</PublicSurface>
<div className="relative min-h-[320px] overflow-hidden rounded-[var(--public-surface-radius)] border border-border/65 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.96))] p-3 shadow-[0_20px_52px_rgba(15,23,42,0.075)]">
<Image
src={heroImage}
alt="Vending machine option available for sale in Utah"
fill
className="rounded-[calc(var(--public-surface-radius)-0.45rem)] object-cover"
sizes="(max-width: 1280px) 100vw, 560px"
priority
/>
</div>
</section>
<section className="mt-12">
<PublicSectionHeader
eyebrow="Machine Options"
title="What businesses usually want help comparing"
description="Most sales conversations come down to the machine type, payment setup, and whether a direct purchase makes more sense than placement."
className="mx-auto mb-8 max-w-3xl text-center"
/>
<div className="grid gap-4 lg:grid-cols-3">
{machineOptions.map((option) => {
const Icon = option.icon
return (
<PublicInset key={option.title} className="h-full p-5 md:p-6">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
<h3 className="mt-4 text-xl font-semibold tracking-tight text-foreground">
{option.title}
</h3>
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
{option.description}
</p>
</PublicInset>
)
<PublicSurface className="mt-10">
<div className="max-w-none">
{cleanWordPressContent(String(page.content || ""), {
imageMapping,
pageTitle: page.title,
})}
</div>
</section>
<section className="mt-12 grid gap-6 lg:grid-cols-[0.98fr_1.02fr]">
<PublicSurface className="overflow-hidden p-0">
<div className="grid gap-0 md:grid-cols-2">
<div className="relative min-h-[260px]">
<Image
src={comboImage}
alt="Combo vending machine for sale"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 360px"
/>
</div>
<div className="flex flex-col justify-center p-5 md:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
New vs. Used
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
We can help you sort through budget, features, and condition.
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
Some buyers need dependable starter equipment. Others need a
cleaner, more modern machine with stronger payment support.
We can talk through both without pushing you into the wrong
setup.
</p>
</div>
</div>
</PublicSurface>
<PublicSurface className="overflow-hidden p-0">
<div className="grid gap-0 md:grid-cols-2">
<div className="order-2 flex flex-col justify-center p-5 md:order-1 md:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Payment Hardware
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
Card readers and cashless upgrades are often part of the decision.
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
If you are trying to modernize how people pay, we can help
you think through card readers, mobile payments, and
compatibility before you commit to a machine.
</p>
</div>
<div className="relative order-1 min-h-[260px] md:order-2">
<Image
src={paymentImage}
alt="Cashless payment hardware for vending machines"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 360px"
/>
</div>
</div>
</PublicSurface>
</section>
<section className="mt-12">
<PublicSectionHeader
eyebrow="Buying Process"
title="A simpler way to move from questions to a real option"
description="You do not need to have the exact model picked out before you reach out. Most of the work is narrowing to the right fit."
className="mx-auto mb-8 max-w-3xl text-center"
/>
<div className="grid gap-4 lg:grid-cols-3">
{buyingSteps.map((step, index) => (
<PublicInset key={step.title} className="h-full p-5 md:p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-lg font-semibold text-primary-foreground">
{index + 1}
</div>
<h3 className="mt-4 text-xl font-semibold tracking-tight text-foreground">
{step.title}
</h3>
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
{step.body}
</p>
</PublicInset>
))}
</div>
</section>
</PublicSurface>
<section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<PublicSurface>
@ -321,9 +111,9 @@ export default async function MachinesForSalePage() {
Need a free machine instead of buying one?
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
If you are a business looking for placement instead of a
purchase, we can help you figure out whether your location is a
fit before you spend money on equipment.
If you&apos;re a business looking for placement instead of a
purchase, we can help you find the right setup for your
location.
</p>
<div className="mt-6">
<GetFreeMachineCta buttonLabel="Get Free Placement" />
@ -335,23 +125,13 @@ export default async function MachinesForSalePage() {
Need Sales Help?
</p>
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
Talk through machine sales, placement, or payment questions.
Talk through machine sales, placement, or feature questions.
</h3>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
We can help with new vs. used options, payment hardware, and
whether free placement or a direct purchase makes more sense
for your location.
</p>
<div className="mt-6">
<Button
asChild
size="lg"
variant="outline"
className="min-h-11 rounded-full px-6"
>
<Link href="/contact-us#contact-form">Talk to Sales</Link>
</Button>
</div>
</div>
</PublicSurface>
</section>

View file

@ -11,7 +11,7 @@ import {
export function AboutPage() {
return (
<div className="public-page">
<div className="public-page max-w-6xl">
<Breadcrumbs
className="mb-6"
items={[{ label: "About Us", href: "/about-us" }]}

View file

@ -29,37 +29,7 @@ export function ContactPage() {
description="Use the form for repairs, moving, manuals, machine sales, or general questions. If you'd rather talk now, call us during business hours."
/>
<section className="mt-10 grid gap-4 lg:grid-cols-3">
<PublicInset className="h-full p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Repairs
</p>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Include the machine model, what the machine is doing, and any photos
or videos that can help us triage the issue faster.
</p>
</PublicInset>
<PublicInset className="h-full p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Sales or Placement
</p>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Tell us about your location, traffic, and whether you are asking
about free placement, machine sales, or both.
</p>
</PublicInset>
<PublicInset className="h-full p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Manuals or Parts
</p>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Share the machine brand and model so we can point you toward the
right part, manual, or support path.
</p>
</PublicInset>
</section>
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] lg:items-start">
<div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)] lg:items-start">
<PublicSurface id="contact-form" as="section" className="p-5 md:p-7">
<div className="mb-6 flex flex-wrap items-center gap-3">
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
@ -75,21 +45,7 @@ export function ContactPage() {
/>
</PublicSurface>
<aside className="space-y-4 lg:sticky lg:top-28">
<PublicSurface className="p-6">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Quick Guidance
</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">
We&apos;ll route you to the right next step
</h2>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
If you are not sure whether to ask about placement, repairs,
moving, manuals, or sales, that&apos;s fine. Send the details you
have and we&apos;ll help sort it out.
</p>
</PublicSurface>
<aside className="space-y-5">
<PublicSurface className="overflow-hidden p-6">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Direct Options
@ -103,10 +59,10 @@ export function ContactPage() {
below.
</p>
<div className="mt-5 space-y-3">
<div className="mt-6 space-y-4">
<a
href={businessConfig.publicCallUrl}
className="flex items-start gap-4 rounded-2xl border border-border/55 bg-background/65 px-4 py-4 transition hover:border-primary/35"
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
>
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Phone className="h-5 w-5" />
@ -124,7 +80,7 @@ export function ContactPage() {
<a
href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`}
className="flex items-start gap-4 rounded-2xl border border-border/55 bg-background/65 px-4 py-4 transition hover:border-primary/35"
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
>
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Mail className="h-5 w-5" />
@ -157,7 +113,7 @@ export function ContactPage() {
</div>
</div>
<div className="mt-4 space-y-2">
<div className="mt-5 space-y-2">
{businessHours.map((schedule) => (
<PublicInset
key={schedule.day}

View file

@ -5,7 +5,6 @@ import { Breadcrumbs, type BreadcrumbItem } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicProse,
PublicSurface,
} from "@/components/public-surface"
import { cn } from "@/lib/utils"
@ -22,7 +21,6 @@ interface DropdownPageShellProps {
title: string
description?: string
headerContent?: ReactNode
contentIntro?: ReactNode
content: ReactNode
contentClassName?: string
contentSurfaceClassName?: string
@ -43,7 +41,6 @@ export function DropdownPageShell({
title,
description,
headerContent,
contentIntro,
content,
contentClassName,
contentSurfaceClassName,
@ -64,43 +61,19 @@ export function DropdownPageShell({
{headerContent}
</PublicPageHeader>
{contentIntro ? (
<section className="mt-10 grid gap-5 lg:grid-cols-2">{contentIntro}</section>
) : null}
<section className={cn(contentIntro ? "mt-8" : "mt-10")}>
<section className="mt-10">
<PublicSurface
className={cn(
"relative overflow-hidden p-0 md:p-0",
contentSurfaceClassName
)}
className={cn("overflow-hidden", contentSurfaceClassName)}
>
<div className="absolute inset-x-0 top-0 h-24 bg-[radial-gradient(circle_at_top,rgba(41,160,71,0.09),transparent_74%)]" />
<div className="relative p-5 md:p-7 lg:p-10">
<div className="mb-8 flex items-center justify-between gap-4 border-b border-border/55 pb-5">
<div>
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
Location Guide
</p>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
How Rocky Mountain Vending typically approaches this type of
location, from placement fit to service expectations.
</p>
</div>
</div>
<PublicProse className={cn("mx-auto max-w-3xl", contentClassName)}>
{content}
</PublicProse>
</div>
<div className={cn("max-w-none", contentClassName)}>{content}</div>
</PublicSurface>
</section>
{sections ? <div className="mt-14 space-y-14">{sections}</div> : null}
{sections ? <div className="mt-12 space-y-12">{sections}</div> : null}
{cta ? (
<section className="mt-14">
<PublicSurface className="overflow-hidden text-center">
<div className="absolute inset-x-0 top-0 h-20 bg-[radial-gradient(circle_at_top,rgba(41,160,71,0.10),transparent_70%)]" />
<section className="mt-12">
<PublicSurface className="text-center">
{cta.eyebrow ? (
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
{cta.eyebrow}
@ -126,7 +99,7 @@ export function DropdownPageShell({
))}
</div>
{cta.note ? (
<PublicInset className="mx-auto mt-6 max-w-2xl border-primary/10 text-left sm:text-center">
<PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center">
{cta.note}
</PublicInset>
) : null}

View file

@ -8,15 +8,15 @@ const features = [
title: "Free Placement",
description:
"For qualifying locations, we can place the machines, stock them, and stay responsible for day-to-day service after install.",
link: "/#how-it-works",
link: "/about-us",
linkText: "How placement works",
},
{
icon: Wrench,
title: "Repairs and Services",
title: "Repairs and Service",
description:
"We handle repairs, restocking, and routine service so your team does not have to manage the machines.",
link: "/services/repairs#request-service",
link: "/services/repairs",
linkText: "Repair services",
},
{

View file

@ -19,7 +19,7 @@ export function Footer() {
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 py-14 sm:px-5 md:py-20 lg:px-6">
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.9fr_0.9fr_1fr]">
{/* Company Info */}
<div className="px-6 py-5 lg:col-span-1">
<div className="rounded-[2rem] border border-border/60 bg-white/92 p-6 shadow-[0_18px_48px_rgba(15,23,42,0.07)] lg:col-span-1">
<Link href="/" className="inline-flex">
<Image
src="/rmv-logo.png"
@ -104,7 +104,7 @@ export function Footer() {
</div>
{/* Services */}
<div className="px-5 py-5">
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
<h3 className="font-semibold mb-5 text-base">Services</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>
@ -159,7 +159,7 @@ export function Footer() {
</div>
{/* Company */}
<div className="px-5 py-5">
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
<h3 className="font-semibold mb-5 text-base">Company</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>
@ -198,7 +198,7 @@ export function Footer() {
</div>
{/* Service Areas */}
<div className="px-5 py-5">
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
<h3 className="font-semibold mb-5 text-base">Service Areas</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>

View file

@ -162,16 +162,9 @@ export function Header() {
{ label: "Reviews", href: "/reviews" },
{ label: "FAQs", href: "/about/faqs" },
]
const moreItems = [
{ label: "Food & Beverage", href: "/food-and-beverage/healthy-options" },
{ label: "Blog Posts", href: "/blog" },
{ label: "About Us", href: "/about-us" },
{ label: "Products", href: "/products" },
{ label: "Service Areas", href: "/service-areas" },
]
const desktopLinkClassName =
"inline-flex items-center whitespace-nowrap rounded-full px-2.5 py-2 text-[0.95rem] font-medium text-foreground transition hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 lg:px-3 lg:text-sm"
"rounded-full px-3 py-2 text-sm font-medium text-foreground transition hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
const mobileLinkClassName =
"flex min-h-11 items-center rounded-[1rem] px-4 text-sm font-medium text-foreground transition hover:bg-primary/6 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
const mobileGroupButtonClassName =
@ -182,7 +175,7 @@ export function Header() {
return (
<header className="sticky top-0 z-40 w-full border-b border-border/50 bg-white/92 shadow-[0_10px_35px_rgba(15,23,42,0.06)] backdrop-blur supports-[backdrop-filter]:bg-white/80">
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 sm:px-5 lg:px-6">
<div className="flex h-[var(--header-height)] items-center justify-between gap-3 md:hidden lg:gap-6">
<div className="flex h-[var(--header-height)] items-center justify-between gap-3 lg:gap-6">
{/* Logo */}
<Link
href="/"
@ -199,7 +192,7 @@ export function Header() {
</Link>
{/* Desktop Navigation */}
<nav className="hidden flex-1 items-center justify-center gap-1 2xl:flex 2xl:gap-2">
<nav className="hidden flex-1 items-center justify-center gap-1 md:flex lg:gap-2">
<Link href="/" className={desktopLinkClassName}>
Home
</Link>
@ -378,7 +371,7 @@ export function Header() {
</nav>
{/* Desktop CTA */}
<div className="hidden flex-shrink-0 items-center gap-2 2xl:flex 2xl:gap-3">
<div className="hidden flex-shrink-0 items-center gap-2 md:flex lg:gap-3">
<CartButton
onClick={() => dispatch({ type: "SET_CART", value: true })}
/>
@ -400,7 +393,7 @@ export function Header() {
{/* Mobile Menu Button */}
<button
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-white text-foreground transition hover:border-primary/35 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 2xl:hidden"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-white text-foreground transition hover:border-primary/35 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 md:hidden"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
aria-label="Toggle menu"
aria-expanded={state.isMenuOpen}
@ -413,161 +406,6 @@ export function Header() {
</button>
</div>
<div className="hidden md:block">
<div className="flex min-h-[4.75rem] items-center justify-between gap-4 py-3">
<Link
href="/"
className="flex min-w-0 flex-shrink-0 items-center gap-2 rounded-full"
>
<Image
src="/rmv-logo.png"
alt="Rocky Mountain Vending"
width={220}
height={55}
className="h-12 w-auto object-contain lg:h-14"
priority
/>
</Link>
<div className="flex min-w-0 flex-shrink-0 items-center gap-2 lg:gap-3">
<CartButton
onClick={() => dispatch({ type: "SET_CART", value: true })}
/>
<a
href="tel:+14352339668"
className="inline-flex min-h-10 items-center gap-2 rounded-full border border-border/60 bg-white px-3 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary lg:px-4"
>
<Phone className="h-4 w-4 flex-shrink-0" />
<span className="hidden xl:inline">(435) 233-9668</span>
<span className="xl:hidden">Call</span>
</a>
<Button
onClick={() => dispatch({ type: "SET_MODAL", value: true })}
className="h-10 whitespace-nowrap rounded-full bg-primary px-4 text-sm hover:bg-primary/90 lg:px-5"
size="lg"
>
Get Free Machine
</Button>
</div>
</div>
<div className="flex min-h-[3.5rem] items-center justify-center border-t border-border/45 py-2">
<nav className="flex flex-wrap items-center justify-center gap-x-1 gap-y-2 lg:gap-x-2">
<Link href="/" className={desktopLinkClassName}>
Home
</Link>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
Who We Serve
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("w-56", desktopDropdownClassName)}
>
{whoWeServeItems.map((item) => (
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
Vending Machines
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("w-64", desktopDropdownClassName)}
>
{vendingMachinesItems.map((item) => (
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
Services
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("w-72", desktopDropdownClassName)}
>
{servicesItems.map((item) => (
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Link href="/contact-us" className={desktopLinkClassName}>
Contact Us
</Link>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
More
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn("w-56", desktopDropdownClassName)}
>
{moreItems.map((item) => (
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</nav>
</div>
</div>
{/* Mobile Navigation */}
{state.isMenuOpen && (
<nav className="border-t border-border/40 py-5 md:hidden">

View file

@ -114,52 +114,11 @@ export function LocationLandingPage({
}: {
locationData: LocationData
}) {
const isSaltLakeCity = locationData.slug === "salt-lake-city-utah"
const countyName = getCountyName(locationData.slug)
const industries = getIndustryFocus(locationData)
const canonicalUrl = buildAbsoluteUrl(buildLocationRoute(locationData.slug))
const title = `Vending Machines in ${locationData.city}, ${locationData.stateAbbr}`
const description = `Rocky Mountain Vending provides free placement for qualifying locations, machine sales, repairs, and vending service for businesses in ${locationData.city}, ${locationData.stateAbbr}.`
const comparisonRows = [
["Credit card readers", "Yes", "Maybe", "Yes"],
["Locally owned", "Yes", "Yes", "Maybe"],
["Fast service", "Yes", "Maybe", "Maybe"],
["Large selection of products", "Yes", "Maybe", "Yes"],
["Quality of equipment used", "Excellent", "Varies", "Excellent"],
["Locked into Coke or Pepsi equipment", "No", "Maybe", "Probably"],
]
const saltLakeServiceLinks = [
{
title: "Traditional snacks and drinks",
body: "Stock the machine with the classic snacks, sodas, and convenience items most locations still want every day.",
href: "/food-and-beverage/traditional-options",
},
{
title: "Healthy snacks and drinks",
body: "Offer protein bars, better-for-you snacks, and drink choices that fit health-conscious teams and customers.",
href: "/food-and-beverage/healthy-options",
},
{
title: "Snack and drink delivery",
body: "Need product delivery or a broader refreshment setup beyond standard machine placement? We can help there too.",
href: "/food-and-beverage/snack-and-drink-delivery",
},
{
title: "Vending machine sales",
body: "Compare purchase options if you want equipment ownership instead of a free-placement arrangement.",
href: "/vending-machines/machines-for-sale",
},
{
title: "Parts, repairs, and moving",
body: "Get support for repair work, machine moving, replacement parts, and operational issues that need direct service help.",
href: "/services/parts",
},
{
title: "Training and support",
body: "Browse the support pages and machine guides if you need help with specific models, manuals, or machine operation questions.",
href: "/blog",
},
]
const structuredData = {
"@context": "https://schema.org",
@ -229,15 +188,15 @@ export function LocationLandingPage({
<PublicPageHeader
align="center"
eyebrow="Local Service Area"
title={`Vending machine service for businesses in ${locationData.city}, ${locationData.stateAbbr}`}
description={`Rocky Mountain Vending helps businesses in ${locationData.city} with free placement for qualifying locations, machine sales, repairs, parts, and ongoing service across ${countyName} and nearby communities.`}
title={`${locationData.city}, ${locationData.stateAbbr} vending machine service`}
description={`Rocky Mountain Vending serves businesses in ${locationData.city}, ${locationData.stateAbbr} with free placement for qualifying locations, machine sales, repairs, parts, and ongoing restocking and service across ${countyName} and nearby communities.`}
className="mb-12 md:mb-16"
/>
<div className="mx-auto mb-14 grid max-w-5xl gap-6 lg:grid-cols-[1.08fr_0.92fr]">
<div className="mx-auto mb-12 grid max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<PublicSurface className="p-6 md:p-8">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
A local vending partner for businesses across {locationData.city}
Vending service for businesses across {locationData.city}
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
If your business is in {locationData.neighborhoods.join(", ")}, or
@ -260,11 +219,11 @@ export function LocationLandingPage({
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
Common business types we serve in {locationData.city}
</h2>
<div className="mt-5 flex flex-wrap gap-2.5">
<div className="mt-5 flex flex-wrap gap-2">
{industries.map((industry) => (
<span
key={industry}
className="rounded-full border border-border/60 bg-background px-3 py-1.5 text-sm text-muted-foreground"
className="rounded-full border border-border/60 bg-background px-3 py-1 text-sm text-muted-foreground"
>
{industry}
</span>
@ -277,15 +236,6 @@ export function LocationLandingPage({
Utah service area.
</p>
</PublicInset>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<GetFreeMachineCta buttonLabel="Check Placement Fit" />
<Link
href="/contact-us#contact-form"
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
>
Talk to Our Team
</Link>
</div>
</PublicSurface>
</div>
@ -293,7 +243,7 @@ export function LocationLandingPage({
<h2 className="text-3xl font-bold tracking-tight text-balance">
Vending services available in {locationData.city}
</h2>
<div className="mt-8 grid gap-5 md:grid-cols-2">
<div className="mt-8 grid gap-6 md:grid-cols-2">
{[
{
title: "Free vending placement",
@ -320,7 +270,7 @@ export function LocationLandingPage({
cta: "View manuals and parts",
},
].map((service) => (
<PublicSurface key={service.title} className="h-full p-6 md:p-7">
<PublicSurface key={service.title} className="h-full p-6">
<h3 className="text-xl font-semibold">{service.title}</h3>
<p className="mt-3 leading-7 text-muted-foreground">
{service.body}
@ -337,95 +287,6 @@ export function LocationLandingPage({
</div>
</section>
{isSaltLakeCity ? (
<section className="mx-auto mb-16 max-w-5xl space-y-6">
<PublicSurface className="p-6 md:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Why Rocky Mountain Vending
</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-balance">
What Salt Lake City businesses usually want to verify before they choose a vendor.
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
Most businesses care about the same things: service speed,
product flexibility, local ownership, and whether the machines
feel modern and dependable after install.
</p>
<div className="mt-6 overflow-hidden rounded-[1.5rem] border border-border/60">
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] border-collapse text-sm">
<thead className="bg-muted/55">
<tr className="border-b border-border/60">
<th className="px-4 py-3 text-left font-semibold text-foreground">
Comparison point
</th>
<th className="px-4 py-3 text-left font-semibold text-foreground">
Rocky Mountain Vending
</th>
<th className="px-4 py-3 text-left font-semibold text-foreground">
Small Vendor
</th>
<th className="px-4 py-3 text-left font-semibold text-foreground">
Large Vendor
</th>
</tr>
</thead>
<tbody>
{comparisonRows.map((row, index) => (
<tr
key={row[0]}
className={`border-b border-border/50 ${index % 2 === 0 ? "bg-background" : "bg-muted/20"}`}
>
<td className="px-4 py-3 font-medium text-foreground">
{row[0]}
</td>
<td className="px-4 py-3 text-muted-foreground">
{row[1]}
</td>
<td className="px-4 py-3 text-muted-foreground">
{row[2]}
</td>
<td className="px-4 py-3 text-muted-foreground">
{row[3]}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</PublicSurface>
<PublicSurface className="p-6 md:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Salt Lake City Services
</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-balance">
The service paths Salt Lake City businesses usually ask about first.
</h2>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{saltLakeServiceLinks.map((item) => (
<PublicInset key={item.title} className="flex h-full flex-col">
<h3 className="text-lg font-semibold text-foreground">
{item.title}
</h3>
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">
{item.body}
</p>
<Link
href={item.href}
className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
>
Learn more
<ArrowRight className="h-4 w-4" />
</Link>
</PublicInset>
))}
</div>
</PublicSurface>
</section>
) : null}
<section className="mx-auto mb-16 grid max-w-5xl gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<PublicSurface className="p-6 md:p-8">
<h2 className="text-3xl font-bold tracking-tight text-balance">
@ -544,7 +405,7 @@ export function LocationLandingPage({
vending help you need. We&apos;ll follow up with the next best
option for your location.
</p>
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
<div className="mt-6 flex flex-col items-center gap-3">
<GetFreeMachineCta buttonLabel="See If Your Location Qualifies" />
<Link
href="/contact-us#contact-form"

View file

@ -42,8 +42,6 @@ export function ManualViewer({
const [pdfError, setPdfError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [showPartsPanel, setShowPartsPanel] = useState(true)
const [partsPanelLoading, setPartsPanelLoading] = useState(true)
const [partsPanelVisible, setPartsPanelVisible] = useState(true)
const isMobile = useIsMobile()
// Reset error state when manual URL changes
@ -51,8 +49,6 @@ export function ManualViewer({
if (isOpen) {
setPdfError(false)
setIsLoading(true)
setPartsPanelLoading(true)
setPartsPanelVisible(true)
}
}, [manualUrl, isOpen])
@ -69,17 +65,6 @@ export function ManualViewer({
setPdfError(true)
}
const showPartsPanelWithData =
showPartsPanel && (partsPanelLoading || partsPanelVisible)
const canToggleParts = partsPanelLoading || partsPanelVisible
const partsToggleLabel = partsPanelLoading
? "Checking Parts..."
: partsPanelVisible
? showPartsPanel
? "Hide Parts"
: "Show Parts"
: "Parts Unavailable"
// Mobile layout - use Sheet
if (isMobile) {
return (
@ -217,10 +202,9 @@ export function ManualViewer({
variant="outline"
size="sm"
onClick={() => setShowPartsPanel(!showPartsPanel)}
disabled={!canToggleParts}
>
<ShoppingCart className="h-4 w-4 mr-1" />
{partsToggleLabel}
{showPartsPanel ? "Hide" : "Show"} Parts
</Button>
<Button
variant="outline"
@ -255,7 +239,7 @@ export function ManualViewer({
{/* PDF Viewer - responsive width based on parts panel */}
<div
className={`overflow-hidden min-h-0 relative transition-all duration-300 h-full ${
showPartsPanelWithData ? "w-[75%] lg:w-[80%]" : "w-full"
showPartsPanel ? "w-[75%] lg:w-[80%]" : "w-full"
}`}
>
{isLoading && !pdfError && (
@ -313,14 +297,10 @@ export function ManualViewer({
)}
</div>
{/* Parts Panel - right side, responsive width */}
{showPartsPanelWithData && (
{showPartsPanel && (
<PartsPanel
manualFilename={filename}
className={`border-l border-yellow-300/20 bg-yellow-50 dark:bg-yellow-950/90 overflow-y-auto h-full ${"w-[25%] lg:w-[20%]"}`}
onStateChange={(state) => {
setPartsPanelLoading(state.isLoading)
setPartsPanelVisible(state.isVisible)
}}
/>
)}
</div>

View file

@ -28,6 +28,9 @@ import {
ShoppingCart,
LayoutGrid,
List,
ExternalLink,
Loader2,
AlertCircle,
} from "lucide-react"
import type { Manual, ManualGroup } from "@/lib/manuals-types"
import { getManualUrl, getThumbnailUrl } from "@/lib/manuals-types"
@ -37,6 +40,176 @@ import {
} from "@/lib/manuals-config"
import { ManualViewer } from "@/components/manual-viewer"
import { getManualsWithParts } from "@/lib/parts-lookup"
import { ebayClient } from "@/lib/ebay-api"
// Product Suggestion Component
interface ProductSuggestion {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
affiliateLink: string
condition?: string
}
interface ProductSuggestionsProps {
manual: Manual
className?: string
}
function ProductSuggestions({
manual,
className = "",
}: ProductSuggestionsProps) {
const [suggestions, setSuggestions] = useState<ProductSuggestion[]>([])
const [isLoading, setIsLoading] = useState(ebayClient.isConfigured())
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!ebayClient.isConfigured()) {
setIsLoading(false)
return
}
async function loadSuggestions() {
setIsLoading(true)
setError(null)
try {
// Generate search query from manual content
const query = `${manual.manufacturer} ${manual.category} vending machine`
const results = await ebayClient.searchItems({
keywords: query,
maxResults: 6,
sortOrder: "BestMatch",
})
setSuggestions(results)
} catch (err) {
console.error("Error loading product suggestions:", err)
setError("Could not load product suggestions")
} finally {
setIsLoading(false)
}
}
if (manual) {
loadSuggestions()
}
}, [manual])
if (!ebayClient.isConfigured()) {
return null
}
if (isLoading) {
return (
<div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
>
<div className="flex items-center justify-center h-32">
<Loader2 className="h-5 w-5 text-yellow-700 dark:text-yellow-300 animate-spin" />
</div>
</div>
)
}
if (error) {
return (
<div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
>
<div className="flex items-center justify-center h-32">
<AlertCircle className="h-6 w-6 text-red-500" />
<span className="ml-2 text-sm text-red-500">{error}</span>
</div>
</div>
)
}
if (suggestions.length === 0) {
return (
<div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
>
<div className="flex items-center justify-center h-32">
<AlertCircle className="h-6 w-6 text-yellow-500" />
<span className="ml-2 text-sm text-yellow-600">
No products found in sandbox environment
</span>
</div>
</div>
)
}
return (
<div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
>
<div className="flex items-center gap-2 mb-4">
<ShoppingCart className="h-4 w-4 text-yellow-700 dark:text-yellow-300" />
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
Related Products
</h3>
</div>
<div className="grid grid-cols-2 gap-3">
{suggestions.map((product) => (
<a
key={product.itemId}
href={product.affiliateLink}
target="_blank"
rel="noopener noreferrer"
className="block group"
>
<div className="bg-white dark:bg-yellow-900/30 rounded border border-yellow-300/40 dark:border-yellow-700/40 p-2 hover:bg-yellow-50 dark:hover:bg-yellow-900/40 transition-colors">
{/* Image */}
{product.imageUrl && (
<div className="mb-2 rounded overflow-hidden bg-yellow-100 dark:bg-yellow-900/50">
<img
src={product.imageUrl}
alt={product.title}
className="w-full h-16 object-cover"
onError={(e) => {
e.currentTarget.src = `https://via.placeholder.com/120x80/fbbf24/1f2937?text=${encodeURIComponent(product.title)}`
}}
/>
</div>
)}
{!product.imageUrl && (
<div className="mb-2 rounded overflow-hidden bg-yellow-100 dark:bg-yellow-900/50 h-16 flex items-center justify-center">
<span className="text-[10px] text-yellow-700 dark:text-yellow-300">
No Image
</span>
</div>
)}
{/* Product Details */}
<div className="space-y-1">
<div className="text-[11px] text-yellow-900 dark:text-yellow-100 line-clamp-2 min-h-[1.5rem]">
{product.title}
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
{product.price}
</span>
<ExternalLink className="h-3 w-3 text-yellow-700 dark:text-yellow-300 group-hover:text-yellow-900 dark:group-hover:text-yellow-100 transition-colors flex-shrink-0" />
</div>
{product.condition && (
<div className="text-[9px] text-yellow-700/80 dark:text-yellow-300/80">
{product.condition}
</div>
)}
</div>
</div>
</a>
))}
</div>
</div>
)
}
interface ManualsPageClientProps {
manuals: Manual[]
@ -65,7 +238,7 @@ function ManualCard({
const thumbnailUrl = getThumbnailUrl(manual)
return (
<Card className="overflow-hidden rounded-[1.75rem] border border-border/70 bg-background shadow-[0_16px_40px_rgba(15,23,42,0.075)] transition-all hover:-translate-y-0.5 hover:shadow-[0_22px_54px_rgba(15,23,42,0.11)]">
<Card className="overflow-hidden rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_60px_rgba(15,23,42,0.12)]">
{thumbnailUrl && (
<div className="relative h-48 min-h-[192px] w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.12),transparent_52%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,255,255,0.98))]">
<Image
@ -77,13 +250,13 @@ function ManualCard({
/>
</div>
)}
<CardHeader className="space-y-3 px-5 pt-5">
<CardHeader className="px-5 pt-5">
<CardTitle className="line-clamp-2 text-base leading-snug">
{manual.filename.replace(/\.pdf$/i, "")}
</CardTitle>
{manual.commonNames && manual.commonNames.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{manual.commonNames.slice(0, 3).map((name, index) => (
{manual.commonNames.map((name, index) => (
<Badge
key={index}
variant="secondary"
@ -92,21 +265,13 @@ function ManualCard({
{name}
</Badge>
))}
{manual.commonNames.length > 3 && (
<Badge
variant="secondary"
className="rounded-full border border-border/60 bg-muted/35 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"
>
+{manual.commonNames.length - 3} more
</Badge>
)}
</div>
)}
{manual.searchTerms &&
manual.searchTerms.length > 0 &&
!manual.commonNames && (
<div className="mt-2 flex flex-wrap gap-2">
{manual.searchTerms.slice(0, 4).map((term, index) => (
{manual.searchTerms.map((term, index) => (
<Badge
key={index}
variant="secondary"
@ -115,14 +280,6 @@ function ManualCard({
{term}
</Badge>
))}
{manual.searchTerms.length > 4 && (
<Badge
variant="secondary"
className="rounded-full border border-border/60 bg-muted/35 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"
>
+{manual.searchTerms.length - 4} more
</Badge>
)}
</div>
)}
</CardHeader>
@ -146,7 +303,7 @@ function ManualCard({
</Badge>
)}
</div>
<div className="space-y-1.5 text-sm text-muted-foreground">
<div className="text-sm text-muted-foreground space-y-1">
{showManufacturer && (
<p>
<strong>Manufacturer:</strong> {manual.manufacturer}
@ -490,28 +647,9 @@ export function ManualsPageClient({
return (
<div className="space-y-6">
{/* Search and Filter Controls */}
<PublicSurface className="p-4 md:p-6">
<CardContent className="p-0">
<div className="space-y-5">
<div className="flex flex-col gap-4 rounded-[1.5rem] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.94))] p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Start With Search
</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight text-foreground">
Find the manual first, then narrow it down
</h2>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
Search by model, manufacturer, or category. Use filters if
you already know the brand or want manuals with parts.
</p>
</div>
<PublicInset className="w-full rounded-[1.25rem] px-4 py-3 text-sm text-muted-foreground shadow-none md:max-w-xs">
Showing <strong>{filteredManuals.length}</strong> of{" "}
<strong>{manuals.length}</strong> manuals
</PublicInset>
</div>
<PublicSurface>
<CardContent className="p-0">
<div className="space-y-6">
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
@ -525,14 +663,12 @@ export function ManualsPageClient({
</div>
{/* Filters Row */}
<div className="rounded-[1.5rem] border border-border/60 bg-white/80 p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4 items-start sm:items-center">
<div className="flex items-center gap-2 w-full sm:w-auto">
<Filter className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium">Filters:</span>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full sm:w-auto">
<div className="relative">
<Select
@ -630,12 +766,10 @@ export function ManualsPageClient({
Clear Filters
</Button>
)}
</div>
</div>
</div>
{/* View Mode Toggle and Results Count */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4 border-t border-border/55 pt-1">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex items-center gap-3 w-full sm:w-auto">
<span className="text-sm text-muted-foreground flex-shrink-0">
View:
@ -673,8 +807,8 @@ export function ManualsPageClient({
</div>
<div className="text-sm text-muted-foreground w-full sm:w-auto text-center sm:text-left">
View the full library grouped by manufacturer or switch to list
view for a faster scan.
Showing <strong>{filteredManuals.length}</strong> of{" "}
<strong>{manuals.length}</strong> manuals
</div>
</div>
</div>
@ -702,15 +836,11 @@ export function ManualsPageClient({
{filteredGroupedManuals.map((group) => {
const organized = organizeCategories(group.categories)
return (
<PublicSurface key={group.manufacturer} className="space-y-7 p-5 md:p-7">
<div className="border-b border-border/60 pb-4">
<PublicSurface key={group.manufacturer} className="space-y-6">
<div className="border-b border-border/60 pb-3">
<h2 className="text-2xl font-bold tracking-tight">
{group.manufacturer}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{Object.values(group.categories).flat().length} manuals
available from this manufacturer.
</p>
</div>
{/* Machine Type Categories First */}
@ -748,7 +878,7 @@ export function ManualsPageClient({
</span>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryManuals.map((manual) => (
<ManualCard
key={manual.path}
@ -791,7 +921,7 @@ export function ManualsPageClient({
)
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryManuals.map((manual) => (
<ManualCard
key={manual.path}
@ -808,13 +938,28 @@ export function ManualsPageClient({
</div>
)}
{/* Product Suggestions Section */}
{filteredManuals.length > 0 && (
<div className="space-y-6 mt-8">
<div className="border-t border-border/60 pt-6">
<h3 className="text-lg font-semibold text-muted-foreground mb-4">
Related Products
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredManuals.slice(0, 3).map((manual) => (
<ProductSuggestions key={manual.path} manual={manual} />
))}
</div>
</div>
)}
</PublicSurface>
)
})}
</div>
) : (
/* List View */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredManuals.map((manual) => (
<ManualCard
key={manual.path}

View file

@ -3,63 +3,32 @@
import { useCallback, useEffect, useState } from "react"
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { EbayCacheState } from "@/lib/ebay-parts-match"
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
import {
hasTrustedPartsListings,
shouldShowEbayPartsPanel,
} from "@/lib/ebay-parts-visibility"
interface PartsPanelProps {
manualFilename: string
className?: string
onStateChange?: (state: { isLoading: boolean; isVisible: boolean }) => void
}
export function PartsPanel({
manualFilename,
className = "",
onStateChange,
}: PartsPanelProps) {
const [parts, setParts] = useState<PartForPage[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [cache, setCache] = useState<EbayCacheState | null>(null)
const formatFreshness = (value: number | null) => {
if (!value) {
return "not refreshed yet"
}
const minutes = Math.max(0, Math.floor(value / 60000))
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
return `${days}d ago`
}
const loadParts = useCallback(async () => {
setIsLoading(true)
setError(null)
setParts([])
setCache(null)
try {
const result = await getTopPartsForManual(manualFilename, 5)
setParts(result.parts)
setError(result.error ?? null)
setCache(result.cache ?? null)
} catch (err) {
console.error("Error loading parts:", err)
setParts([])
setCache(null)
setError("Could not load parts")
} finally {
setIsLoading(false)
@ -72,25 +41,37 @@ export function PartsPanel({
}
}, [loadParts, manualFilename])
const hasListings = hasTrustedPartsListings(parts)
const shouldShowPanel = shouldShowEbayPartsPanel({
isLoading,
parts,
cache,
error,
})
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
const hasListings = parts.some((part) => part.ebayListings.length > 0)
useEffect(() => {
if (!onStateChange) {
return
}
onStateChange({
isLoading,
isVisible: shouldShowPanel,
})
}, [isLoading, onStateChange, shouldShowPanel])
const renderStatusCard = (title: string, message: string) => (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts
</span>
</div>
</div>
<div className="flex flex-1 flex-col items-center justify-center px-3 py-4 text-center">
<AlertCircle className="h-5 w-5 text-yellow-700 dark:text-yellow-300 mb-2" />
<p className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
{title}
</p>
<p className="mt-1 text-[11px] leading-relaxed text-yellow-900/70 dark:text-yellow-100/70">
{message}
</p>
<Button
variant="outline"
size="sm"
onClick={() => void loadParts()}
className="mt-3 h-8 text-[11px] border-yellow-300/60 text-yellow-900 hover:bg-yellow-100 dark:border-yellow-700/60 dark:text-yellow-100 dark:hover:bg-yellow-900/40"
>
Retry
</Button>
</div>
</div>
)
if (isLoading) {
return (
@ -102,14 +83,6 @@ export function PartsPanel({
Parts
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@ -119,8 +92,53 @@ export function PartsPanel({
)
}
if (!shouldShowPanel) {
return null
if (error && !hasListings) {
const loweredError = error.toLowerCase()
const statusMessage = error.includes("eBay API not configured")
? "Set EBAY_APP_ID in the app environment so live listings can load."
: loweredError.includes("rate limit") || loweredError.includes("exceeded")
? "eBay is temporarily rate-limited. Try again in a minute."
: error
return renderStatusCard("eBay unavailable", statusMessage)
}
if (parts.length === 0) {
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts
</span>
</div>
</div>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
No parts data extracted for this manual yet
</div>
</div>
)
}
if (!hasListings) {
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts
</span>
</div>
</div>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
No live eBay matches found for these parts yet
</div>
</div>
)
}
return (
@ -132,14 +150,6 @@ export function PartsPanel({
Parts ({parts.length})
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
@ -149,10 +159,15 @@ export function PartsPanel({
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium">
Cached eBay listings are unavailable right now.
Live eBay listings are unavailable right now.
</p>
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
{error}
{error.includes("eBay API not configured")
? "Set EBAY_APP_ID in the app environment, then reload the panel."
: error.toLowerCase().includes("rate limit") ||
error.toLowerCase().includes("exceeded")
? "eBay is temporarily rate-limited. Reload after a short wait."
: error}
</p>
</div>
</div>

View file

@ -20,11 +20,6 @@ type PublicPageHeaderProps = {
children?: ReactNode
}
type PublicProseProps = {
className?: string
children: ReactNode
}
export function PublicSection({
id,
tone = "default",
@ -107,7 +102,7 @@ export function PublicSurface({
return (
<Component
className={cn(
"rounded-[var(--public-surface-radius)] border border-border/65 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.96))] p-5 shadow-[0_20px_52px_rgba(15,23,42,0.075)] md:p-7",
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] p-5 shadow-[var(--public-surface-shadow)] md:p-7",
className
)}
{...props}
@ -125,7 +120,7 @@ export function PublicInset({
return (
<div
className={cn(
"rounded-[var(--public-inset-radius)] border border-border/55 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(255,250,244,0.92))] p-4 shadow-[0_12px_30px_rgba(15,23,42,0.055)]",
"rounded-[var(--public-inset-radius)] border border-border/60 bg-white/95 p-4 shadow-[0_10px_28px_rgba(15,23,42,0.06)]",
className
)}
{...props}
@ -149,29 +144,14 @@ export function PublicSectionHeader({
className,
}: PublicSectionHeaderProps) {
return (
<div className={cn("space-y-2.5", className)}>
<div className={cn("space-y-2", className)}>
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
{eyebrow}
</p>
<h2 className="text-xl font-semibold tracking-tight text-foreground md:text-[1.375rem]">
{title}
</h2>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
{description}
</p>
</div>
)
}
export function PublicProse({ className, children }: PublicProseProps) {
return (
<div
className={cn(
"prose prose-slate max-w-none prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-foreground prose-p:text-muted-foreground prose-p:leading-7 prose-li:text-muted-foreground prose-li:leading-7 prose-strong:text-foreground prose-a:text-foreground prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-img:rounded-[1.5rem] prose-img:border prose-img:border-border/55 prose-img:shadow-[0_18px_45px_rgba(15,23,42,0.08)] prose-hr:border-border/50 prose-blockquote:border-primary/25 prose-blockquote:text-foreground md:prose-lg",
className
)}
>
{children}
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
</div>
)
}

View file

@ -11,7 +11,7 @@ export function RequestMachineSection() {
<section id="request-machine" className="bg-background py-16 md:py-24">
<div className="container mx-auto px-4">
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
<PublicSurface className="p-6 md:p-8 lg:sticky lg:top-28">
<PublicSurface className="bg-white p-6 md:p-8 lg:sticky lg:top-28">
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
<Package className="h-4 w-4" />
Free Placement
@ -61,7 +61,7 @@ export function RequestMachineSection() {
</div>
</PublicSurface>
<PublicSurface className="p-5 md:p-7">
<PublicSurface className="bg-white p-5 md:p-7">
<RequestMachineForm
onSubmit={(data) =>
console.log("Machine request form submitted:", data)

View file

@ -9,47 +9,6 @@ import {
PublicSurface,
} from "@/components/public-surface"
const reviewThemes = [
{
title: "Always cold and stocked",
body: "Customers consistently mention that the machines stay full, drinks stay cold, and the day-to-day experience feels dependable instead of neglected.",
},
{
title: "Fast, friendly service",
body: "When something needs attention, businesses talk about quick follow-through, easy communication, and issues getting handled without a long delay.",
},
{
title: "Wide product variety",
body: "Reviews regularly call out strong drink selection, snack variety, and the ability to request items that fit the people using the location.",
},
{
title: "Fair pricing and reliability",
body: "People mention competitive pricing, clean machines, and a setup that feels professional instead of frustrating or outdated.",
},
]
const featuredQuotes = [
{
quote:
"He is arguably one of the best vendors in the industry. There probably isn't too many people I would trust more than him.",
author: "Martin Harrison",
},
{
quote: "He always has my favorite energy drink at a great price!",
author: "TOPX Kingsford",
},
{
quote:
"This vending machine is my favorite at my job! It always works and has the best stuff!!",
author: "DJ Montoya",
},
{
quote:
"Great to work with, looking forward to having him in more locations.",
author: "Jennifer Spencer",
},
]
export function ReviewsPage() {
useEffect(() => {
const existingScript = document.querySelector(
@ -85,65 +44,8 @@ export function ReviewsPage() {
align="center"
eyebrow="Customer Reviews"
title="What Utah businesses say about working with Rocky Mountain Vending."
description="See the real themes customers mention most often, browse featured comments, and then dig into the live Google review feed."
>
<PublicInset className="mx-auto inline-flex w-fit rounded-full px-4 py-2 text-sm text-muted-foreground shadow-none">
Average rating: 4.9 out of 5, based on 50+ customer reviews.
</PublicInset>
</PublicPageHeader>
<section className="mt-12 grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<PublicSurface>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Why Businesses Trust Rocky
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
The same strengths keep showing up in the reviews.
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
We&apos;re honored to have earned strong feedback from Utah
businesses that rely on us for placement, restocking, repairs, and
day-to-day service. The reviews tend to point back to the same
things: stocked machines, responsive help, a better product mix,
and follow-through after install.
</p>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{reviewThemes.map((theme) => (
<PublicInset key={theme.title} className="h-full">
<h3 className="text-lg font-semibold text-foreground">
{theme.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{theme.body}
</p>
</PublicInset>
))}
</div>
</PublicSurface>
<PublicSurface className="flex flex-col justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Featured Comments
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
A few of the comments that capture the pattern.
</h2>
</div>
<div className="mt-6 grid gap-4">
{featuredQuotes.map((item) => (
<PublicInset key={item.author} className="p-5">
<p className="text-sm leading-relaxed text-foreground">
&ldquo;{item.quote}&rdquo;
</p>
<p className="mt-3 text-sm font-semibold text-primary">
{item.author}
</p>
</PublicInset>
))}
</div>
</PublicSurface>
</section>
description="Browse the live Google review feed and see what Utah businesses say about placement, restocking, repairs, and service."
/>
<section className="mt-12">
<PublicSurface className="overflow-hidden p-5 md:p-7">
@ -167,9 +69,9 @@ export function ReviewsPage() {
</div>
</div>
<div className="mt-6 rounded-[1.5rem] bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.92))] p-2 sm:p-3">
<div className="mt-6">
<iframe
className="lc_reviews_widget min-h-[620px] w-full rounded-[1.35rem] border border-border/60 bg-background md:min-h-[780px]"
className="lc_reviews_widget min-h-[900px] w-full rounded-[1.5rem] border border-border/60 bg-background"
src="https://reputationhub.site/reputation/widgets/review_widget/YAoWLgNSid8oG44j9BjG"
frameBorder="0"
scrolling="no"
@ -179,7 +81,7 @@ export function ReviewsPage() {
</PublicSurface>
</section>
<section className="mt-12 grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<section className="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<PublicSurface>
<h2 className="text-3xl font-semibold tracking-tight text-balance">
What businesses usually want to verify before they choose a vendor
@ -218,65 +120,42 @@ export function ReviewsPage() {
<PublicSurface className="flex flex-col justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Why It Matters
Next Step
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
Reviews are usually the last confidence check before someone reaches out.
Want to see whether your location qualifies?
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
Most businesses are trying to verify the same things: follow-through,
communication, and whether the machines stay stocked and working
after install. If that sounds like your checklist too, we can help
you sort through next steps quickly.
Tell us about your traffic, breakroom, or customer area and
we&apos;ll help you decide between free placement, machine sales,
or service help.
</p>
</div>
<div className="mt-6 grid gap-4">
<PublicInset className="p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Common Questions
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Link
href="/#request-machine"
className="rounded-[1.5rem] border border-border/60 bg-white p-5 text-left transition hover:border-primary/30 hover:text-primary"
>
<h3 className="text-lg font-semibold text-foreground">
Free Placement
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
See whether your business qualifies for vending machine
placement and ongoing service.
</p>
<ul className="mt-3 space-y-3 text-sm leading-relaxed text-muted-foreground">
<li>Does this location qualify for free placement?</li>
<li>Can Rocky handle repairs and restocking without extra staff work on our side?</li>
<li>Should we ask about placement, machine sales, or direct service help?</li>
</ul>
</PublicInset>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
<Link
href="/#request-machine"
className="rounded-[1.5rem] border border-border/55 bg-background/70 p-5 text-left transition hover:border-primary/30 hover:text-primary"
>
<h3 className="text-lg font-semibold text-foreground">
Free Placement
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
See whether your business qualifies for vending machine
placement and ongoing service.
</p>
</Link>
<Link
href="/contact-us#contact-form"
className="rounded-[1.5rem] border border-border/55 bg-background/70 p-5 text-left transition hover:border-primary/30 hover:text-primary"
>
<h3 className="text-lg font-semibold text-foreground">
Service or Sales
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Reach out about repairs, moving, manuals, parts, or machine
sales.
</p>
</Link>
</div>
</div>
<div className="mt-6 rounded-[1.5rem] border border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.7))] p-5">
<p className="text-sm font-semibold text-foreground">
Looking for a direct answer?
</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Tell us about your location, traffic, and what kind of help you
need. We&apos;ll point you toward the right option instead of
making you guess between service pages.
</p>
</Link>
<Link
href="/contact-us#contact-form"
className="rounded-[1.5rem] border border-border/60 bg-white p-5 text-left transition hover:border-primary/30 hover:text-primary"
>
<h3 className="text-lg font-semibold text-foreground">
Service or Sales
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Reach out about repairs, moving, manuals, parts, or machine
sales.
</p>
</Link>
</div>
</PublicSurface>
</section>

View file

@ -4,8 +4,6 @@ import { ReactNode } from "react"
import { CheckCircle2 } from "lucide-react"
import { DropdownPageShell } from "@/components/dropdown-page-shell"
import { PublicInset } from "@/components/public-surface"
import { Button } from "@/components/ui/button"
import Link from "next/link"
interface WhoWeServePageProps {
title: string
@ -57,58 +55,13 @@ export function WhoWeServePage({
description ||
"See how Rocky Mountain Vending adapts machine placement, product mix, and ongoing service to the way this kind of location actually runs."
}
headerContent={
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
<Button asChild size="lg" className="min-h-11 rounded-full px-6">
<Link href="/contact-us#contact-form">Talk to Our Team</Link>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="min-h-11 rounded-full px-6"
>
<Link href="/#request-machine">See If You Qualify</Link>
</Button>
</div>
}
contentIntro={
<>
<PublicInset className="h-full border-primary/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,245,0.94))] p-5 md:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
How We Tailor Service
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance text-foreground">
We shape the setup around the pace of the location.
</h2>
<ul className="mt-4 space-y-3 text-sm leading-relaxed text-muted-foreground">
<li>Machine type and product mix matched to how people actually use the space.</li>
<li>Placement recommendations based on traffic flow, break patterns, and visibility.</li>
<li>Service cadence adjusted so stocking and support stay consistent without adding staff work.</li>
</ul>
</PublicInset>
<PublicInset className="h-full border-border/50 bg-white/80 p-5 md:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Good Fit Signals
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance text-foreground">
These are usually the reasons businesses reach out first.
</h2>
<ul className="mt-4 space-y-3 text-sm leading-relaxed text-muted-foreground">
<li>Your team or visitors need easier access to drinks, snacks, or convenience items on site.</li>
<li>You want a cleaner vending setup without daily oversight falling back on your staff.</li>
<li>You need local follow-through when a machine needs restocking, repair, or payment support.</li>
</ul>
</PublicInset>
</>
}
content={
<div className="space-y-6 text-foreground">{content}</div>
<div className="text-foreground">{content}</div>
}
contentClassName="prose-headings:mb-4 prose-headings:mt-10 prose-p:max-w-[68ch] prose-p:text-[1.02rem] prose-p:leading-8 prose-li:max-w-[68ch] prose-ul:space-y-2"
contentClassName="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-foreground prose-a:underline prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-strong:text-foreground"
sections={
<section>
<div className="mx-auto mb-8 max-w-3xl text-center">
<div className="mx-auto mb-6 max-w-3xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Why Rocky
</p>
@ -154,12 +107,6 @@ export function WhoWeServePage({
variant: "outline",
},
],
note: (
<p className="text-sm leading-relaxed text-muted-foreground">
We can help you sort out whether this should start as a placement request,
a machine-sales conversation, or direct service support.
</p>
),
}}
/>
)

View file

@ -1,114 +0,0 @@
// @ts-nocheck
import { mutation, query } from "./_generated/server"
import { v } from "convex/values"
function trimOptional(value?: string | null) {
const normalized = String(value || "").trim()
return normalized || undefined
}
function buildDisplayName(args: {
displayName?: string
firstName?: string
lastName?: string
}) {
if (trimOptional(args.displayName)) {
return trimOptional(args.displayName)
}
const firstName = trimOptional(args.firstName)
const lastName = trimOptional(args.lastName)
const fallback = [firstName, lastName].filter(Boolean).join(" ").trim()
return fallback || undefined
}
export const getByNormalizedPhone = query({
args: {
normalizedPhone: v.string(),
},
handler: async (ctx, args) => {
return await ctx.db
.query("contactProfiles")
.withIndex("by_normalizedPhone", (q) =>
q.eq("normalizedPhone", args.normalizedPhone)
)
.unique()
},
})
export const upsertByPhone = mutation({
args: {
normalizedPhone: v.string(),
displayName: v.optional(v.string()),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
email: v.optional(v.string()),
company: v.optional(v.string()),
lastIntent: v.optional(v.string()),
lastLeadOutcome: v.optional(
v.union(
v.literal("none"),
v.literal("contact"),
v.literal("requestMachine")
)
),
lastSummaryText: v.optional(v.string()),
lastCallAt: v.optional(v.number()),
lastReminderAt: v.optional(v.number()),
reminderNotes: v.optional(v.string()),
source: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("contactProfiles")
.withIndex("by_normalizedPhone", (q) =>
q.eq("normalizedPhone", args.normalizedPhone)
)
.unique()
const now = Date.now()
const patch = {
normalizedPhone: args.normalizedPhone,
displayName: buildDisplayName(args),
firstName: trimOptional(args.firstName),
lastName: trimOptional(args.lastName),
email: trimOptional(args.email),
company: trimOptional(args.company),
lastIntent: trimOptional(args.lastIntent),
lastLeadOutcome: args.lastLeadOutcome,
lastSummaryText: trimOptional(args.lastSummaryText),
lastCallAt: args.lastCallAt,
lastReminderAt: args.lastReminderAt,
reminderNotes: trimOptional(args.reminderNotes),
source: trimOptional(args.source),
updatedAt: now,
}
if (existing) {
await ctx.db.patch(existing._id, {
displayName: patch.displayName || existing.displayName,
firstName: patch.firstName || existing.firstName,
lastName: patch.lastName || existing.lastName,
email: patch.email || existing.email,
company: patch.company || existing.company,
lastIntent: patch.lastIntent || existing.lastIntent,
lastLeadOutcome: patch.lastLeadOutcome || existing.lastLeadOutcome,
lastSummaryText: patch.lastSummaryText || existing.lastSummaryText,
lastCallAt: patch.lastCallAt || existing.lastCallAt,
lastReminderAt: patch.lastReminderAt || existing.lastReminderAt,
reminderNotes: patch.reminderNotes || existing.reminderNotes,
source: patch.source || existing.source,
updatedAt: now,
})
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("contactProfiles", {
...patch,
createdAt: now,
})
return await ctx.db.get(id)
},
})

File diff suppressed because it is too large Load diff

View file

@ -1,497 +0,0 @@
// @ts-nocheck
export function normalizeEmail(value?: string) {
const normalized = String(value || "")
.trim()
.toLowerCase()
return normalized || undefined
}
export function normalizePhone(value?: string) {
const digits = String(value || "").replace(/\D/g, "")
if (!digits) {
return undefined
}
if (digits.length === 10) {
return `+1${digits}`
}
if (digits.length === 11 && digits.startsWith("1")) {
return `+${digits}`
}
return `+${digits}`
}
function trimOptional(value?: string) {
const trimmed = String(value || "").trim()
return trimmed || undefined
}
function isPlaceholderFirstName(value?: string) {
const normalized = String(value || "")
.trim()
.toLowerCase()
return normalized === "unknown" || normalized === "phone"
}
function isPlaceholderLastName(value?: string) {
const normalized = String(value || "")
.trim()
.toLowerCase()
return (
normalized === "contact" ||
normalized === "lead" ||
normalized === "caller"
)
}
function looksLikePhoneLabel(value?: string) {
const normalized = trimOptional(value)
if (!normalized) {
return false
}
const digits = normalized.replace(/\D/g, "")
return digits.length >= 7 && digits.length <= 15
}
export function sanitizeContactNameParts(args: {
firstName?: string
lastName?: string
fullName?: string
}) {
let firstName = trimOptional(args.firstName)
let lastName = trimOptional(args.lastName)
if (!firstName && !lastName) {
const fullName = trimOptional(args.fullName)
if (fullName && !looksLikePhoneLabel(fullName)) {
const parts = fullName.split(/\s+/).filter(Boolean)
if (parts.length === 1) {
firstName = parts[0]
} else if (parts.length > 1) {
firstName = parts.shift()
lastName = parts.join(" ")
}
}
}
if (isPlaceholderFirstName(firstName)) {
firstName = undefined
}
if (isPlaceholderLastName(lastName)) {
lastName = undefined
}
return {
firstName,
lastName,
}
}
export function dedupeStrings(values?: string[]) {
return Array.from(
new Set(
(values || [])
.map((value) => String(value || "").trim())
.filter(Boolean)
)
)
}
export async function findContactByIdentity(ctx, args) {
if (args.ghlContactId) {
const byGhl = await ctx.db
.query("contacts")
.withIndex("by_ghlContactId", (q) => q.eq("ghlContactId", args.ghlContactId))
.unique()
if (byGhl) {
return byGhl
}
}
const normalizedEmail = normalizeEmail(args.email)
if (normalizedEmail) {
const byEmail = await ctx.db
.query("contacts")
.withIndex("by_normalizedEmail", (q) =>
q.eq("normalizedEmail", normalizedEmail)
)
.unique()
if (byEmail) {
return byEmail
}
}
const normalizedPhone = normalizePhone(args.phone)
if (normalizedPhone) {
const byPhone = await ctx.db
.query("contacts")
.withIndex("by_normalizedPhone", (q) =>
q.eq("normalizedPhone", normalizedPhone)
)
.unique()
if (byPhone) {
return byPhone
}
}
return null
}
export async function upsertContactRecord(ctx, input) {
const now = input.updatedAt ?? Date.now()
const normalizedEmail = normalizeEmail(input.email)
const normalizedPhone = normalizePhone(input.phone)
const existing = await findContactByIdentity(ctx, {
ghlContactId: input.ghlContactId,
email: normalizedEmail,
phone: normalizedPhone,
})
const existingName = sanitizeContactNameParts({
firstName: existing?.firstName,
lastName: existing?.lastName,
})
const incomingName = sanitizeContactNameParts({
firstName: input.firstName,
lastName: input.lastName,
fullName: input.fullName,
})
const patch = {
firstName: incomingName.firstName ?? existingName.firstName ?? "",
lastName: incomingName.lastName ?? existingName.lastName ?? "",
email: input.email || existing?.email,
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
phone: input.phone || existing?.phone,
normalizedPhone: normalizedPhone || existing?.normalizedPhone,
company: input.company ?? existing?.company,
tags: dedupeStrings([...(existing?.tags || []), ...(input.tags || [])]),
status: input.status || existing?.status || "lead",
source: input.source || existing?.source,
notes: input.notes ?? existing?.notes,
ghlContactId: input.ghlContactId || existing?.ghlContactId,
livekitIdentity: input.livekitIdentity || existing?.livekitIdentity,
lastActivityAt:
input.lastActivityAt ?? existing?.lastActivityAt ?? input.createdAt ?? now,
updatedAt: now,
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("contacts", {
...patch,
createdAt: input.createdAt ?? now,
})
return await ctx.db.get(id)
}
export async function upsertConversationRecord(ctx, input) {
const now = input.updatedAt ?? Date.now()
let existing = null
if (input.ghlConversationId) {
existing = await ctx.db
.query("conversations")
.withIndex("by_ghlConversationId", (q) =>
q.eq("ghlConversationId", input.ghlConversationId)
)
.unique()
}
if (!existing && input.livekitRoomName) {
existing = await ctx.db
.query("conversations")
.withIndex("by_livekitRoomName", (q) =>
q.eq("livekitRoomName", input.livekitRoomName)
)
.unique()
}
if (!existing && input.voiceSessionId) {
existing = await ctx.db
.query("conversations")
.withIndex("by_voiceSessionId", (q) =>
q.eq("voiceSessionId", input.voiceSessionId)
)
.unique()
}
if (!existing && input.contactId) {
const candidates = await ctx.db
.query("conversations")
.withIndex("by_contactId", (q) => q.eq("contactId", input.contactId))
.collect()
const targetMoment =
input.lastMessageAt ?? input.startedAt ?? input.updatedAt ?? now
existing =
candidates
.filter((candidate) => {
if (input.channel && candidate.channel !== input.channel) {
return false
}
const candidateMoment =
candidate.lastMessageAt ??
candidate.startedAt ??
candidate.updatedAt ??
0
return Math.abs(candidateMoment - targetMoment) <= 5 * 60 * 1000
})
.sort((a, b) => {
const aMoment = a.lastMessageAt ?? a.startedAt ?? a.updatedAt ?? 0
const bMoment = b.lastMessageAt ?? b.startedAt ?? b.updatedAt ?? 0
return Math.abs(aMoment - targetMoment) - Math.abs(bMoment - targetMoment)
})[0] || null
}
const patch = {
contactId: input.contactId ?? existing?.contactId,
title: input.title || existing?.title,
channel: input.channel || existing?.channel || "unknown",
source: input.source || existing?.source,
status: input.status || existing?.status || "open",
direction: input.direction || existing?.direction || "mixed",
startedAt: input.startedAt ?? existing?.startedAt ?? now,
endedAt: input.endedAt ?? existing?.endedAt,
lastMessageAt: input.lastMessageAt ?? existing?.lastMessageAt,
lastMessagePreview: input.lastMessagePreview ?? existing?.lastMessagePreview,
unreadCount: input.unreadCount ?? existing?.unreadCount ?? 0,
summaryText: input.summaryText ?? existing?.summaryText,
ghlConversationId: input.ghlConversationId || existing?.ghlConversationId,
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
updatedAt: now,
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("conversations", {
...patch,
createdAt: input.createdAt ?? now,
})
return await ctx.db.get(id)
}
export async function ensureConversationParticipant(ctx, input) {
const participants = await ctx.db
.query("conversationParticipants")
.withIndex("by_conversationId", (q) =>
q.eq("conversationId", input.conversationId)
)
.collect()
const normalizedEmail = normalizeEmail(input.email)
const normalizedPhone = normalizePhone(input.phone)
const existing = participants.find((participant) => {
if (input.contactId && participant.contactId === input.contactId) {
return true
}
if (
input.externalContactId &&
participant.externalContactId === input.externalContactId
) {
return true
}
if (normalizedEmail && participant.normalizedEmail === normalizedEmail) {
return true
}
if (normalizedPhone && participant.normalizedPhone === normalizedPhone) {
return true
}
return false
})
const patch = {
contactId: input.contactId ?? existing?.contactId,
role: input.role || existing?.role || "unknown",
displayName: input.displayName || existing?.displayName,
phone: input.phone || existing?.phone,
normalizedPhone: normalizedPhone || existing?.normalizedPhone,
email: input.email || existing?.email,
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
externalContactId: input.externalContactId || existing?.externalContactId,
updatedAt: Date.now(),
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("conversationParticipants", {
conversationId: input.conversationId,
...patch,
createdAt: Date.now(),
})
return await ctx.db.get(id)
}
export async function upsertMessageRecord(ctx, input) {
let existing = null
if (input.ghlMessageId) {
existing = await ctx.db
.query("messages")
.withIndex("by_ghlMessageId", (q) =>
q.eq("ghlMessageId", input.ghlMessageId)
)
.unique()
}
if (!existing && input.voiceTranscriptTurnId) {
existing = await ctx.db
.query("messages")
.withIndex("by_voiceTranscriptTurnId", (q) =>
q.eq("voiceTranscriptTurnId", input.voiceTranscriptTurnId)
)
.unique()
}
const now = input.updatedAt ?? Date.now()
const patch = {
conversationId: input.conversationId,
contactId: input.contactId,
direction: input.direction || existing?.direction || "system",
channel: input.channel || existing?.channel || "unknown",
source: input.source || existing?.source,
messageType: input.messageType || existing?.messageType,
body: String(input.body || existing?.body || "").trim(),
status: input.status || existing?.status,
sentAt: input.sentAt ?? existing?.sentAt ?? now,
ghlMessageId: input.ghlMessageId || existing?.ghlMessageId,
voiceTranscriptTurnId:
input.voiceTranscriptTurnId ?? existing?.voiceTranscriptTurnId,
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
metadata: input.metadata || existing?.metadata,
updatedAt: now,
}
let message
if (existing) {
await ctx.db.patch(existing._id, patch)
message = await ctx.db.get(existing._id)
} else {
const id = await ctx.db.insert("messages", {
...patch,
createdAt: input.createdAt ?? now,
})
message = await ctx.db.get(id)
}
await ctx.db.patch(input.conversationId, {
lastMessageAt: patch.sentAt,
lastMessagePreview: patch.body.slice(0, 240),
updatedAt: now,
})
return message
}
export async function upsertCallArtifactRecord(ctx, input) {
let existing = null
if (input.recordingId) {
existing = await ctx.db
.query("callArtifacts")
.withIndex("by_recordingId", (q) => q.eq("recordingId", input.recordingId))
.unique()
}
if (!existing && input.voiceSessionId) {
existing = await ctx.db
.query("callArtifacts")
.withIndex("by_voiceSessionId", (q) =>
q.eq("voiceSessionId", input.voiceSessionId)
)
.unique()
}
if (!existing && input.ghlMessageId) {
existing = await ctx.db
.query("callArtifacts")
.withIndex("by_ghlMessageId", (q) =>
q.eq("ghlMessageId", input.ghlMessageId)
)
.unique()
}
const now = input.updatedAt ?? Date.now()
const patch = {
conversationId: input.conversationId,
contactId: input.contactId ?? existing?.contactId,
source: input.source || existing?.source,
recordingId: input.recordingId || existing?.recordingId,
recordingUrl: input.recordingUrl || existing?.recordingUrl,
recordingStatus: input.recordingStatus || existing?.recordingStatus,
transcriptionText: input.transcriptionText ?? existing?.transcriptionText,
durationMs: input.durationMs ?? existing?.durationMs,
startedAt: input.startedAt ?? existing?.startedAt,
endedAt: input.endedAt ?? existing?.endedAt,
ghlMessageId: input.ghlMessageId || existing?.ghlMessageId,
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
metadata: input.metadata || existing?.metadata,
updatedAt: now,
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("callArtifacts", {
...patch,
createdAt: input.createdAt ?? now,
})
return await ctx.db.get(id)
}
export async function upsertExternalSyncState(ctx, input) {
const existing = await ctx.db
.query("externalSyncState")
.withIndex("by_provider_entityType_entityId", (q) =>
q
.eq("provider", input.provider)
.eq("entityType", input.entityType)
.eq("entityId", input.entityId)
)
.unique()
const patch = {
cursor: input.cursor ?? existing?.cursor,
checksum: input.checksum ?? existing?.checksum,
status: input.status || existing?.status || "pending",
lastAttemptAt: input.lastAttemptAt ?? existing?.lastAttemptAt ?? Date.now(),
lastSyncedAt: input.lastSyncedAt ?? existing?.lastSyncedAt,
error: input.error ?? existing?.error,
metadata: input.metadata ?? existing?.metadata,
updatedAt: Date.now(),
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("externalSyncState", {
provider: input.provider,
entityType: input.entityType,
entityId: input.entityId,
...patch,
})
return await ctx.db.get(id)
}

View file

@ -1,20 +0,0 @@
import { cronJobs } from "convex/server"
import { api } from "./_generated/api"
const crons = cronJobs()
crons.interval(
"ebay-manual-parts-refresh",
{ hours: 24 },
api.ebay.refreshCache,
{ reason: "cron" }
)
crons.interval(
"ghl-crm-mirror-sync",
{ hours: 1 },
api.crm.runGhlMirror,
{ reason: "cron" }
)
export default crons

View file

@ -1,648 +0,0 @@
// @ts-nocheck
import { action, internalMutation, query } from "./_generated/server"
import { api, internal } from "./_generated/api"
import { v } from "convex/values"
const POLL_KEY = "manual-parts"
const LISTING_EXPIRES_MS = 14 * 24 * 60 * 60 * 1000
const STALE_AFTER_MS = 36 * 60 * 60 * 1000
const BASE_REFRESH_MS = 24 * 60 * 60 * 1000
const MAX_BACKOFF_MS = 7 * 24 * 60 * 60 * 1000
const MAX_QUERIES_PER_RUN = 4
const MAX_RESULTS_PER_QUERY = 8
const MAX_UNIQUE_RESULTS = 48
const SYNTHETIC_ITEM_PREFIX = "123456789"
const PLACEHOLDER_IMAGE_HOSTS = [
"images.unsplash.com",
"via.placeholder.com",
"placehold.co",
] as const
const POLL_QUERY_POOL = [
{
label: "dixie narco part number",
keywords: "dixie narco vending part number",
categoryId: "11700",
},
{
label: "crane national vendors part",
keywords: "crane national vendors vending part",
categoryId: "11700",
},
{
label: "seaga vending control board",
keywords: "seaga vending control board",
categoryId: "11700",
},
{
label: "coinco coin mech",
keywords: "coinco vending coin mech",
categoryId: "11700",
},
{
label: "mei bill validator",
keywords: "mei vending bill validator",
categoryId: "11700",
},
{
label: "wittern delivery motor",
keywords: "wittern vending delivery motor",
categoryId: "11700",
},
{
label: "vending refrigeration deck",
keywords: "vending machine refrigeration deck",
categoryId: "11700",
},
{
label: "vending keypad",
keywords: "vending machine keypad",
categoryId: "11700",
},
] as const
function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
}
function parsePositiveNumber(value: string): number | null {
const match = value.match(/([0-9]+(?:\.[0-9]+)?)/)
if (!match) {
return null
}
const parsed = Number.parseFloat(match[1])
if (!Number.isFinite(parsed) || parsed <= 0) {
return null
}
return parsed
}
function getPollQueriesForRun(now: number) {
const total = POLL_QUERY_POOL.length
if (total === 0) {
return []
}
const startIndex = Math.floor(now / BASE_REFRESH_MS) % total
const count = Math.min(MAX_QUERIES_PER_RUN, total)
const queries: (typeof POLL_QUERY_POOL)[number][] = []
for (let index = 0; index < count; index += 1) {
queries.push(POLL_QUERY_POOL[(startIndex + index) % total])
}
return queries
}
function buildAffiliateLink(viewItemUrl: string): string {
const campaignId = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
if (!campaignId) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", campaignId)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
function firstValue<T>(value: T | T[] | undefined): T | undefined {
if (value === undefined) {
return undefined
}
return Array.isArray(value) ? value[0] : value
}
function parseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}
function isTrustedListingCandidate(listing: ReturnType<typeof normalizeEbayItem>) {
const itemId = listing.itemId?.trim() || ""
if (!/^[0-9]{9,15}$/.test(itemId)) {
return false
}
if (itemId.startsWith(SYNTHETIC_ITEM_PREFIX)) {
return false
}
const parsedUrl = parseUrl(listing.viewItemUrl || "")
if (!parsedUrl) {
return false
}
const host = parsedUrl.hostname.toLowerCase()
if (!host.includes("ebay.")) {
return false
}
if (!parsedUrl.pathname.includes("/itm/")) {
return false
}
const parsedPrice = parsePositiveNumber(listing.price || "")
if (!parsedPrice) {
return false
}
if (listing.imageUrl) {
const parsedImage = parseUrl(listing.imageUrl)
const imageHost = parsedImage?.hostname.toLowerCase() || ""
if (PLACEHOLDER_IMAGE_HOSTS.some((placeholder) => imageHost.includes(placeholder))) {
return false
}
}
return true
}
function isRateLimitError(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("10001") ||
normalized.includes("rate limit") ||
normalized.includes("too many requests") ||
normalized.includes("exceeded the number of times") ||
normalized.includes("quota")
)
}
function getBackoffMs(consecutiveFailures: number, rateLimited: boolean) {
if (rateLimited) {
return BASE_REFRESH_MS
}
const base = BASE_REFRESH_MS / 2
const multiplier = Math.max(1, consecutiveFailures)
return Math.min(base * multiplier, MAX_BACKOFF_MS)
}
async function readEbayErrorMessage(response: Response) {
const text = await response.text().catch(() => "")
if (!text.trim()) {
return `eBay API error: ${response.status}`
}
try {
const parsed = JSON.parse(text) as any
const messages = parsed?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(messages?.message)
? messages.message[0]
: messages?.message
if (typeof message === "string" && message.trim()) {
const errorId = Array.isArray(messages?.errorId)
? messages.errorId[0]
: messages?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
} catch {
// Fall through to returning raw text.
}
return text.trim() || `eBay API error: ${response.status}`
}
function readEbayJsonError(data: any): string | null {
const errorMessage = data?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(errorMessage?.message)
? errorMessage.message[0]
: errorMessage?.message
if (typeof message !== "string" || !message.trim()) {
return null
}
const errorId = Array.isArray(errorMessage?.errorId)
? errorMessage.errorId[0]
: errorMessage?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
function normalizeEbayItem(item: any, fetchedAt: number) {
const currentPrice = firstValue(item?.sellingStatus?.currentPrice)
const shippingCost = firstValue(item?.shippingInfo?.shippingServiceCost)
const condition = firstValue(item?.condition)
const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || ""
const title = item?.title || "Unknown Item"
return {
itemId: String(item?.itemId || ""),
title,
normalizedTitle: normalizeText(title),
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: item?.galleryURL || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: undefined,
affiliateLink: buildAffiliateLink(viewItemUrl),
sourceQueries: [] as string[],
fetchedAt,
firstSeenAt: fetchedAt,
lastSeenAt: fetchedAt,
expiresAt: fetchedAt + LISTING_EXPIRES_MS,
active: true,
}
}
async function searchEbayListings(query: (typeof POLL_QUERY_POOL)[number]) {
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
throw new Error("eBay App ID is not configured")
}
const url = new URL("https://svcs.ebay.com/services/search/FindingService/v1")
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
url.searchParams.set("SERVICE-VERSION", "1.0.0")
url.searchParams.set("SECURITY-APPNAME", appId)
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
url.searchParams.set("REST-PAYLOAD", "true")
url.searchParams.set("keywords", query.keywords)
url.searchParams.set("sortOrder", "BestMatch")
url.searchParams.set("paginationInput.entriesPerPage", String(MAX_RESULTS_PER_QUERY))
if (query.categoryId) {
url.searchParams.set("categoryId", query.categoryId)
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
throw new Error(await readEbayErrorMessage(response))
}
const data = await response.json()
const jsonError = readEbayJsonError(data)
if (jsonError) {
throw new Error(jsonError)
}
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
const searchResult = findItemsAdvancedResponse?.searchResult?.[0]
const items = Array.isArray(searchResult?.item)
? searchResult.item
: searchResult?.item
? [searchResult.item]
: []
return items
}
export const getCacheOverview = query({
args: {},
handler: async (ctx) => {
const now = Date.now()
const state =
(await ctx.db
.query("ebayPollState")
.withIndex("by_key", (q) => q.eq("key", POLL_KEY))
.unique()) || null
const listings = await ctx.db.query("ebayListings").collect()
const activeListings = listings.filter(
(listing) => listing.active && listing.expiresAt >= now
)
const freshnessMs = state?.lastSuccessfulAt
? Math.max(0, now - state.lastSuccessfulAt)
: null
return {
key: POLL_KEY,
status: state?.status || "idle",
lastSuccessfulAt: state?.lastSuccessfulAt || null,
lastAttemptAt: state?.lastAttemptAt || null,
nextEligibleAt: state?.nextEligibleAt || null,
lastError: state?.lastError || null,
consecutiveFailures: state?.consecutiveFailures || 0,
queryCount: state?.queryCount || 0,
itemCount: state?.itemCount || 0,
sourceQueries: state?.sourceQueries || [],
freshnessMs,
isStale: freshnessMs !== null ? freshnessMs >= STALE_AFTER_MS : true,
listingCount: listings.length,
activeListingCount: activeListings.length,
}
},
})
export const listCachedListings = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now()
const listings = await ctx.db.query("ebayListings").collect()
const normalized = listings.map((listing) => ({
...listing,
active: listing.active && listing.expiresAt >= now,
}))
normalized.sort((a, b) => {
if (a.active !== b.active) {
return Number(b.active) - Number(a.active)
}
const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0
const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0
return bFreshness - aFreshness
})
return typeof args.limit === "number" && args.limit > 0
? normalized.slice(0, args.limit)
: normalized
},
})
export const refreshCache = action({
args: {
reason: v.optional(v.string()),
force: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const now = Date.now()
const state =
(await ctx.runQuery(api.ebay.getCacheOverview, {})) || ({
status: "idle",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: null,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
} as const)
if (
!args.force &&
typeof state.nextEligibleAt === "number" &&
state.nextEligibleAt > now
) {
return {
status: "skipped",
message: "Refresh is deferred until the next eligible window.",
nextEligibleAt: state.nextEligibleAt,
}
}
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
const nextEligibleAt = now + BASE_REFRESH_MS
await ctx.runMutation(internal.ebay.upsertPollResult, {
key: POLL_KEY,
status: "missing_config",
lastAttemptAt: now,
lastSuccessfulAt: state.lastSuccessfulAt || null,
nextEligibleAt,
lastError: "EBAY_APP_ID is not configured.",
consecutiveFailures: state.consecutiveFailures + 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
listings: [],
reason: args.reason || "missing_config",
})
return {
status: "missing_config",
message: "EBAY_APP_ID is not configured.",
nextEligibleAt,
}
}
const sourceQueries: string[] = []
const collectedListings = new Map<string, ReturnType<typeof normalizeEbayItem>>()
let queryCount = 0
let rateLimited = false
let lastError: string | null = null
const pollQueries = getPollQueriesForRun(now)
for (const query of pollQueries) {
if (collectedListings.size >= MAX_UNIQUE_RESULTS) {
break
}
queryCount += 1
sourceQueries.push(query.label)
try {
const items = await searchEbayListings(query)
for (const item of items) {
const listing = normalizeEbayItem(item, now)
if (!listing.itemId) {
continue
}
if (!isTrustedListingCandidate(listing)) {
continue
}
const existing = collectedListings.get(listing.itemId)
if (existing) {
existing.sourceQueries = Array.from(
new Set([...existing.sourceQueries, query.label])
)
existing.title = listing.title || existing.title
existing.normalizedTitle = normalizeText(existing.title)
existing.price = listing.price || existing.price
existing.currency = listing.currency || existing.currency
existing.imageUrl = listing.imageUrl || existing.imageUrl
existing.viewItemUrl = listing.viewItemUrl || existing.viewItemUrl
existing.condition = listing.condition || existing.condition
existing.shippingCost = listing.shippingCost || existing.shippingCost
existing.affiliateLink = listing.affiliateLink || existing.affiliateLink
existing.lastSeenAt = now
existing.fetchedAt = now
existing.expiresAt = now + LISTING_EXPIRES_MS
existing.active = true
continue
}
listing.sourceQueries = [query.label]
collectedListings.set(listing.itemId, listing)
}
} catch (error) {
lastError = error instanceof Error ? error.message : "Failed to refresh eBay listings"
if (isRateLimitError(lastError)) {
rateLimited = true
break
}
}
}
const listings = Array.from(collectedListings.values())
const nextEligibleAt = now + getBackoffMs(
state.consecutiveFailures + 1,
rateLimited
)
await ctx.runMutation(internal.ebay.upsertPollResult, {
key: POLL_KEY,
status: rateLimited
? "rate_limited"
: lastError
? "error"
: "success",
lastAttemptAt: now,
lastSuccessfulAt: rateLimited || lastError ? state.lastSuccessfulAt || null : now,
nextEligibleAt: rateLimited || lastError ? nextEligibleAt : now + BASE_REFRESH_MS,
lastError: lastError || null,
consecutiveFailures:
rateLimited || lastError ? state.consecutiveFailures + 1 : 0,
queryCount,
itemCount: listings.length,
sourceQueries,
listings,
reason: args.reason || "cron",
})
return {
status: rateLimited ? "rate_limited" : lastError ? "error" : "success",
message: lastError || undefined,
queryCount,
itemCount: listings.length,
nextEligibleAt: rateLimited || lastError ? nextEligibleAt : now + BASE_REFRESH_MS,
}
},
})
export const upsertPollResult = internalMutation({
args: {
key: v.string(),
status: v.union(
v.literal("idle"),
v.literal("success"),
v.literal("rate_limited"),
v.literal("error"),
v.literal("missing_config"),
v.literal("skipped")
),
lastAttemptAt: v.number(),
lastSuccessfulAt: v.union(v.number(), v.null()),
nextEligibleAt: v.union(v.number(), v.null()),
lastError: v.union(v.string(), v.null()),
consecutiveFailures: v.number(),
queryCount: v.number(),
itemCount: v.number(),
sourceQueries: v.array(v.string()),
listings: v.array(
v.object({
itemId: v.string(),
title: v.string(),
normalizedTitle: v.string(),
price: v.string(),
currency: v.string(),
imageUrl: v.optional(v.string()),
viewItemUrl: v.string(),
condition: v.optional(v.string()),
shippingCost: v.optional(v.string()),
affiliateLink: v.string(),
sourceQueries: v.array(v.string()),
fetchedAt: v.number(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
expiresAt: v.number(),
active: v.boolean(),
})
),
reason: v.optional(v.string()),
},
handler: async (ctx, args) => {
for (const listing of args.listings) {
const existing = await ctx.db
.query("ebayListings")
.withIndex("by_itemId", (q) => q.eq("itemId", listing.itemId))
.unique()
if (existing) {
await ctx.db.patch(existing._id, {
title: listing.title,
normalizedTitle: listing.normalizedTitle,
price: listing.price,
currency: listing.currency,
imageUrl: listing.imageUrl,
viewItemUrl: listing.viewItemUrl,
condition: listing.condition,
shippingCost: listing.shippingCost,
affiliateLink: listing.affiliateLink,
sourceQueries: Array.from(
new Set([...(existing.sourceQueries || []), ...(listing.sourceQueries || [])])
),
fetchedAt: listing.fetchedAt,
firstSeenAt: existing.firstSeenAt || listing.firstSeenAt,
lastSeenAt: listing.lastSeenAt,
expiresAt: listing.expiresAt,
active: listing.active,
})
continue
}
await ctx.db.insert("ebayListings", listing)
}
const existingState = await ctx.db
.query("ebayPollState")
.withIndex("by_key", (q) => q.eq("key", args.key))
.unique()
const stateRecord: Record<string, any> = {
key: args.key,
status: args.status,
lastAttemptAt: args.lastAttemptAt,
consecutiveFailures: args.consecutiveFailures,
queryCount: args.queryCount,
itemCount: args.itemCount,
sourceQueries: args.sourceQueries,
updatedAt: Date.now(),
}
if (args.lastSuccessfulAt !== null) {
stateRecord.lastSuccessfulAt = args.lastSuccessfulAt
}
if (args.nextEligibleAt !== null) {
stateRecord.nextEligibleAt = args.nextEligibleAt
}
if (args.lastError !== null) {
stateRecord.lastError = args.lastError
}
if (existingState) {
await ctx.db.patch(existingState._id, stateRecord)
return await ctx.db.get(existingState._id)
}
const id = await ctx.db.insert("ebayPollState", stateRecord)
return await ctx.db.get(id)
},
})

View file

@ -1,229 +0,0 @@
// @ts-nocheck
type GhlMirrorConfig = {
token: string
locationId: string
baseUrl: string
version: string
}
function normalizeBaseUrl(value?: string) {
return String(value || "https://services.leadconnectorhq.com").replace(
/\/+$/,
""
)
}
export function readGhlMirrorConfig() {
const token = String(
process.env.GHL_PRIVATE_INTEGRATION_TOKEN || process.env.GHL_API_TOKEN || ""
).trim()
const locationId = String(process.env.GHL_LOCATION_ID || "").trim()
const baseUrl = normalizeBaseUrl(process.env.GHL_API_BASE_URL)
const version = String(process.env.GHL_API_VERSION || "2021-07-28").trim()
if (!token || !locationId) {
return null
}
return {
token,
locationId,
baseUrl,
version,
} satisfies GhlMirrorConfig
}
export async function fetchGhlMirrorJson(
config: GhlMirrorConfig,
pathname: string,
init?: RequestInit
) {
const response = await fetch(`${config.baseUrl}${pathname}`, {
...init,
headers: {
Authorization: `Bearer ${config.token}`,
Version: config.version,
Accept: "application/json",
"Content-Type": "application/json",
...(init?.headers || {}),
},
cache: "no-store",
})
const text = await response.text()
let body: any = null
if (text) {
try {
body = JSON.parse(text)
} catch {
body = null
}
}
if (!response.ok) {
throw new Error(
`GHL request failed (${response.status}) for ${pathname}: ${body?.message || text || "Unknown error"}`
)
}
return body
}
export async function fetchGhlContactsPage(
config: GhlMirrorConfig,
args?: {
limit?: number
cursor?: string
}
) {
const searchParams = new URLSearchParams({
locationId: config.locationId,
limit: String(Math.min(100, Math.max(1, args?.limit || 100))),
})
if (args?.cursor) {
searchParams.set("startAfterId", args.cursor)
}
const payload = await fetchGhlMirrorJson(
config,
`/contacts/?${searchParams.toString()}`
)
const contacts = Array.isArray(payload?.contacts)
? payload.contacts
: Array.isArray(payload?.data?.contacts)
? payload.data.contacts
: []
const nextCursor =
contacts.length > 0 ? String(contacts[contacts.length - 1]?.id || "") : ""
return {
items: contacts,
nextCursor: nextCursor || undefined,
}
}
export async function fetchGhlMessagesPage(
config: GhlMirrorConfig,
args?: {
limit?: number
cursor?: string
channel?: "Call" | "SMS"
}
) {
const url = new URL(`${config.baseUrl}/conversations/messages/export`)
url.searchParams.set("locationId", config.locationId)
url.searchParams.set("limit", String(Math.min(100, Math.max(1, args?.limit || 100))))
url.searchParams.set("channel", args?.channel || "SMS")
if (args?.cursor) {
url.searchParams.set("cursor", args.cursor)
}
const payload = await fetchGhlMirrorJson(config, url.pathname + url.search)
return {
items: Array.isArray(payload?.messages) ? payload.messages : [],
nextCursor:
typeof payload?.nextCursor === "string" && payload.nextCursor
? payload.nextCursor
: undefined,
}
}
export async function fetchGhlConversationsPage(
config: GhlMirrorConfig,
args?: {
limit?: number
}
) {
const url = new URL(`${config.baseUrl}/conversations/search`)
url.searchParams.set("locationId", config.locationId)
url.searchParams.set("limit", String(Math.min(100, Math.max(1, args?.limit || 100))))
const payload = await fetchGhlMirrorJson(config, url.pathname + url.search)
return {
items: Array.isArray(payload?.conversations) ? payload.conversations : [],
total:
typeof payload?.total === "number"
? payload.total
: Array.isArray(payload?.conversations)
? payload.conversations.length
: 0,
}
}
export async function fetchGhlConversationMessages(
config: GhlMirrorConfig,
args: {
conversationId: string
}
) {
const payload = await fetchGhlMirrorJson(
config,
`/conversations/${encodeURIComponent(args.conversationId)}/messages`
)
return {
items: Array.isArray(payload?.messages?.messages)
? payload.messages.messages
: Array.isArray(payload?.messages)
? payload.messages
: Array.isArray(payload?.data?.messages?.messages)
? payload.data.messages.messages
: Array.isArray(payload?.data?.messages)
? payload.data.messages
: Array.isArray(payload)
? payload
: [],
}
}
export async function sendGhlConversationMessage(
config: GhlMirrorConfig,
args: {
conversationId?: string
contactId?: string
message: string
type?: string
}
) {
return await fetchGhlMirrorJson(config, "/conversations/messages", {
method: "POST",
body: JSON.stringify({
type: args.type || "SMS",
message: args.message,
conversationId: args.conversationId,
contactId: args.contactId,
}),
})
}
export async function fetchGhlCallLogsPage(
config: GhlMirrorConfig,
args?: {
page?: number
pageSize?: number
}
) {
const url = new URL(`${config.baseUrl}/voice-ai/dashboard/call-logs`)
url.searchParams.set("locationId", config.locationId)
url.searchParams.set("page", String(Math.max(1, args?.page || 1)))
url.searchParams.set(
"pageSize",
String(Math.min(50, Math.max(1, args?.pageSize || 50)))
)
const payload = await fetchGhlMirrorJson(config, url.pathname + url.search)
return {
items: Array.isArray(payload?.callLogs) ? payload.callLogs : [],
page: Number(payload?.page || args?.page || 1),
total: Number(payload?.total || 0),
pageSize: Number(payload?.pageSize || args?.pageSize || 50),
}
}

View file

@ -1,12 +1,6 @@
// @ts-nocheck
import { action, mutation } from "./_generated/server"
import { v } from "convex/values"
import {
ensureConversationParticipant,
upsertContactRecord,
upsertConversationRecord,
upsertMessageRecord,
} from "./crmModel"
const leadSyncStatus = v.union(
v.literal("pending"),
@ -125,60 +119,8 @@ export const createLead = mutation({
},
handler: async (ctx, args) => {
const now = Date.now()
const contact = await upsertContactRecord(ctx, {
firstName: args.firstName,
lastName: args.lastName,
email: args.email,
phone: args.phone,
company: args.company,
source: args.source,
status: args.status === "delivered" ? "active" : "lead",
lastActivityAt: now,
})
const conversation = await upsertConversationRecord(ctx, {
contactId: contact?._id,
title:
args.type === "requestMachine"
? "Machine request"
: "Website contact",
channel: "chat",
source: args.source || "website",
status: args.status === "failed" ? "archived" : "open",
direction: "inbound",
startedAt: now,
lastMessageAt: now,
lastMessagePreview: args.message || args.intent,
summaryText: args.intent,
})
await ensureConversationParticipant(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
role: "contact",
displayName: `${args.firstName} ${args.lastName}`.trim(),
phone: args.phone,
email: args.email,
})
if (args.message || args.intent) {
await upsertMessageRecord(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
direction: "inbound",
channel: "chat",
source: args.source || "website",
messageType: args.type,
body: args.message || args.intent || "",
status: args.status,
sentAt: now,
})
}
return await ctx.db.insert("leadSubmissions", {
...args,
contactId: contact?._id,
conversationId: conversation?._id,
createdAt: now,
updatedAt: now,
deliveredAt: args.status === "delivered" ? now : undefined,
@ -234,54 +176,6 @@ export const ingestLead = mutation({
const fallbackName = splitName(args.name)
const type = mapServiceToType(args.service)
const now = Date.now()
const contact = await upsertContactRecord(ctx, {
firstName: args.firstName || fallbackName.firstName,
lastName: args.lastName || fallbackName.lastName,
email: args.email,
phone: args.phone,
company: args.company,
source: args.source,
status: "lead",
lastActivityAt: now,
})
const conversation = await upsertConversationRecord(ctx, {
contactId: contact?._id,
title: type === "requestMachine" ? "Machine request" : "Website contact",
channel: "chat",
source: args.source || "website",
status: "open",
direction: "inbound",
startedAt: now,
lastMessageAt: now,
lastMessagePreview: args.message || args.intent,
summaryText: args.intent || args.service,
})
await ensureConversationParticipant(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
role: "contact",
displayName: `${args.firstName || fallbackName.firstName} ${args.lastName || fallbackName.lastName}`.trim(),
phone: args.phone,
email: args.email,
})
await upsertMessageRecord(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
direction: "inbound",
channel: "chat",
source: args.source || "website",
messageType: type,
body: args.message,
status: "pending",
sentAt: now,
metadata: JSON.stringify({
intent: args.intent,
service: args.service,
}),
})
const leadId = await ctx.db.insert("leadSubmissions", {
type,
status: "pending",
@ -304,8 +198,6 @@ export const ingestLead = mutation({
consentVersion: args.consentVersion,
consentCapturedAt: args.consentCapturedAt,
consentSourcePage: args.consentSourcePage,
contactId: contact?._id,
conversationId: conversation?._id,
usesendStatus: "pending",
ghlStatus: "pending",
createdAt: now,
@ -349,22 +241,6 @@ export const updateLeadSyncStatus = mutation({
updatedAt: now,
})
if (lead.contactId) {
await ctx.db.patch(lead.contactId, {
status: status === "delivered" ? "active" : "lead",
lastActivityAt: now,
updatedAt: now,
})
}
if (lead.conversationId) {
await ctx.db.patch(lead.conversationId, {
status: status === "failed" ? "archived" : "open",
lastMessageAt: now,
updatedAt: now,
})
}
return await ctx.db.get(args.leadId)
},
})

View file

@ -1,11 +1,6 @@
// @ts-nocheck
import { mutation, query } from "./_generated/server"
import { v } from "convex/values"
import {
canonicalizeTenantDomain,
manualVisibleForTenant,
tenantDomainVariants,
} from "../lib/manuals-tenant"
const manualInput = v.object({
filename: v.string(),
@ -28,19 +23,10 @@ const manualInput = v.object({
})
export const list = query({
args: {
domain: v.string(),
},
handler: async (ctx, args) => {
const tenantDomain = canonicalizeTenantDomain(args.domain)
if (!tenantDomain) {
return []
}
args: {},
handler: async (ctx) => {
const manuals = await ctx.db.query("manuals").collect()
return manuals
.filter((manual) => manualVisibleForTenant(manual, tenantDomain))
.sort((a, b) => a.filename.localeCompare(b.filename))
return manuals.sort((a, b) => a.filename.localeCompare(b.filename))
},
})
@ -120,64 +106,3 @@ export const upsertMany = mutation({
return results
},
})
export const backfillTenantVisibility = mutation({
args: {
domain: v.string(),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const tenantDomain = canonicalizeTenantDomain(args.domain)
if (!tenantDomain) {
throw new Error("A valid tenant domain is required.")
}
const aliases = tenantDomainVariants(tenantDomain)
const dryRun = Boolean(args.dryRun)
const now = Date.now()
const manuals = await ctx.db.query("manuals").collect()
let patched = 0
let alreadyCovered = 0
for (const manual of manuals) {
const visibilitySet = new Set(
(manual.siteVisibility || [])
.map((entry) => canonicalizeTenantDomain(entry))
.filter(Boolean)
)
const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain)
const hasDomain =
aliases.some((alias) => visibilitySet.has(alias)) ||
(sourceDomain ? aliases.includes(sourceDomain) : false)
if (hasDomain) {
alreadyCovered += 1
continue
}
const nextVisibility = Array.from(
new Set([...visibilitySet, ...aliases])
).sort()
if (!dryRun) {
await ctx.db.patch(manual._id, {
sourceDomain: sourceDomain || tenantDomain,
siteVisibility: nextVisibility,
updatedAt: now,
})
}
patched += 1
}
return {
domain: tenantDomain,
total: manuals.length,
patched,
alreadyCovered,
dryRun,
}
},
})

View file

@ -90,50 +90,6 @@ export default defineSchema({
.index("by_category", ["category"])
.index("by_path", ["path"]),
ebayListings: defineTable({
itemId: v.string(),
title: v.string(),
normalizedTitle: v.string(),
price: v.string(),
currency: v.string(),
imageUrl: v.optional(v.string()),
viewItemUrl: v.string(),
condition: v.optional(v.string()),
shippingCost: v.optional(v.string()),
affiliateLink: v.string(),
sourceQueries: v.array(v.string()),
fetchedAt: v.number(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
expiresAt: v.number(),
active: v.boolean(),
})
.index("by_itemId", ["itemId"])
.index("by_active", ["active"])
.index("by_expiresAt", ["expiresAt"])
.index("by_lastSeenAt", ["lastSeenAt"]),
ebayPollState: defineTable({
key: v.string(),
status: v.union(
v.literal("idle"),
v.literal("success"),
v.literal("rate_limited"),
v.literal("error"),
v.literal("missing_config"),
v.literal("skipped")
),
lastSuccessfulAt: v.optional(v.number()),
lastAttemptAt: v.optional(v.number()),
nextEligibleAt: v.optional(v.number()),
lastError: v.optional(v.string()),
consecutiveFailures: v.number(),
queryCount: v.number(),
itemCount: v.number(),
sourceQueries: v.array(v.string()),
updatedAt: v.number(),
}).index("by_key", ["key"]),
manualCategories: defineTable({
name: v.string(),
slug: v.string(),
@ -189,8 +145,6 @@ export default defineSchema({
v.literal("skipped")
)
),
contactId: v.optional(v.id("contacts")),
conversationId: v.optional(v.id("conversations")),
error: v.optional(v.string()),
deliveredAt: v.optional(v.number()),
createdAt: v.number(),
@ -245,186 +199,6 @@ export default defineSchema({
.index("by_kind", ["kind"])
.index("by_status", ["status"]),
contacts: defineTable({
firstName: v.string(),
lastName: v.string(),
email: v.optional(v.string()),
normalizedEmail: v.optional(v.string()),
phone: v.optional(v.string()),
normalizedPhone: v.optional(v.string()),
company: v.optional(v.string()),
tags: v.optional(v.array(v.string())),
status: v.optional(
v.union(
v.literal("active"),
v.literal("lead"),
v.literal("customer"),
v.literal("inactive")
)
),
source: v.optional(v.string()),
notes: v.optional(v.string()),
ghlContactId: v.optional(v.string()),
livekitIdentity: v.optional(v.string()),
lastActivityAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_normalizedEmail", ["normalizedEmail"])
.index("by_normalizedPhone", ["normalizedPhone"])
.index("by_ghlContactId", ["ghlContactId"])
.index("by_lastActivityAt", ["lastActivityAt"])
.index("by_updatedAt", ["updatedAt"]),
conversations: defineTable({
contactId: v.optional(v.id("contacts")),
title: v.optional(v.string()),
channel: v.union(
v.literal("call"),
v.literal("sms"),
v.literal("chat"),
v.literal("unknown")
),
source: v.optional(v.string()),
status: v.optional(
v.union(v.literal("open"), v.literal("closed"), v.literal("archived"))
),
direction: v.optional(
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("mixed"))
),
startedAt: v.number(),
endedAt: v.optional(v.number()),
lastMessageAt: v.optional(v.number()),
lastMessagePreview: v.optional(v.string()),
unreadCount: v.optional(v.number()),
summaryText: v.optional(v.string()),
ghlConversationId: v.optional(v.string()),
livekitRoomName: v.optional(v.string()),
voiceSessionId: v.optional(v.id("voiceSessions")),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_contactId", ["contactId"])
.index("by_channel", ["channel"])
.index("by_status", ["status"])
.index("by_ghlConversationId", ["ghlConversationId"])
.index("by_livekitRoomName", ["livekitRoomName"])
.index("by_voiceSessionId", ["voiceSessionId"])
.index("by_lastMessageAt", ["lastMessageAt"]),
conversationParticipants: defineTable({
conversationId: v.id("conversations"),
contactId: v.optional(v.id("contacts")),
role: v.optional(
v.union(
v.literal("contact"),
v.literal("agent"),
v.literal("system"),
v.literal("unknown")
)
),
displayName: v.optional(v.string()),
phone: v.optional(v.string()),
normalizedPhone: v.optional(v.string()),
email: v.optional(v.string()),
normalizedEmail: v.optional(v.string()),
externalContactId: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_conversationId", ["conversationId"])
.index("by_contactId", ["contactId"])
.index("by_externalContactId", ["externalContactId"]),
messages: defineTable({
conversationId: v.id("conversations"),
contactId: v.optional(v.id("contacts")),
direction: v.optional(
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("system"))
),
channel: v.union(
v.literal("call"),
v.literal("sms"),
v.literal("chat"),
v.literal("unknown")
),
source: v.optional(v.string()),
messageType: v.optional(v.string()),
body: v.string(),
status: v.optional(v.string()),
sentAt: v.number(),
ghlMessageId: v.optional(v.string()),
voiceTranscriptTurnId: v.optional(v.id("voiceTranscriptTurns")),
voiceSessionId: v.optional(v.id("voiceSessions")),
livekitRoomName: v.optional(v.string()),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_conversationId", ["conversationId"])
.index("by_contactId", ["contactId"])
.index("by_ghlMessageId", ["ghlMessageId"])
.index("by_voiceTranscriptTurnId", ["voiceTranscriptTurnId"])
.index("by_sentAt", ["sentAt"]),
callArtifacts: defineTable({
conversationId: v.id("conversations"),
contactId: v.optional(v.id("contacts")),
source: v.optional(v.string()),
recordingId: v.optional(v.string()),
recordingUrl: v.optional(v.string()),
recordingStatus: v.optional(
v.union(
v.literal("pending"),
v.literal("starting"),
v.literal("recording"),
v.literal("completed"),
v.literal("failed")
)
),
transcriptionText: v.optional(v.string()),
durationMs: v.optional(v.number()),
startedAt: v.optional(v.number()),
endedAt: v.optional(v.number()),
ghlMessageId: v.optional(v.string()),
voiceSessionId: v.optional(v.id("voiceSessions")),
livekitRoomName: v.optional(v.string()),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_conversationId", ["conversationId"])
.index("by_contactId", ["contactId"])
.index("by_recordingId", ["recordingId"])
.index("by_voiceSessionId", ["voiceSessionId"])
.index("by_ghlMessageId", ["ghlMessageId"]),
externalSyncState: defineTable({
provider: v.string(),
entityType: v.string(),
entityId: v.string(),
cursor: v.optional(v.string()),
checksum: v.optional(v.string()),
status: v.optional(
v.union(
v.literal("running"),
v.literal("pending"),
v.literal("synced"),
v.literal("failed"),
v.literal("missing_config"),
v.literal("reconciled"),
v.literal("mismatch")
)
),
lastAttemptAt: v.optional(v.number()),
lastSyncedAt: v.optional(v.number()),
error: v.optional(v.string()),
metadata: v.optional(v.string()),
updatedAt: v.number(),
})
.index("by_provider_entityType", ["provider", "entityType"])
.index("by_provider_entityType_entityId", ["provider", "entityType", "entityId"]),
voiceSessions: defineTable({
roomName: v.string(),
participantIdentity: v.string(),
@ -474,8 +248,6 @@ export default defineSchema({
recordingUrl: v.optional(v.string()),
recordingError: v.optional(v.string()),
metadata: v.optional(v.string()),
contactId: v.optional(v.id("contacts")),
conversationId: v.optional(v.id("conversations")),
createdAt: v.number(),
updatedAt: v.number(),
})

View file

@ -1,68 +1,6 @@
// @ts-nocheck
import { mutation, query } from "./_generated/server"
import { v } from "convex/values"
import {
ensureConversationParticipant,
upsertCallArtifactRecord,
upsertContactRecord,
upsertConversationRecord,
upsertMessageRecord,
} from "./crmModel"
async function syncPhoneConversation(ctx, session, overrides = {}) {
const contact = await upsertContactRecord(ctx, {
firstName: "Phone",
lastName: "Caller",
phone: session.participantIdentity,
livekitIdentity: session.participantIdentity,
source: "phone-agent",
status: "lead",
lastActivityAt:
overrides.lastActivityAt ?? session.updatedAt ?? session.startedAt ?? Date.now(),
})
const conversation = await upsertConversationRecord(ctx, {
contactId: contact?._id,
title: `Phone call ${session.roomName}`,
channel: "call",
source: "phone-agent",
status:
session.callStatus === "completed"
? "closed"
: session.callStatus === "failed"
? "archived"
: "open",
direction: "inbound",
startedAt: session.startedAt,
endedAt: session.endedAt,
lastMessageAt: overrides.lastActivityAt ?? session.updatedAt ?? session.startedAt,
lastMessagePreview:
overrides.lastMessagePreview ?? session.summaryText ?? session.handoffReason,
summaryText: session.summaryText,
livekitRoomName: session.roomName,
voiceSessionId: session._id,
})
await ensureConversationParticipant(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
role: "contact",
displayName: contact ? `${contact.firstName} ${contact.lastName}`.trim() : "Phone caller",
phone: contact?.phone || session.participantIdentity,
email: contact?.email,
})
await ctx.db.patch(session._id, {
contactId: contact?._id,
conversationId: conversation?._id,
updatedAt: Date.now(),
})
return {
contact,
conversation,
}
}
export const getByRoom = query({
args: {
@ -145,7 +83,7 @@ export const createSession = mutation({
},
handler: async (ctx, args) => {
const now = args.startedAt ?? Date.now()
const id = await ctx.db.insert("voiceSessions", {
return await ctx.db.insert("voiceSessions", {
...args,
startedAt: now,
callStatus: args.callStatus,
@ -156,13 +94,6 @@ export const createSession = mutation({
createdAt: now,
updatedAt: now,
})
const session = await ctx.db.get(id)
if (session) {
await syncPhoneConversation(ctx, session)
}
return id
},
})
@ -210,11 +141,7 @@ export const upsertPhoneCallSession = mutation({
notificationStatus: existing.notificationStatus || "pending",
updatedAt: Date.now(),
})
const updated = await ctx.db.get(existing._id)
if (updated) {
await syncPhoneConversation(ctx, updated)
}
return updated
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("voiceSessions", {
@ -237,11 +164,7 @@ export const upsertPhoneCallSession = mutation({
updatedAt: now,
})
const session = await ctx.db.get(id)
if (session) {
await syncPhoneConversation(ctx, session)
}
return session
return await ctx.db.get(id)
},
})
@ -280,33 +203,6 @@ export const addTranscriptTurn = mutation({
: session.agentAnsweredAt,
updatedAt: Date.now(),
})
const { contact, conversation } = await syncPhoneConversation(ctx, {
...session,
updatedAt: createdAt,
}, {
lastActivityAt: createdAt,
lastMessagePreview: args.text,
})
await upsertMessageRecord(ctx, {
conversationId: conversation._id,
contactId: args.role === "user" ? contact?._id : undefined,
direction:
args.role === "user"
? "inbound"
: args.role === "assistant"
? "outbound"
: "system",
channel: "call",
source: args.source || "phone-agent",
messageType: args.kind || "transcript",
body: args.text,
sentAt: createdAt,
voiceTranscriptTurnId: turnId,
voiceSessionId: args.sessionId,
livekitRoomName: args.roomName,
})
}
return turnId
@ -335,21 +231,8 @@ export const linkPhoneCallLead = mutation({
handoffReason: args.handoffReason,
updatedAt: Date.now(),
})
const session = await ctx.db.get(args.sessionId)
if (session) {
const { conversation } = await syncPhoneConversation(ctx, session)
if (args.linkedLeadId || args.leadOutcome || args.handoffReason) {
await ctx.db.patch(conversation._id, {
summaryText:
session.summaryText ||
args.handoffReason ||
conversation.summaryText,
updatedAt: Date.now(),
})
}
}
return session
return await ctx.db.get(args.sessionId)
},
})
@ -377,21 +260,7 @@ export const updateRecording = mutation({
recordingError: args.recordingError,
updatedAt: Date.now(),
})
const session = await ctx.db.get(args.sessionId)
if (session) {
const { contact, conversation } = await syncPhoneConversation(ctx, session)
await upsertCallArtifactRecord(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
source: "phone-agent",
recordingId: args.recordingId,
recordingUrl: args.recordingUrl,
recordingStatus: args.recordingStatus,
voiceSessionId: session._id,
livekitRoomName: session.roomName,
})
}
return session
return await ctx.db.get(args.sessionId)
},
})
@ -441,31 +310,7 @@ export const completeSession = mutation({
notificationError: args.notificationError,
updatedAt: endedAt,
})
const session = await ctx.db.get(args.sessionId)
if (session) {
const { contact, conversation } = await syncPhoneConversation(ctx, session, {
lastActivityAt: endedAt,
lastMessagePreview: args.summaryText || session.summaryText,
})
await upsertCallArtifactRecord(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
source: "phone-agent",
recordingId: args.recordingId,
recordingUrl: args.recordingUrl,
recordingStatus: args.recordingStatus,
transcriptionText: args.summaryText,
durationMs:
typeof session.startedAt === "number"
? Math.max(0, endedAt - session.startedAt)
: undefined,
startedAt: session.startedAt,
endedAt,
voiceSessionId: session._id,
livekitRoomName: session.roomName,
})
}
return session
return await ctx.db.get(args.sessionId)
},
})

View file

@ -1,61 +0,0 @@
# Manuals Tenant Recovery Runbook
## 1) Verify runtime env on active app
Confirm these variables on the live Coolify app/container:
- `NEXT_PUBLIC_CONVEX_URL` (full `https://...` URL)
- `NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app`
- Optional override: `MANUALS_TENANT_DOMAIN=rmv.abundancepartners.app`
## 2) Verify Convex tenant data gate
Run:
```bash
pnpm deploy:staging:convex-gate
```
This fails if Convex returns fewer than one manual for the active domain.
## 3) Backfill existing manuals rows for tenant visibility
Dry run first:
```bash
pnpm manuals:backfill:tenant -- --domain rmv.abundancepartners.app --dry-run
```
Apply:
```bash
pnpm manuals:backfill:tenant -- --domain rmv.abundancepartners.app
```
## 4) Re-run smoke checks
```bash
pnpm deploy:staging:smoke -- --base-url https://rmv.abundancepartners.app --skip-browser
```
Manuals checks will fail if:
- `/manuals` renders with `initialManuals: []`
- tenant domain marker mismatches the host
- degraded manuals state is shown
## 5) Recover eBay parts cache (when status is `rate_limited`/empty)
Force a cache refresh from the live app (requires `ADMIN_API_TOKEN`):
```bash
curl -sS -X POST \
-H "x-admin-token: $ADMIN_API_TOKEN" \
https://rmv.abundancepartners.app/api/admin/ebay/refresh
```
Then verify:
1. `GET /api/ebay/search?...` reports cache `status=success` with non-zero `listingCount`.
2. `POST /api/ebay/manual-parts` for a known parts manual returns at least one listing.
3. Manual viewer shows no stale/error eBay panel when matches are unavailable.

View file

@ -1,55 +0,0 @@
# eBay Cache Diagnosis
Use this when the manuals/parts experience looks empty or stale and you want to know whether the problem is env, Convex, cache data, or the browser UI.
## What It Checks
- Public pages: `/`, `/contact-us`, `/products`, `/manuals`
- eBay cache routes:
- `GET /api/ebay/search?keywords=vending machine part`
- `POST /api/ebay/manual-parts`
- Notification validation:
- `GET /api/ebay/notifications?challenge_code=...`
- Admin refresh:
- `POST /api/admin/ebay/refresh` when an admin token is provided
- Browser smoke:
- Loads `/manuals`
- Opens the AP parts manual viewer
- Confirms the viewer or fallback state is visible
## How To Run
Local:
```bash
pnpm diagnose:ebay
```
Staging:
```bash
pnpm diagnose:ebay --base-url https://rmv.abundancepartners.app --admin-token "$ADMIN_API_TOKEN"
```
You can skip browser checks if Playwright browsers are unavailable:
```bash
SMOKE_SKIP_BROWSER=1 pnpm diagnose:ebay
```
## How To Read The Output
- `NEXT_PUBLIC_CONVEX_URL missing`
- The cache routes should return `status: disabled` and no listings.
- `cache.message` mentions bundled/fallback cache
- This is not revenue-ready. The app is not using Convex cached inventory.
- `cache.status=success` with `listingCount=0`
- Treat this as backend cache failure or empty cache; not revenue-ready.
- `synthetic placeholder listings` failure
- Listings are fake data and should not be shown in affiliate cards.
- `trusted listings missing affiliate tracking` failure
- Listings may be real but links are not monetized yet.
- Notification challenge returns `200`
- The eBay validation endpoint is wired correctly.
- Admin refresh returns `2xx`
- The cache seeding path is available and the admin token is accepted.

View file

@ -3,9 +3,6 @@ import { fetchQuery } from "convex/nextjs"
import { makeFunctionReference } from "convex/server"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
import { normalizeManualAssetValue } from "@/lib/manuals-asset-paths"
import { deriveThumbnailPathFromManualPath } from "@/lib/manuals-thumbnail-fallback"
import type { Product } from "@/lib/products/types"
import type { Manual } from "@/lib/manuals-types"
@ -83,15 +80,14 @@ function getServerConvexClient(useAdminAuth: boolean) {
async function queryManualsWithAuthFallback<TData>(
label: string,
queryRef: ReturnType<typeof makeFunctionReference<"query">>,
fallback: TData,
args: Record<string, unknown> = {}
fallback: TData
): Promise<TData> {
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
const adminClient = getServerConvexClient(true)
if (adminClient) {
try {
return (await adminClient.query(queryRef, args)) as TData
return (await adminClient.query(queryRef, {})) as TData
} catch (error) {
console.error(`[convex-service] ${label} admin query failed`, error)
if (!adminKey) {
@ -107,7 +103,7 @@ async function queryManualsWithAuthFallback<TData>(
return await safeFetchQuery(
`${label}.public`,
publicClient.query(queryRef, args),
publicClient.query(queryRef, {}),
fallback
)
}
@ -171,13 +167,6 @@ function mapConvexProduct(product: ConvexProductDoc): Product {
}
function mapConvexManual(manual: ConvexManualDoc): Manual {
const normalizedThumbnailUrl = normalizeManualAssetValue(
manual.thumbnailUrl,
"thumbnail"
)
const thumbnailUrl =
normalizedThumbnailUrl || deriveThumbnailPathFromManualPath(manual.path)
return {
filename: manual.filename,
path: manual.path,
@ -189,7 +178,7 @@ function mapConvexManual(manual: ConvexManualDoc): Manual {
: undefined,
searchTerms: manual.searchTerms,
commonNames: manual.commonNames,
thumbnailUrl: thumbnailUrl || undefined,
thumbnailUrl: manual.thumbnailUrl,
}
}
@ -238,26 +227,15 @@ export async function getConvexProduct(id: string): Promise<Product | null> {
return match ? mapConvexProduct(match) : null
}
export async function listConvexManuals(domain?: string): Promise<Manual[]> {
export async function listConvexManuals(): Promise<Manual[]> {
if (!hasConvexUrl()) {
return []
}
const tenantDomain = resolveManualsTenantDomain({
requestHost: domain,
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
})
if (!tenantDomain) {
return []
}
const manuals = await queryManualsWithAuthFallback(
"manuals.list",
LIST_MANUALS,
[] as ConvexManualDoc[],
{ domain: tenantDomain }
[] as ConvexManualDoc[]
)
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
}

View file

@ -1,516 +0,0 @@
export type CachedEbayListing = {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
normalizedTitle?: string
sourceQueries?: string[]
fetchedAt?: number
firstSeenAt?: number
lastSeenAt?: number
expiresAt?: number
active?: boolean
}
export type ManualPartInput = {
partNumber: string
description: string
manufacturer?: string
category?: string
manualFilename?: string
}
export type EbayCacheState = {
key: string
status:
| "idle"
| "success"
| "rate_limited"
| "error"
| "missing_config"
| "skipped"
| "disabled"
lastSuccessfulAt: number | null
lastAttemptAt: number | null
nextEligibleAt: number | null
lastError: string | null
consecutiveFailures: number
queryCount: number
itemCount: number
sourceQueries: string[]
freshnessMs: number | null
isStale: boolean
listingCount?: number
activeListingCount?: number
message?: string
}
const SYNTHETIC_ITEM_PREFIX = "123456789"
const PLACEHOLDER_IMAGE_HOSTS = [
"images.unsplash.com",
"via.placeholder.com",
"placehold.co",
]
const GENERIC_PART_TERMS = new Set([
"and",
"the",
"for",
"with",
"from",
"page",
"part",
"parts",
"number",
"numbers",
"read",
"across",
"refer",
"reference",
"shown",
"figure",
"fig",
"rev",
"revision",
"item",
"items",
"assembly",
"assy",
"machine",
"vending",
])
const COMMON_QUERY_STOPWORDS = new Set([
"a",
"an",
"and",
"for",
"in",
"of",
"the",
"to",
"with",
"vending",
"machine",
"machines",
"part",
"parts",
])
function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
}
function parsePositivePrice(value: string): number | null {
const match = value.match(/([0-9]+(?:\.[0-9]+)?)/)
if (!match) {
return null
}
const parsed = Number.parseFloat(match[1])
if (!Number.isFinite(parsed) || parsed <= 0) {
return null
}
return parsed
}
function parseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}
export function isSyntheticEbayListing(
listing: Pick<CachedEbayListing, "itemId" | "viewItemUrl" | "imageUrl">
): boolean {
const itemId = listing.itemId?.trim() || ""
const viewItemUrl = listing.viewItemUrl?.trim() || ""
const imageUrl = listing.imageUrl?.trim() || ""
if (!itemId || itemId.startsWith(SYNTHETIC_ITEM_PREFIX)) {
return true
}
if (viewItemUrl.includes(SYNTHETIC_ITEM_PREFIX)) {
return true
}
if (imageUrl) {
const parsedImageUrl = parseUrl(imageUrl)
const imageHost = parsedImageUrl?.hostname.toLowerCase() || ""
if (PLACEHOLDER_IMAGE_HOSTS.some((host) => imageHost.includes(host))) {
return true
}
}
return false
}
export function isTrustedEbayListing(listing: CachedEbayListing): boolean {
const itemId = listing.itemId?.trim() || ""
if (!/^[0-9]{9,15}$/.test(itemId)) {
return false
}
if (isSyntheticEbayListing(listing)) {
return false
}
const parsedViewUrl = parseUrl(listing.viewItemUrl || "")
if (!parsedViewUrl) {
return false
}
const viewHost = parsedViewUrl.hostname.toLowerCase()
if (!viewHost.includes("ebay.")) {
return false
}
if (!parsedViewUrl.pathname.includes("/itm/")) {
return false
}
if (!parsePositivePrice(listing.price || "")) {
return false
}
return true
}
export function filterTrustedEbayListings(
listings: CachedEbayListing[]
): CachedEbayListing[] {
return listings.filter((listing) => isTrustedEbayListing(listing))
}
function tokenize(value: string): string[] {
return Array.from(
new Set(
normalizeText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length > 1 && !COMMON_QUERY_STOPWORDS.has(token))
)
)
}
function listingSearchText(listing: Pick<CachedEbayListing, "title" | "normalizedTitle">): string {
return normalizeText(listing.normalizedTitle || listing.title)
}
function isListingFresh(listing: CachedEbayListing): boolean {
if (listing.active === false) {
return false
}
if (typeof listing.expiresAt === "number") {
return listing.expiresAt >= Date.now()
}
return true
}
function sourceQueryBonus(listing: CachedEbayListing, queryTerms: string[]): number {
if (!listing.sourceQueries || listing.sourceQueries.length === 0) {
return 0
}
const sourceText = listing.sourceQueries.map((query) => normalizeText(query)).join(" ")
let bonus = 0
for (const term of queryTerms) {
if (sourceText.includes(term)) {
bonus += 3
}
}
return bonus
}
function computeTokenOverlapScore(queryTerms: string[], haystackText: string): number {
let score = 0
for (const term of queryTerms) {
if (haystackText.includes(term)) {
score += 8
}
}
return score
}
function scoreListingForPart(part: ManualPartInput, listing: CachedEbayListing): number {
const partNumber = normalizeText(part.partNumber)
const description = normalizeText(part.description)
const manufacturer = normalizeText(part.manufacturer || "")
const category = normalizeText(part.category || "")
const titleText = listingSearchText(listing)
const listingTokens = tokenize(listing.title)
const descriptionTokens = tokenize(part.description)
const manufacturerTokens = tokenize(part.manufacturer || "")
const categoryTokens = tokenize(part.category || "")
let score = isListingFresh(listing) ? 10 : -6
if (!partNumber) {
return -100
}
if (titleText.includes(partNumber)) {
score += 110
}
const compactPartNumber = partNumber.replace(/\s+/g, "")
const compactTitle = titleText.replace(/\s+/g, "")
if (compactPartNumber && compactTitle.includes(compactPartNumber)) {
score += 90
}
const exactTokenMatch = listingTokens.includes(partNumber)
if (exactTokenMatch) {
score += 80
}
const digitsOnlyPart = partNumber.replace(/[^0-9]/g, "")
if (digitsOnlyPart.length >= 4 && compactTitle.includes(digitsOnlyPart)) {
score += 40
}
if (description) {
const overlap = descriptionTokens.filter((token) => titleText.includes(token)).length
score += Math.min(overlap * 7, 28)
}
if (manufacturer) {
score += Math.min(
manufacturerTokens.filter((token) => titleText.includes(token)).length * 8,
24
)
}
if (category) {
score += Math.min(
categoryTokens.filter((token) => titleText.includes(token)).length * 5,
10
)
}
score += computeTokenOverlapScore(tokenize(part.partNumber), titleText)
score += sourceQueryBonus(listing, [
partNumber,
...descriptionTokens,
...manufacturerTokens,
...categoryTokens,
])
if (GENERIC_PART_TERMS.has(partNumber)) {
score -= 50
}
if (titleText.includes("vending") || titleText.includes("machine")) {
score += 6
}
if (listing.condition && /new|used|refurbished/i.test(listing.condition)) {
score += 2
}
return score
}
function scoreListingForQuery(query: string, listing: CachedEbayListing): number {
const queryText = normalizeText(query)
const titleText = listingSearchText(listing)
const queryTerms = tokenize(query)
let score = isListingFresh(listing) ? 10 : -6
if (!queryText) {
return -100
}
if (titleText.includes(queryText)) {
score += 70
}
score += computeTokenOverlapScore(queryTerms, titleText)
score += sourceQueryBonus(listing, queryTerms)
if (queryTerms.some((term) => titleText.includes(term))) {
score += 20
}
if (titleText.includes("vending")) {
score += 8
}
if (GENERIC_PART_TERMS.has(queryText)) {
score -= 30
}
return score
}
export function rankListingsForPart(
part: ManualPartInput,
listings: CachedEbayListing[],
limit: number
): CachedEbayListing[] {
return listings
.map((listing) => ({
listing,
score: scoreListingForPart(part, listing),
}))
.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score
}
const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0
const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
.filter((entry) => entry.score > 0)
.map((entry) => entry.listing)
}
export function rankListingsForQuery(
query: string,
listings: CachedEbayListing[],
limit: number
): CachedEbayListing[] {
return listings
.map((listing) => ({
listing,
score: scoreListingForQuery(query, listing),
}))
.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score
}
const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0
const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
.filter((entry) => entry.score > 0)
.map((entry) => entry.listing)
}
export function isEbayRateLimitError(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("10001") ||
normalized.includes("rate limit") ||
normalized.includes("exceeded the number of times") ||
normalized.includes("too many requests") ||
normalized.includes("quota")
)
}
export function buildAffiliateLink(
viewItemUrl: string,
campaignId?: string | null
): string {
const trimmedCampaignId = campaignId?.trim() || ""
if (!trimmedCampaignId) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", trimmedCampaignId)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
export function normalizeEbayItem(
item: any,
options?: {
campaignId?: string
sourceQuery?: string
fetchedAt?: number
existing?: CachedEbayListing
expiresAt?: number
}
): CachedEbayListing {
const currentPrice = item?.sellingStatus?.currentPrice
const shippingCost = item?.shippingInfo?.shippingServiceCost
const condition = item?.condition
const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || ""
const title = item?.title || "Unknown Item"
const fetchedAt = options?.fetchedAt ?? Date.now()
const existing = options?.existing
const sourceQueries = Array.from(
new Set([
...(existing?.sourceQueries || []),
...(options?.sourceQuery ? [options.sourceQuery] : []),
])
)
return {
itemId: String(item?.itemId || existing?.itemId || ""),
title,
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: item?.galleryURL || existing?.imageUrl || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || existing?.condition || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: existing?.shippingCost,
affiliateLink: buildAffiliateLink(viewItemUrl, options?.campaignId),
normalizedTitle: normalizeText(title),
sourceQueries,
firstSeenAt: existing?.firstSeenAt ?? fetchedAt,
lastSeenAt: fetchedAt,
fetchedAt,
expiresAt: options?.expiresAt ?? existing?.expiresAt ?? fetchedAt,
active: true,
}
}
export function sortListingsByFreshness(listings: CachedEbayListing[]): CachedEbayListing[] {
return [...listings].sort((a, b) => {
const aActive = a.active === false ? 0 : 1
const bActive = b.active === false ? 0 : 1
if (aActive !== bActive) {
return bActive - aActive
}
const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0
const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0
return bFreshness - aFreshness
})
}
export function estimateListingFreshness(now: number, lastSuccessfulAt?: number) {
if (!lastSuccessfulAt) {
return {
isFresh: false,
isStale: true,
freshnessMs: null as number | null,
}
}
const freshnessMs = Math.max(0, now - lastSuccessfulAt)
return {
isFresh: freshnessMs < 24 * 60 * 60 * 1000,
isStale: freshnessMs >= 24 * 60 * 60 * 1000,
freshnessMs,
}
}

View file

@ -1,92 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
hasTrustedPartsListings,
shouldShowEbayPartsPanel,
} from "@/lib/ebay-parts-visibility"
import type { EbayCacheState } from "@/lib/ebay-parts-match"
function createCacheState(
overrides: Partial<EbayCacheState> = {}
): EbayCacheState {
return {
key: "manual-parts",
status: "idle",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: null,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
...overrides,
}
}
test("hasTrustedPartsListings returns true when at least one part has listings", () => {
assert.equal(
hasTrustedPartsListings([
{ ebayListings: [] },
{ ebayListings: [{ itemId: "123" }] },
]),
true
)
})
test("shouldShowEbayPartsPanel hides panel for rate-limited cache with no listings", () => {
const result = shouldShowEbayPartsPanel({
isLoading: false,
parts: [{ ebayListings: [] }],
cache: createCacheState({
status: "rate_limited",
isStale: true,
listingCount: 0,
activeListingCount: 0,
freshnessMs: null,
lastAttemptAt: null,
lastSuccessfulAt: null,
nextEligibleAt: null,
lastError: "rate limited",
}),
error: null,
})
assert.equal(result, false)
})
test("shouldShowEbayPartsPanel shows panel while loading", () => {
const result = shouldShowEbayPartsPanel({
isLoading: true,
parts: [],
cache: null,
error: null,
})
assert.equal(result, true)
})
test("shouldShowEbayPartsPanel shows panel when trusted listings exist", () => {
const result = shouldShowEbayPartsPanel({
isLoading: false,
parts: [{ ebayListings: [{ itemId: "abc" }] }],
cache: createCacheState({
status: "success",
isStale: false,
listingCount: 12,
activeListingCount: 12,
freshnessMs: 5000,
lastAttemptAt: Date.now(),
lastSuccessfulAt: Date.now(),
nextEligibleAt: Date.now() + 1000,
lastError: null,
}),
error: null,
})
assert.equal(result, true)
})

View file

@ -1,43 +0,0 @@
import type { EbayCacheState } from "@/lib/ebay-parts-match"
type PartLike = {
ebayListings?: Array<unknown>
}
const HIDDEN_CACHE_STATUSES = new Set([
"rate_limited",
"missing_config",
"disabled",
"error",
])
export function hasTrustedPartsListings(parts: PartLike[]) {
return parts.some((part) => (part.ebayListings || []).length > 0)
}
export function shouldShowEbayPartsPanel(args: {
isLoading: boolean
parts: PartLike[]
cache: EbayCacheState | null
error: string | null
}) {
if (args.isLoading) {
return true
}
const hasListings = hasTrustedPartsListings(args.parts)
if (hasListings) {
return true
}
const status = args.cache?.status || "idle"
if (HIDDEN_CACHE_STATUSES.has(status)) {
return false
}
if (args.error) {
return false
}
return false
}

View file

@ -1,429 +0,0 @@
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
const GOOGLE_CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"
const DEFAULT_TIME_ZONE = "America/Denver"
const DEFAULT_SLOT_MINUTES = 15
const DEFAULT_START_HOUR = 8
const DEFAULT_END_HOUR = 17
const OFFERABLE_WEEKDAYS = new Set([3, 4, 5])
type LocalDateTime = {
year: number
month: number
day: number
hour: number
minute: number
second: number
weekday: number
}
type BusyInterval = {
start: number
end: number
}
function getTimeZone() {
return process.env.GOOGLE_CALENDAR_TIMEZONE || DEFAULT_TIME_ZONE
}
function getSlotMinutes() {
const value = Number.parseInt(
process.env.GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES || "",
10
)
return Number.isFinite(value) && value > 0 ? value : DEFAULT_SLOT_MINUTES
}
function getCallbackHours() {
const startHour = Number.parseInt(
process.env.GOOGLE_CALENDAR_CALLBACK_START_HOUR || "",
10
)
const endHour = Number.parseInt(
process.env.GOOGLE_CALENDAR_CALLBACK_END_HOUR || "",
10
)
return {
startHour:
Number.isFinite(startHour) && startHour >= 0 && startHour <= 23
? startHour
: DEFAULT_START_HOUR,
endHour:
Number.isFinite(endHour) && endHour >= 1 && endHour <= 24
? endHour
: DEFAULT_END_HOUR,
}
}
function getRequiredConfig() {
const clientId = String(process.env.GOOGLE_CALENDAR_CLIENT_ID || "").trim()
const clientSecret = String(
process.env.GOOGLE_CALENDAR_CLIENT_SECRET || ""
).trim()
const refreshToken = String(
process.env.GOOGLE_CALENDAR_REFRESH_TOKEN || ""
).trim()
const calendarId = String(process.env.GOOGLE_CALENDAR_ID || "").trim()
const missing = [
!clientId ? "GOOGLE_CALENDAR_CLIENT_ID" : null,
!clientSecret ? "GOOGLE_CALENDAR_CLIENT_SECRET" : null,
!refreshToken ? "GOOGLE_CALENDAR_REFRESH_TOKEN" : null,
!calendarId ? "GOOGLE_CALENDAR_ID" : null,
].filter(Boolean)
if (missing.length > 0) {
throw new Error(`${missing.join(", ")} is not configured.`)
}
return {
clientId,
clientSecret,
refreshToken,
calendarId,
}
}
export function isGoogleCalendarConfigured() {
try {
getRequiredConfig()
return true
} catch {
return false
}
}
function getLocalDateTime(date: Date, timeZone = getTimeZone()): LocalDateTime {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
})
const parts = formatter.formatToParts(date)
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]))
const weekdayMap: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
}
return {
year: Number.parseInt(values.year || "0", 10),
month: Number.parseInt(values.month || "0", 10),
day: Number.parseInt(values.day || "0", 10),
hour: Number.parseInt(values.hour || "0", 10),
minute: Number.parseInt(values.minute || "0", 10),
second: Number.parseInt(values.second || "0", 10),
weekday: weekdayMap[values.weekday || "Sun"] ?? 0,
}
}
function getTimeZoneOffsetMs(date: Date, timeZone = getTimeZone()) {
const parts = getLocalDateTime(date, timeZone)
const asUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
)
return asUtc - date.getTime()
}
function zonedDateTimeToUtc(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second = 0,
timeZone = getTimeZone()
) {
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
const offset = getTimeZoneOffsetMs(utcGuess, timeZone)
return new Date(utcGuess.getTime() - offset)
}
function addDaysLocal(date: LocalDateTime, days: number) {
const utcMidnight = Date.UTC(date.year, date.month - 1, date.day)
const next = new Date(utcMidnight + days * 24 * 60 * 60 * 1000)
return {
year: next.getUTCFullYear(),
month: next.getUTCMonth() + 1,
day: next.getUTCDate(),
}
}
function roundUpToSlot(date: Date, slotMinutes = getSlotMinutes()) {
const rounded = new Date(date.getTime())
rounded.setUTCSeconds(0, 0)
const intervalMs = slotMinutes * 60 * 1000
const remainder = rounded.getTime() % intervalMs
if (remainder !== 0) {
rounded.setTime(rounded.getTime() + (intervalMs - remainder))
}
return rounded
}
function formatSlotLabel(startAt: Date, endAt: Date, timeZone = getTimeZone()) {
const startFormatter = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
const endFormatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "numeric",
minute: "2-digit",
})
return `${startFormatter.format(startAt)} - ${endFormatter.format(endAt)}`
}
async function getGoogleAccessToken() {
const config = getRequiredConfig()
const body = new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
refresh_token: config.refreshToken,
grant_type: "refresh_token",
})
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
})
const data = (await response.json().catch(() => ({}))) as {
access_token?: string
error?: string
error_description?: string
}
if (!response.ok || !data.access_token) {
throw new Error(
data.error_description ||
data.error ||
"Failed to authenticate with Google Calendar."
)
}
return {
accessToken: data.access_token,
calendarId: config.calendarId,
}
}
async function fetchBusyIntervals(startAt: Date, endAt: Date) {
const { accessToken, calendarId } = await getGoogleAccessToken()
const response = await fetch(`${GOOGLE_CALENDAR_API_BASE}/freeBusy`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
timeMin: startAt.toISOString(),
timeMax: endAt.toISOString(),
timeZone: getTimeZone(),
items: [{ id: calendarId }],
}),
})
const data = (await response.json().catch(() => ({}))) as {
calendars?: Record<
string,
{
busy?: Array<{ start: string; end: string }>
}
>
error?: {
message?: string
}
}
if (!response.ok) {
throw new Error(
data.error?.message || "Failed to fetch Google Calendar availability."
)
}
return (data.calendars?.[calendarId]?.busy || [])
.map((entry) => ({
start: new Date(entry.start).getTime(),
end: new Date(entry.end).getTime(),
}))
.filter((entry) => Number.isFinite(entry.start) && Number.isFinite(entry.end))
}
function overlapsBusyWindow(
startAt: Date,
endAt: Date,
busyIntervals: BusyInterval[]
) {
const start = startAt.getTime()
const end = endAt.getTime()
return busyIntervals.some((busy) => start < busy.end && end > busy.start)
}
export async function listFutureCallbackSlots(limit = 3) {
const timeZone = getTimeZone()
const slotMinutes = getSlotMinutes()
const { startHour, endHour } = getCallbackHours()
const now = new Date()
const nowLocal = getLocalDateTime(now, timeZone)
const tomorrow = addDaysLocal(nowLocal, 1)
const searchStart = zonedDateTimeToUtc(
tomorrow.year,
tomorrow.month,
tomorrow.day,
0,
0,
0,
timeZone
)
const searchEnd = new Date(searchStart.getTime() + 21 * 24 * 60 * 60 * 1000)
const busyIntervals = await fetchBusyIntervals(searchStart, searchEnd)
const slots: Array<{
startAt: string
endAt: string
displayLabel: string
dayLabel: string
}> = []
for (let offset = 1; offset <= 21 && slots.length < limit; offset += 1) {
const day = addDaysLocal(nowLocal, offset)
const dayMarker = zonedDateTimeToUtc(
day.year,
day.month,
day.day,
12,
0,
0,
timeZone
)
const weekday = getLocalDateTime(dayMarker, timeZone).weekday
if (!OFFERABLE_WEEKDAYS.has(weekday)) {
continue
}
for (
let minuteOffset = 0;
minuteOffset < (endHour - startHour) * 60 && slots.length < limit;
minuteOffset += slotMinutes
) {
const hour = startHour + Math.floor(minuteOffset / 60)
const minute = minuteOffset % 60
const slotStart = zonedDateTimeToUtc(
day.year,
day.month,
day.day,
hour,
minute,
0,
timeZone
)
const slotEnd = new Date(slotStart.getTime() + slotMinutes * 60 * 1000)
if (slotStart.getTime() <= now.getTime()) {
continue
}
if (overlapsBusyWindow(slotStart, slotEnd, busyIntervals)) {
continue
}
slots.push({
startAt: slotStart.toISOString(),
endAt: slotEnd.toISOString(),
displayLabel: formatSlotLabel(slotStart, slotEnd, timeZone),
dayLabel: formatSlotLabel(slotStart, slotEnd, timeZone).split(" - ")[0],
})
}
}
return slots
}
export async function createFollowupReminderEvent(args: {
title: string
description: string
startAt: Date
endAt: Date
}) {
const { accessToken, calendarId } = await getGoogleAccessToken()
const timeZone = getTimeZone()
const response = await fetch(
`${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
summary: args.title,
description: args.description,
start: {
dateTime: args.startAt.toISOString(),
timeZone,
},
end: {
dateTime: args.endAt.toISOString(),
timeZone,
},
}),
}
)
const data = (await response.json().catch(() => ({}))) as {
id?: string
htmlLink?: string
error?: {
message?: string
}
}
if (!response.ok || !data.id) {
throw new Error(
data.error?.message || "Failed to create the Google Calendar reminder."
)
}
return {
eventId: data.id,
htmlLink: data.htmlLink || "",
}
}
export function buildSameDayReminderWindow() {
const slotMinutes = getSlotMinutes()
const startAt = roundUpToSlot(new Date(), slotMinutes)
const endAt = new Date(startAt.getTime() + slotMinutes * 60 * 1000)
return {
startAt,
endAt,
}
}

View file

@ -1,70 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
isPrivateOrLocalHost,
normalizeManualAssetValue,
} from "@/lib/manuals-asset-paths"
test("normalizeManualAssetValue keeps relative paths relative", () => {
assert.equal(
normalizeManualAssetValue("Royal-Vendors/foo-manual.pdf", "manual"),
"Royal-Vendors/foo-manual.pdf"
)
assert.equal(
normalizeManualAssetValue("Royal-Vendors/foo-thumb.jpg", "thumbnail"),
"Royal-Vendors/foo-thumb.jpg"
)
})
test("normalizeManualAssetValue normalizes site-proxy absolute URLs to relative paths", () => {
assert.equal(
normalizeManualAssetValue(
"https://cdn.example.com/manuals/vendor/foo-manual.pdf",
"manual"
),
"vendor/foo-manual.pdf"
)
assert.equal(
normalizeManualAssetValue(
"https://files.example.com/vendor/foo-manual.pdf",
"manual"
),
"https://files.example.com/vendor/foo-manual.pdf"
)
})
test("normalizeManualAssetValue rewrites localhost and private hosts to relative paths", () => {
assert.equal(
normalizeManualAssetValue(
"http://localhost:3000/api/thumbnails/Royal-Vendors/foo-thumb.jpg",
"thumbnail"
),
"Royal-Vendors/foo-thumb.jpg"
)
assert.equal(
normalizeManualAssetValue(
"http://127.0.0.1:3000/api/manuals/Royal-Vendors/foo-manual.pdf",
"manual"
),
"Royal-Vendors/foo-manual.pdf"
)
assert.equal(
normalizeManualAssetValue(
"http://10.1.2.3:3000/api/thumbnails/Royal-Vendors/foo-thumb.jpg",
"thumbnail"
),
"Royal-Vendors/foo-thumb.jpg"
)
})
test("isPrivateOrLocalHost identifies local/private hosts", () => {
assert.equal(isPrivateOrLocalHost("localhost"), true)
assert.equal(isPrivateOrLocalHost("127.0.0.1"), true)
assert.equal(isPrivateOrLocalHost("10.0.0.9"), true)
assert.equal(isPrivateOrLocalHost("192.168.1.20"), true)
assert.equal(isPrivateOrLocalHost("172.18.5.4"), true)
assert.equal(isPrivateOrLocalHost("cdn.example.com"), false)
})

View file

@ -1,127 +0,0 @@
export type ManualAssetKind = "manual" | "thumbnail"
function safeDecodeSegment(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function decodePath(pathname: string) {
return pathname
.split("/")
.map((segment) => safeDecodeSegment(segment))
.join("/")
}
function stripLeadingSlash(value: string) {
return value.replace(/^\/+/, "")
}
function stripAssetPrefix(value: string, kind: ManualAssetKind) {
if (kind === "manual") {
return value
.replace(/^api\/manuals\//i, "")
.replace(/^manuals\//i, "")
.replace(/^\/api\/manuals\//i, "")
.replace(/^\/manuals\//i, "")
}
return value
.replace(/^api\/thumbnails\//i, "")
.replace(/^thumbnails\//i, "")
.replace(/^\/api\/thumbnails\//i, "")
.replace(/^\/thumbnails\//i, "")
}
function looksLikePrivateHost(hostname: string) {
const host = hostname.trim().toLowerCase()
if (!host) {
return true
}
if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
return true
}
if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) {
return true
}
const match172 = host.match(/^172\.(\d{1,3})\./)
if (match172) {
const octet = Number.parseInt(match172[1] || "", 10)
if (Number.isFinite(octet) && octet >= 16 && octet <= 31) {
return true
}
}
return false
}
function normalizeRelativePath(value: string, kind: ManualAssetKind) {
const decoded = decodePath(stripLeadingSlash(value.trim()))
const withoutPrefix = stripAssetPrefix(decoded, kind).trim()
if (!withoutPrefix) {
return null
}
if (withoutPrefix.includes("..")) {
return null
}
return withoutPrefix
}
function extractRelativePathFromAbsolute(url: URL, kind: ManualAssetKind) {
const decodedPath = decodePath(url.pathname)
return normalizeRelativePath(decodedPath, kind)
}
export function isPrivateOrLocalHost(hostname: string) {
return looksLikePrivateHost(hostname)
}
export function normalizeManualAssetValue(
value: string | null | undefined,
kind: ManualAssetKind
): string | null {
const raw = String(value || "").trim()
if (!raw) {
return null
}
if (!/^https?:\/\//i.test(raw)) {
return normalizeRelativePath(raw, kind)
}
try {
const parsed = new URL(raw)
const extractedRelativePath = extractRelativePathFromAbsolute(parsed, kind)
if (looksLikePrivateHost(parsed.hostname)) {
return extractedRelativePath
}
// Normalize site-proxy URLs to relative paths to keep persisted values tenant-safe.
if (extractedRelativePath) {
const lowerPath = parsed.pathname.toLowerCase()
if (
lowerPath.includes("/api/manuals/") ||
lowerPath.includes("/api/thumbnails/") ||
lowerPath.includes("/manuals/") ||
lowerPath.includes("/thumbnails/")
) {
return extractedRelativePath
}
}
return raw
} catch {
return null
}
}
export function isAbsoluteHttpUrl(value: string) {
return /^https?:\/\//i.test(value.trim())
}

View file

@ -1,35 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import type { Manual } from "@/lib/manuals-types"
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
function buildManual(overrides: Partial<Manual> = {}): Manual {
return {
filename: "test-manual.pdf",
path: "Test/test-manual.pdf",
manufacturer: "Test",
category: "Test",
...overrides,
}
}
test("sanitizeManualThumbnailsForRuntime keeps relative thumbnails in production", () => {
const manual = buildManual({ thumbnailUrl: "Test/test-manual.jpg" })
const result = sanitizeManualThumbnailsForRuntime([manual], {
isLocalDevelopment: false,
thumbnailsRoot: "/tmp/manuals-thumbnails",
})
assert.equal(result[0]?.thumbnailUrl, "Test/test-manual.jpg")
})
test("sanitizeManualThumbnailsForRuntime strips missing relative thumbnails in local development", () => {
const manual = buildManual({ thumbnailUrl: "Test/missing-thumb.jpg" })
const result = sanitizeManualThumbnailsForRuntime([manual], {
isLocalDevelopment: true,
thumbnailsRoot: "/tmp/manuals-thumbnails",
fileExists: () => false,
})
assert.equal(result[0]?.thumbnailUrl, undefined)
})

View file

@ -1,33 +0,0 @@
import { join } from "node:path"
import type { Manual } from "@/lib/manuals-types"
type ThumbnailSanitizeOptions = {
isLocalDevelopment: boolean
thumbnailsRoot: string
fileExists?: (path: string) => boolean
}
export function sanitizeManualThumbnailsForRuntime(
manuals: Manual[],
options: ThumbnailSanitizeOptions
): Manual[] {
if (!options.isLocalDevelopment) {
return manuals
}
const fileExists = options.fileExists ?? (() => false)
return manuals.map((manual) => {
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
return manual
}
const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/")
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "")
: manual.thumbnailUrl
return fileExists(join(options.thumbnailsRoot, relativeThumbnailPath))
? manual
: { ...manual, thumbnailUrl: undefined }
})
}

View file

@ -1,90 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
canonicalizeTenantDomain,
manualVisibleForTenant,
resolveManualsTenantDomain,
tenantDomainVariants,
} from "@/lib/manuals-tenant"
test("canonicalizeTenantDomain strips protocol, port, path, and casing", () => {
assert.equal(
canonicalizeTenantDomain("HTTPS://RMV.AbundancePartners.App:443/manuals"),
"rmv.abundancepartners.app"
)
assert.equal(canonicalizeTenantDomain(""), "")
assert.equal(canonicalizeTenantDomain(undefined), "")
})
test("tenantDomainVariants includes root and www aliases", () => {
assert.deepEqual(tenantDomainVariants("rmv.abundancepartners.app"), [
"rmv.abundancepartners.app",
"www.rmv.abundancepartners.app",
])
assert.deepEqual(tenantDomainVariants("www.rockymountainvending.com"), [
"www.rockymountainvending.com",
"rockymountainvending.com",
])
})
test("manualVisibleForTenant matches sourceDomain and siteVisibility aliases", () => {
assert.equal(
manualVisibleForTenant(
{
sourceDomain: "rmv.abundancepartners.app",
},
"https://rmv.abundancepartners.app/manuals"
),
true
)
assert.equal(
manualVisibleForTenant(
{
siteVisibility: ["www.rockymountainvending.com"],
},
"rockymountainvending.com"
),
true
)
assert.equal(
manualVisibleForTenant(
{
sourceDomain: "quickfreshvending.com",
siteVisibility: ["quickfreshvending.com"],
},
"rmv.abundancepartners.app"
),
false
)
})
test("resolveManualsTenantDomain prioritizes request host then env overrides", () => {
assert.equal(
resolveManualsTenantDomain({
requestHost: "rmv.abundancepartners.app",
envTenantDomain: "fallback.example",
envSiteDomain: "another.example",
}),
"rmv.abundancepartners.app"
)
assert.equal(
resolveManualsTenantDomain({
requestHost: "",
envTenantDomain: "tenant.example",
envSiteDomain: "site.example",
}),
"tenant.example"
)
assert.equal(
resolveManualsTenantDomain({
requestHost: "",
envTenantDomain: "",
envSiteDomain: "site.example",
}),
"site.example"
)
})

View file

@ -1,79 +0,0 @@
export type TenantScopedManual = {
sourceDomain?: string
siteVisibility?: string[]
}
function stripProtocolAndPath(input: string) {
const firstHost = input.split(",")[0] || ""
return firstHost
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^\/\//, "")
.replace(/\/.*$/, "")
.replace(/:\d+$/, "")
.replace(/\.$/, "")
}
export function canonicalizeTenantDomain(
input: string | null | undefined
): string {
if (!input) {
return ""
}
return stripProtocolAndPath(input)
}
export function tenantDomainVariants(domain: string): string[] {
const canonical = canonicalizeTenantDomain(domain)
if (!canonical) {
return []
}
if (canonical.startsWith("www.")) {
return [canonical, canonical.replace(/^www\./, "")]
}
return [canonical, `www.${canonical}`]
}
export function manualVisibleForTenant(
manual: TenantScopedManual,
domain: string
): boolean {
const variants = new Set(tenantDomainVariants(domain))
if (variants.size === 0) {
return false
}
const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain)
if (sourceDomain && variants.has(sourceDomain)) {
return true
}
const visibility = Array.isArray(manual.siteVisibility)
? manual.siteVisibility
.map((entry) => canonicalizeTenantDomain(entry))
.filter(Boolean)
: []
if (visibility.some((entry) => variants.has(entry))) {
return true
}
return false
}
export function resolveManualsTenantDomain(params: {
requestHost?: string | null
envSiteDomain?: string | null
envTenantDomain?: string | null
}) {
return (
canonicalizeTenantDomain(params.requestHost) ||
canonicalizeTenantDomain(params.envTenantDomain) ||
canonicalizeTenantDomain(params.envSiteDomain)
)
}

View file

@ -1,24 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { deriveThumbnailPathFromManualPath } from "@/lib/manuals-thumbnail-fallback"
test("deriveThumbnailPathFromManualPath derives jpg path from relative manual path", () => {
assert.equal(
deriveThumbnailPathFromManualPath("Royal-Vendors/vender-3.pdf"),
"Royal-Vendors/vender-3.jpg"
)
})
test("deriveThumbnailPathFromManualPath returns undefined for absolute URLs", () => {
assert.equal(
deriveThumbnailPathFromManualPath("https://example.com/manuals/file.pdf"),
undefined
)
})
test("deriveThumbnailPathFromManualPath returns undefined for non-pdf paths", () => {
assert.equal(
deriveThumbnailPathFromManualPath("Royal-Vendors/not-a-pdf.txt"),
undefined
)
})

View file

@ -1,14 +0,0 @@
export function deriveThumbnailPathFromManualPath(
manualPath: string | undefined | null
): string | undefined {
const trimmedPath = String(manualPath || "").trim()
if (!trimmedPath || /^https?:\/\//i.test(trimmedPath)) {
return undefined
}
if (!trimmedPath.toLowerCase().endsWith(".pdf")) {
return undefined
}
return trimmedPath.replace(/\.pdf$/i, ".jpg")
}

View file

@ -2,24 +2,24 @@
* Parts lookup utility for frontend
*
* Provides functions to fetch parts data by manual filename.
* Static JSON remains the primary data source, while cached eBay matches
* are fetched from the server so normal browsing never reaches eBay.
* Static JSON remains the primary data source, while live eBay fallback
* goes through the server route so credentials never reach the browser.
*/
import type {
CachedEbayListing,
EbayCacheState,
ManualPartInput,
} from "@/lib/ebay-parts-match"
import {
filterTrustedEbayListings,
isSyntheticEbayListing,
} from "@/lib/ebay-parts-match"
export interface PartForPage {
partNumber: string
description: string
ebayListings: CachedEbayListing[]
ebayListings: Array<{
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}>
}
interface ManualPartsLookup {
@ -32,40 +32,30 @@ interface ManualPagesParts {
}
}
interface CachedPartsResponse {
manualFilename: string
parts: Array<
ManualPartInput & {
ebayListings: CachedEbayListing[]
}
>
cache: EbayCacheState
cacheSource?: "convex" | "fallback"
interface EbaySearchResult {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}
interface EbaySearchResponse {
results: EbaySearchResult[]
error?: string
}
interface CachedEbaySearchResponse {
results: CachedEbayListing[]
cache: EbayCacheState
cacheSource?: "convex" | "fallback"
error?: string
}
const cachedManualMatchResponses = new Map<
// Cache for eBay search results
const ebaySearchCache = new Map<
string,
{ response: CachedPartsResponse; timestamp: number }
{ results: EbaySearchResult[]; timestamp: number }
>()
const inFlightManualMatchRequests = new Map<string, Promise<CachedPartsResponse>>()
const MANUAL_MATCH_CACHE_TTL = 5 * 60 * 1000
const cachedEbaySearchResponses = new Map<
string,
{ response: CachedEbaySearchResponse; timestamp: number }
>()
const inFlightEbaySearches = new Map<
string,
Promise<CachedEbaySearchResponse>
>()
const EBAY_SEARCH_CACHE_TTL = 5 * 60 * 1000
const inFlightEbaySearches = new Map<string, Promise<EbaySearchResponse>>()
const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
const GENERIC_PART_TERMS = new Set([
"and",
@ -139,196 +129,121 @@ async function loadPartsData(): Promise<{
}
}
function makeFallbackCacheState(errorMessage?: string): EbayCacheState {
return {
key: "manual-parts",
status: "disabled",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: errorMessage || "eBay cache unavailable.",
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message: errorMessage || "eBay cache unavailable.",
}
}
async function fetchManualPartsMatches(
manualFilename: string,
parts: ManualPartInput[],
limit: number
): Promise<CachedPartsResponse> {
const cacheKey = [
manualFilename.trim().toLowerCase(),
String(limit),
parts
.map((part) =>
[
part.partNumber.trim().toLowerCase(),
part.description.trim().toLowerCase(),
part.manufacturer?.trim().toLowerCase() || "",
part.category?.trim().toLowerCase() || "",
].join(":")
)
.join("|"),
].join("::")
const cached = cachedManualMatchResponses.get(cacheKey)
if (cached && Date.now() - cached.timestamp < MANUAL_MATCH_CACHE_TTL) {
return cached.response
}
const inFlight = inFlightManualMatchRequests.get(cacheKey)
if (inFlight) {
return inFlight
}
const request = (async () => {
try {
const response = await fetch("/api/ebay/manual-parts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
manualFilename,
parts,
limit,
}),
})
const body = await response.json().catch(() => null)
if (!response.ok || !body || typeof body !== "object") {
const message =
body && typeof body.error === "string"
? body.error
: `Failed to load cached parts (${response.status})`
return {
manualFilename,
parts: parts.map((part) => ({
...part,
ebayListings: [],
})),
cache: makeFallbackCacheState(message),
error: message,
}
}
const partsResponse = body as CachedPartsResponse
return {
manualFilename: partsResponse.manualFilename || manualFilename,
parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [],
cache: partsResponse.cache || makeFallbackCacheState(),
error:
typeof (partsResponse as CachedPartsResponse).error === "string"
? (partsResponse as CachedPartsResponse).error
: undefined,
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load cached parts"
return {
manualFilename,
parts: parts.map((part) => ({
...part,
ebayListings: [],
})),
cache: makeFallbackCacheState(message),
error: message,
}
}
})()
inFlightManualMatchRequests.set(cacheKey, request)
try {
const response = await request
cachedManualMatchResponses.set(cacheKey, {
response,
timestamp: Date.now(),
})
return response
} finally {
inFlightManualMatchRequests.delete(cacheKey)
}
}
/**
* Search eBay for parts with caching.
* This calls the server route so the app never needs direct eBay credentials
* in client code.
*/
async function searchEBayForParts(
partNumber: string,
description?: string,
manufacturer?: string
): Promise<CachedEbaySearchResponse> {
): Promise<EbaySearchResponse> {
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
const cached = cachedEbaySearchResponses.get(cacheKey)
if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) {
return cached.response
// Check cache
const cached = ebaySearchCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
return { results: cached.results as EbaySearchResult[] }
}
const inFlight = inFlightEbaySearches.get(cacheKey)
if (inFlight) {
return inFlight
}
const buildQuery = () => {
let query = partNumber
const request = (async () => {
try {
const params = new URLSearchParams({
keywords: [partNumber, description, manufacturer, "vending machine"]
.filter(Boolean)
.join(" "),
maxResults: "3",
sortOrder: "BestMatch",
})
if (description && description.length > 0 && description.length < 50) {
const descWords = description
.split(/\s+/)
.filter((word) => word.length > 3)
.slice(0, 3)
.join(" ")
const response = await fetch(`/api/ebay/search?${params.toString()}`)
const body = await response.json().catch(() => null)
if (!response.ok || !body || typeof body !== "object") {
const message =
body && typeof body.error === "string"
? body.error
: `Failed to load cached eBay listings (${response.status})`
return {
results: [],
cache: makeFallbackCacheState(message),
error: message,
}
}
return {
results: Array.isArray((body as any).results)
? ((body as any).results as CachedEbayListing[])
: [],
cache: (body as any).cache || makeFallbackCacheState(),
error:
typeof (body as any).error === "string" ? (body as any).error : undefined,
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load cached eBay listings"
return {
results: [],
cache: makeFallbackCacheState(message),
error: message,
if (descWords) {
query += ` ${descWords}`
}
}
})()
inFlightEbaySearches.set(cacheKey, request)
try {
const response = await request
cachedEbaySearchResponses.set(cacheKey, {
response,
if (manufacturer) {
query += ` ${manufacturer}`
}
return `${query} vending machine`
}
const searchViaApi = async (
categoryId?: string
): Promise<EbaySearchResponse> => {
const requestKey = `${cacheKey}:${categoryId || "general"}`
const inFlight = inFlightEbaySearches.get(requestKey)
if (inFlight) {
return inFlight
}
const params = new URLSearchParams({
keywords: buildQuery(),
maxResults: "3",
sortOrder: "BestMatch",
})
if (categoryId) {
params.set("categoryId", categoryId)
}
const request = (async () => {
try {
const response = await fetch(`/api/ebay/search?${params.toString()}`)
const body = await response.json().catch(() => null)
if (!response.ok) {
const message =
body && typeof body.error === "string"
? body.error
: `eBay API error: ${response.status}`
return { results: [], error: message }
}
const results = Array.isArray(body) ? body : []
return { results }
} catch (error) {
return {
results: [],
error:
error instanceof Error ? error.message : "Failed to search eBay",
}
}
})()
inFlightEbaySearches.set(requestKey, request)
try {
return await request
} finally {
inFlightEbaySearches.delete(requestKey)
}
}
const categorySearch = await searchViaApi("11700")
if (categorySearch.results.length > 0) {
ebaySearchCache.set(cacheKey, {
results: categorySearch.results,
timestamp: Date.now(),
})
return response
} finally {
inFlightEbaySearches.delete(cacheKey)
return categorySearch
}
const generalSearch = await searchViaApi()
if (generalSearch.results.length > 0) {
ebaySearchCache.set(cacheKey, {
results: generalSearch.results,
timestamp: Date.now(),
})
return generalSearch
}
return {
results: [],
error: categorySearch.error || generalSearch.error,
}
}
@ -336,6 +251,20 @@ function normalizePartText(value: string): string {
return value.trim().toLowerCase()
}
function isSyntheticEbayListing(
listing: PartForPage["ebayListings"][number]
): boolean {
const itemId = listing.itemId?.trim() || ""
const viewItemUrl = listing.viewItemUrl?.trim() || ""
const imageUrl = listing.imageUrl?.trim() || ""
return (
imageUrl.includes("images.unsplash.com") ||
viewItemUrl.includes("123456789") ||
itemId.startsWith("123456789")
)
}
function hasLiveEbayListings(listings: PartForPage["ebayListings"]): boolean {
return listings.some((listing) => !isSyntheticEbayListing(listing))
}
@ -533,13 +462,6 @@ async function getPartsForManualWithStatus(manualFilename: string): Promise<{
return { parts }
}
function sanitizePartListings(parts: PartForPage[]): PartForPage[] {
return parts.map((part) => ({
...part,
ebayListings: filterTrustedEbayListings(part.ebayListings || []),
}))
}
/**
* Get all parts for a manual with enhanced eBay data
*/
@ -547,7 +469,7 @@ export async function getPartsForManual(
manualFilename: string
): Promise<PartForPage[]> {
const result = await getPartsForManualWithStatus(manualFilename)
return sanitizePartListings(result.parts)
return result.parts
}
/**
@ -568,17 +490,8 @@ export async function getPartsForPage(
return []
}
const matched = await fetchManualPartsMatches(
manualFilename,
parts.map((part) => ({
partNumber: part.partNumber,
description: part.description,
manualFilename,
})),
Math.max(parts.length, 1)
)
return sanitizePartListings(matched.parts as PartForPage[])
const enhanced = await enhancePartsData(parts)
return enhanced.parts
}
/**
@ -590,7 +503,6 @@ export async function getTopPartsForManual(
): Promise<{
parts: PartForPage[]
error?: string
cache?: EbayCacheState
}> {
const { parts } = await getPartsForManualWithStatus(manualFilename)
@ -602,20 +514,23 @@ export async function getTopPartsForManual(
parts,
Math.max(limit * 2, limit)
)
const matched = await fetchManualPartsMatches(
manualFilename,
liveSearchCandidates.map((part) => ({
partNumber: part.partNumber,
description: part.description,
manualFilename,
})),
limit
)
const { parts: enrichedParts, error } =
await enhancePartsData(liveSearchCandidates)
const sorted = enrichedParts.sort((a, b) => {
const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0
const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0
if (aHasLiveListings !== bHasLiveListings) {
return bHasLiveListings - aHasLiveListings
}
return b.ebayListings.length - a.ebayListings.length
})
return {
parts: sanitizePartListings(matched.parts as PartForPage[]),
error: matched.error,
cache: matched.cache,
parts: sorted.slice(0, limit),
error,
}
}

View file

@ -15,13 +15,9 @@ export type AdminPhoneCallDetail = {
id: string
roomName: string
participantIdentity: string
callerPhone?: string
pathname?: string
pageUrl?: string
source?: string
contactProfileId?: string
contactDisplayName?: string
contactCompany?: string
startedAt: number
endedAt?: number
durationMs: number | null
@ -37,18 +33,6 @@ export type AdminPhoneCallDetail = {
notificationStatus: "pending" | "sent" | "failed" | "disabled"
notificationSentAt?: number
notificationError?: string
reminderStatus?: "none" | "scheduled" | "sameDay"
reminderRequestedAt?: number
reminderStartAt?: number
reminderEndAt?: number
reminderCalendarEventId?: string
reminderCalendarHtmlLink?: string
reminderNote?: string
warmTransferStatus?: "none" | "attempted" | "connected" | "failed" | "fallback"
warmTransferTarget?: string
warmTransferAttemptedAt?: number
warmTransferConnectedAt?: number
warmTransferFailureReason?: string
recordingStatus?:
| "pending"
| "starting"
@ -71,21 +55,6 @@ export type AdminPhoneCallDetail = {
message?: string
createdAt: number
}
contactProfile: null | {
_id: string
normalizedPhone: string
displayName?: string
firstName?: string
lastName?: string
email?: string
company?: string
lastIntent?: string
lastLeadOutcome?: "none" | "contact" | "requestMachine"
lastSummaryText?: string
lastCallAt?: number
lastReminderAt?: number
reminderNotes?: string
}
turns: AdminPhoneCallTurn[]
}
@ -152,39 +121,20 @@ export function buildPhoneCallSummary(
""
const callerNumber =
detail.call.callerPhone ||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity
const parts = [
`Caller: ${detail.call.contactDisplayName || callerNumber || "Unknown caller"}.`,
`Caller: ${callerNumber || "Unknown caller"}.`,
answeredLabel,
leadLabel,
]
if (detail.call.contactCompany) {
parts.push(`Company: ${detail.call.contactCompany}.`)
}
if (detail.call.handoffRequested) {
parts.push(
`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`
)
}
if (detail.call.reminderStatus === "sameDay") {
parts.push("A same-day follow-up reminder was created for Matt.")
} else if (detail.call.reminderStatus === "scheduled") {
parts.push(
`A follow-up reminder was scheduled for ${formatPhoneCallTimestamp(detail.call.reminderStartAt)}.`
)
}
if (detail.call.warmTransferStatus && detail.call.warmTransferStatus !== "none") {
parts.push(
`Warm transfer status: ${detail.call.warmTransferStatus}${detail.call.warmTransferFailureReason ? ` (${detail.call.warmTransferFailureReason})` : ""}.`
)
}
if (leadMessage) {
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`)
}
@ -219,10 +169,8 @@ export async function sendPhoneCallSummaryEmail(args: {
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`
const summaryText = buildPhoneCallSummary(args.detail)
const callerNumber =
args.detail.call.callerPhone ||
normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
"Unknown caller"
const callerLabel = args.detail.call.contactDisplayName || callerNumber
const statusLabel = args.detail.call.callStatus.toUpperCase()
const transcriptHtml = args.detail.turns
@ -241,24 +189,13 @@ export async function sendPhoneCallSummaryEmail(args: {
const html = `
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.6;">
<h1 style="font-size: 20px; margin-bottom: 16px;">Rocky Mountain Vending phone call summary</h1>
<p><strong>Caller:</strong> ${callerLabel}</p>
<p><strong>Caller number:</strong> ${callerNumber}</p>
<p><strong>Company:</strong> ${args.detail.call.contactCompany || "Unknown"}</p>
<p><strong>Caller:</strong> ${callerNumber}</p>
<p><strong>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p>
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
<p><strong>Call status:</strong> ${statusLabel}</p>
<p><strong>Jessica answered:</strong> ${args.detail.call.answered ? "Yes" : "No"}</p>
<p><strong>Lead outcome:</strong> ${args.detail.call.leadOutcome}</p>
<p><strong>Handoff requested:</strong> ${args.detail.call.handoffRequested ? "Yes" : "No"}</p>
<p><strong>Reminder status:</strong> ${args.detail.call.reminderStatus || "none"}</p>
<p><strong>Reminder time:</strong> ${formatPhoneCallTimestamp(args.detail.call.reminderStartAt)}</p>
<p><strong>Reminder link:</strong> ${
args.detail.call.reminderCalendarHtmlLink
? `<a href="${args.detail.call.reminderCalendarHtmlLink}">${args.detail.call.reminderCalendarHtmlLink}</a>`
: "No reminder link"
}</p>
<p><strong>Warm transfer:</strong> ${args.detail.call.warmTransferStatus || "none"}</p>
<p><strong>Warm transfer details:</strong> ${args.detail.call.warmTransferFailureReason || "—"}</p>
<p><strong>Recording status:</strong> ${args.detail.call.recordingStatus || "Unavailable"}</p>
<p><strong>Recording URL:</strong> ${
args.detail.call.recordingUrl
@ -277,7 +214,7 @@ export async function sendPhoneCallSummaryEmail(args: {
await sendTransactionalEmail({
from: fromEmail,
to: adminEmail,
subject: `[RMV Phone] ${statusLabel} call from ${callerLabel}`,
subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`,
html,
})

View file

@ -1,41 +0,0 @@
export function normalizePhoneE164(input?: string | null) {
const digits = String(input || "").replace(/\D/g, "")
if (!digits) {
return ""
}
if (digits.length === 10) {
return `+1${digits}`
}
if (digits.length === 11 && digits.startsWith("1")) {
return `+${digits}`
}
if (digits.length >= 11) {
return `+${digits}`
}
return ""
}
export function splitDisplayName(name?: string | null) {
const trimmed = String(name || "").trim()
if (!trimmed) {
return {
firstName: "",
lastName: "",
displayName: "",
}
}
const parts = trimmed.split(/\s+/)
const firstName = parts.shift() || ""
const lastName = parts.join(" ")
return {
firstName,
lastName,
displayName: [firstName, lastName].filter(Boolean).join(" "),
}
}

View file

@ -1,12 +1,4 @@
import { createHash, randomBytes } from "node:crypto"
import { cookies } from "next/headers"
import { fetchMutation, fetchQuery } from "convex/nextjs"
import { NextResponse } from "next/server"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
export const ADMIN_SESSION_COOKIE = "rmv_admin_session"
const ADMIN_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7
function getProvidedToken(request: Request) {
const authHeader = request.headers.get("authorization") || ""
@ -38,123 +30,3 @@ export function requireAdminToken(request: Request) {
export function isAdminUiEnabled() {
return process.env.ADMIN_UI_ENABLED === "true"
}
export function getConfiguredAdminEmail() {
return String(process.env.ADMIN_EMAIL || "")
.trim()
.toLowerCase()
}
function getConfiguredAdminPassword() {
return String(process.env.ADMIN_PASSWORD || "")
}
function hashAdminSessionToken(token: string) {
return createHash("sha256").update(token).digest("hex")
}
function readCookieFromHeader(cookieHeader: string, name: string) {
const cookies = cookieHeader.split(";")
for (const entry of cookies) {
const [cookieName, ...rest] = entry.trim().split("=")
if (cookieName === name) {
return rest.join("=")
}
}
return ""
}
export function isAdminCredentialLoginConfigured() {
return Boolean(
isAdminUiEnabled() &&
hasConvexUrl() &&
getConfiguredAdminEmail() &&
getConfiguredAdminPassword()
)
}
export function isAdminCredentialMatch(email: string, password: string) {
return (
email.trim().toLowerCase() === getConfiguredAdminEmail() &&
password === getConfiguredAdminPassword()
)
}
export async function createAdminSession(email: string) {
if (!hasConvexUrl()) {
throw new Error("Convex is not configured for admin sessions.")
}
const normalizedEmail = email.trim().toLowerCase()
const rawToken = randomBytes(32).toString("hex")
const tokenHash = hashAdminSessionToken(rawToken)
const expiresAt = Date.now() + ADMIN_SESSION_TTL_MS
await fetchMutation(api.admin.ensureAdminUser, {
email: normalizedEmail,
name: normalizedEmail.split("@")[0],
})
await fetchMutation(api.admin.createSession, {
email: normalizedEmail,
tokenHash,
expiresAt,
})
return {
token: rawToken,
expiresAt,
}
}
export async function destroyAdminSession(rawToken?: string | null) {
if (!rawToken || !hasConvexUrl()) {
return
}
try {
await fetchMutation(api.admin.destroySession, {
tokenHash: hashAdminSessionToken(rawToken),
})
} catch (error) {
console.error("Failed to destroy admin session:", error)
}
}
export async function validateAdminSession(rawToken?: string | null) {
if (!rawToken || !hasConvexUrl()) {
return null
}
try {
return await fetchQuery(api.admin.validateSession, {
tokenHash: hashAdminSessionToken(rawToken),
})
} catch (error) {
console.error("Failed to validate admin session:", error)
return null
}
}
export async function requireAdminSession(request: Request) {
const rawToken = readCookieFromHeader(
request.headers.get("cookie") || "",
ADMIN_SESSION_COOKIE
)
const session = await validateAdminSession(rawToken || null)
if (!session?.user) {
return null
}
return session.user
}
export async function getAdminUserFromCookies() {
if (!isAdminUiEnabled()) {
return null
}
const cookieStore = await cookies()
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value
const session = await validateAdminSession(rawToken)
return session?.user || null
}

View file

@ -143,16 +143,12 @@ function getConfiguredTenantDomains() {
}
function defaultDeps(): LeadSubmissionDeps {
const ghlSyncEnabled = String(process.env.ENABLE_GHL_SYNC || "")
.trim()
.toLowerCase() === "true"
return {
storageConfigured: isConvexConfigured(),
emailConfigured: isEmailConfigured(),
ghlConfigured:
ghlSyncEnabled &&
Boolean(process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID),
ghlConfigured: Boolean(
process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID
),
ingest: ingestLead,
updateLeadStatus: updateLeadSyncStatus,
sendEmail: (to, subject, html, replyTo) =>

View file

@ -1,130 +0,0 @@
type GhlSyncEnv = {
token: string
locationId: string
baseUrl: string
}
function normalizeBaseUrl(value?: string) {
return (value || "https://services.leadconnectorhq.com").replace(/\/+$/, "")
}
export function getGhlSyncEnv(): GhlSyncEnv {
const token = String(
process.env.GHL_PRIVATE_INTEGRATION_TOKEN || process.env.GHL_API_TOKEN || ""
).trim()
const locationId = String(process.env.GHL_LOCATION_ID || "").trim()
const baseUrl = normalizeBaseUrl(process.env.GHL_API_BASE_URL)
if (!token || !locationId) {
throw new Error("GHL token or location ID is not configured.")
}
return { token, locationId, baseUrl }
}
async function fetchGhlJson(pathname: string, init?: RequestInit) {
const env = getGhlSyncEnv()
const response = await fetch(`${env.baseUrl}${pathname}`, {
...init,
headers: {
Authorization: `Bearer ${env.token}`,
Version: process.env.GHL_API_VERSION || "2021-07-28",
Accept: "application/json",
"Content-Type": "application/json",
...(init?.headers || {}),
},
cache: "no-store",
})
const text = await response.text()
let body: any = null
if (text) {
try {
body = JSON.parse(text)
} catch {
body = null
}
}
if (!response.ok) {
throw new Error(
`GHL request failed (${response.status}) for ${pathname}: ${body?.message || text || "Unknown error"}`
)
}
return body
}
export async function fetchGhlContacts(args?: {
limit?: number
cursor?: string
}) {
const env = getGhlSyncEnv()
const searchParams = new URLSearchParams({
locationId: env.locationId,
limit: String(Math.min(100, Math.max(1, args?.limit || 100))),
})
if (args?.cursor) {
searchParams.set("startAfterId", args.cursor)
}
const payload = await fetchGhlJson(`/contacts/?${searchParams.toString()}`)
const contacts = Array.isArray(payload?.contacts)
? payload.contacts
: Array.isArray(payload?.data?.contacts)
? payload.data.contacts
: []
const nextCursor =
contacts.length > 0 ? String(contacts[contacts.length - 1]?.id || "") : ""
return {
items: contacts,
nextCursor: nextCursor || undefined,
}
}
export async function fetchGhlMessages(args?: {
limit?: number
cursor?: string
channel?: "Call" | "SMS"
}) {
const env = getGhlSyncEnv()
const url = new URL(`${env.baseUrl}/conversations/messages/export`)
url.searchParams.set("locationId", env.locationId)
url.searchParams.set("limit", String(Math.min(100, Math.max(1, args?.limit || 100))))
url.searchParams.set("channel", args?.channel || "SMS")
if (args?.cursor) {
url.searchParams.set("cursor", args.cursor)
}
const payload = await fetchGhlJson(url.pathname + url.search)
return {
items: Array.isArray(payload?.messages) ? payload.messages : [],
nextCursor:
typeof payload?.nextCursor === "string" && payload.nextCursor
? payload.nextCursor
: undefined,
}
}
export async function fetchGhlCallLogs(args?: {
page?: number
pageSize?: number
}) {
const env = getGhlSyncEnv()
const url = new URL(`${env.baseUrl}/voice-ai/dashboard/call-logs`)
url.searchParams.set("locationId", env.locationId)
url.searchParams.set("page", String(Math.max(1, args?.page || 1)))
url.searchParams.set(
"pageSize",
String(Math.min(50, Math.max(1, args?.pageSize || 50)))
)
const payload = await fetchGhlJson(url.pathname + url.search)
return {
items: Array.isArray(payload?.callLogs) ? payload.callLogs : [],
page: Number(payload?.page || args?.page || 1),
total: Number(payload?.total || 0),
pageSize: Number(payload?.pageSize || args?.pageSize || 50),
}
}

View file

@ -1,208 +0,0 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import {
rankListingsForQuery,
sortListingsByFreshness,
type CachedEbayListing,
} from "@/lib/ebay-parts-match"
export type ManualPartRow = {
partNumber: string
description: string
ebayListings?: CachedEbayListing[]
}
type ManualPartsLookup = Record<string, ManualPartRow[]>
type ManualPagesPartsLookup = Record<string, Record<string, ManualPartRow[]>>
let manualPartsCache: ManualPartsLookup | null = null
let manualPagesPartsCache: ManualPagesPartsLookup | null = null
let staticEbayListingsCache: CachedEbayListing[] | null = null
async function readJsonFile<T>(filename: string): Promise<T> {
const filePath = path.join(process.cwd(), "public", filename)
const contents = await readFile(filePath, "utf8")
return JSON.parse(contents) as T
}
export async function loadManualPartsLookup(): Promise<ManualPartsLookup> {
if (!manualPartsCache) {
manualPartsCache = await readJsonFile<ManualPartsLookup>(
"manual_parts_lookup.json"
)
}
return manualPartsCache
}
export async function loadManualPagesPartsLookup(): Promise<ManualPagesPartsLookup> {
if (!manualPagesPartsCache) {
manualPagesPartsCache = await readJsonFile<ManualPagesPartsLookup>(
"manual_pages_parts.json"
)
}
return manualPagesPartsCache
}
export async function findManualParts(
manualFilename: string
): Promise<ManualPartRow[]> {
const manualParts = await loadManualPartsLookup()
if (manualFilename in manualParts) {
return manualParts[manualFilename]
}
const lowerFilename = manualFilename.toLowerCase()
for (const [filename, parts] of Object.entries(manualParts)) {
if (filename.toLowerCase() === lowerFilename) {
return parts
}
}
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "")
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
for (const [filename, parts] of Object.entries(manualParts)) {
const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase()
if (
otherWithoutExt === lowerWithoutExt ||
otherWithoutExt.includes(lowerWithoutExt) ||
lowerWithoutExt.includes(otherWithoutExt)
) {
return parts
}
}
return []
}
export async function findManualPageParts(
manualFilename: string,
pageNumber: number
): Promise<ManualPartRow[]> {
const manualPagesParts = await loadManualPagesPartsLookup()
if (
manualPagesParts[manualFilename] &&
manualPagesParts[manualFilename][pageNumber.toString()]
) {
return manualPagesParts[manualFilename][pageNumber.toString()]
}
const lowerFilename = manualFilename.toLowerCase()
for (const [filename, pages] of Object.entries(manualPagesParts)) {
if (
filename.toLowerCase() === lowerFilename &&
pages[pageNumber.toString()]
) {
return pages[pageNumber.toString()]
}
}
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "")
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
for (const [filename, pages] of Object.entries(manualPagesParts)) {
const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase()
if (
otherWithoutExt === lowerWithoutExt ||
otherWithoutExt.includes(lowerWithoutExt) ||
lowerWithoutExt.includes(otherWithoutExt)
) {
if (pages[pageNumber.toString()]) {
return pages[pageNumber.toString()]
}
}
}
return []
}
export async function listManualsWithParts(): Promise<Set<string>> {
const manualParts = await loadManualPartsLookup()
const manualsWithParts = new Set<string>()
for (const [filename, parts] of Object.entries(manualParts)) {
if (parts.length > 0) {
manualsWithParts.add(filename)
manualsWithParts.add(filename.toLowerCase())
manualsWithParts.add(filename.replace(/\.pdf$/i, ""))
manualsWithParts.add(filename.replace(/\.pdf$/i, "").toLowerCase())
}
}
return manualsWithParts
}
function dedupeListings(listings: CachedEbayListing[]): CachedEbayListing[] {
const byItemId = new Map<string, CachedEbayListing>()
for (const listing of listings) {
const itemId = listing.itemId?.trim()
if (!itemId) {
continue
}
const existing = byItemId.get(itemId)
if (!existing) {
byItemId.set(itemId, listing)
continue
}
const existingFreshness = existing.lastSeenAt ?? existing.fetchedAt ?? 0
const nextFreshness = listing.lastSeenAt ?? listing.fetchedAt ?? 0
if (nextFreshness >= existingFreshness) {
byItemId.set(itemId, {
...existing,
...listing,
sourceQueries: Array.from(
new Set([...(existing.sourceQueries || []), ...(listing.sourceQueries || [])])
),
})
}
}
return sortListingsByFreshness(Array.from(byItemId.values()))
}
export async function loadStaticEbayListings(): Promise<CachedEbayListing[]> {
if (staticEbayListingsCache) {
return staticEbayListingsCache
}
const [manualParts, manualPagesParts] = await Promise.all([
loadManualPartsLookup(),
loadManualPagesPartsLookup(),
])
const listings: CachedEbayListing[] = []
for (const parts of Object.values(manualParts)) {
for (const part of parts) {
if (Array.isArray(part.ebayListings)) {
listings.push(...part.ebayListings)
}
}
}
for (const pages of Object.values(manualPagesParts)) {
for (const parts of Object.values(pages)) {
for (const part of parts) {
if (Array.isArray(part.ebayListings)) {
listings.push(...part.ebayListings)
}
}
}
}
staticEbayListingsCache = dedupeListings(listings)
return staticEbayListingsCache
}
export async function searchStaticEbayListings(
query: string,
limit = 6
): Promise<CachedEbayListing[]> {
const listings = await loadStaticEbayListings()
return rankListingsForQuery(query, listings, limit)
}

View file

@ -1,27 +0,0 @@
import {
getManualsQdrantCorpus,
searchManualsQdrantCorpus,
} from "@/lib/manuals-qdrant-corpus"
export async function searchServiceKnowledge(args: {
query: string
limit?: number
}) {
const corpus = await getManualsQdrantCorpus()
const results = searchManualsQdrantCorpus(corpus, args.query, {
limit: args.limit ?? 4,
profile: "public_safe",
})
return results.map((result) => ({
score: result.score,
title: result.chunk.title,
manufacturer: result.chunk.manufacturer,
model: result.chunk.model,
sourceKind: result.chunk.sourceKind,
manualType: result.chunk.manualType,
sourceFilename: result.chunk.sourceFilename,
manualUrl: result.chunk.manualUrl,
snippet: result.chunk.text.slice(0, 600).trim(),
}))
}

View file

@ -7,19 +7,12 @@
"scripts": {
"build": "next build",
"copy:check": "node scripts/check-public-copy.mjs",
"diagnose:ebay": "node scripts/staging-smoke.mjs",
"deploy:staging:convex-gate": "node scripts/check-convex-manuals-gate.mjs",
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build && pnpm deploy:staging:convex-gate",
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts",
"manuals:qdrant:eval": "tsx scripts/evaluate-manuals-qdrant-corpus.ts",
"manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
"manuals:backfill:tenant": "tsx scripts/backfill-convex-manuals-tenant.ts",
"ghl:export:call-transcripts": "tsx scripts/export-ghl-call-transcripts.ts",
"ghl:export:outbound-call-transcripts": "tsx scripts/export-ghl-outbound-call-transcripts.ts",
"convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"",
"dev": "next dev",
"lint": "eslint .",
@ -37,7 +30,6 @@
"lighthouse:ci": "lighthouse-ci autorun",
"analyze": "ANALYZE=true next build",
"generate:links": "node scripts/generate-internal-links.js",
"contacts:import:ghl": "tsx scripts/import-ghl-contacts-to-contact-profiles.ts",
"links": "node scripts/generate-internal-links.js",
"mcp": "pnpm dlx shadcn@latest mcp",
"seo:sitemap": "node scripts/seo-internal-link-tool.js sitemap",

View file

@ -1,60 +0,0 @@
import { config as loadEnv } from "dotenv"
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference } from "convex/server"
import { resolveManualsTenantDomain } from "../lib/manuals-tenant"
loadEnv({ path: ".env.local" })
loadEnv({ path: ".env.staging", override: false })
const BACKFILL_MUTATION = makeFunctionReference<"mutation">(
"manuals:backfillTenantVisibility"
)
function parseArgs(argv: string[]) {
const domainFlagIndex = argv.indexOf("--domain")
const domain =
domainFlagIndex >= 0 ? (argv[domainFlagIndex + 1] || "").trim() : ""
return {
domain,
dryRun: argv.includes("--dry-run"),
}
}
function readConvexUrl() {
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL
if (!convexUrl) {
throw new Error(
"NEXT_PUBLIC_CONVEX_URL (or CONVEX_URL) is required for Convex backfill."
)
}
return convexUrl
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const domain = resolveManualsTenantDomain({
envTenantDomain: args.domain || process.env.MANUALS_TENANT_DOMAIN,
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
})
if (!domain) {
throw new Error(
"Could not resolve tenant domain. Pass --domain or set MANUALS_TENANT_DOMAIN / NEXT_PUBLIC_SITE_DOMAIN."
)
}
const convex = new ConvexHttpClient(readConvexUrl())
const result = await convex.mutation(BACKFILL_MUTATION, {
domain,
dryRun: args.dryRun,
})
console.log("[manuals-tenant-backfill] result", result)
}
main().catch((error) => {
console.error("[manuals-tenant-backfill] failed", error)
process.exit(1)
})

View file

@ -1,82 +0,0 @@
import process from "node:process"
import dotenv from "dotenv"
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference } from "convex/server"
const LIST_MANUALS = makeFunctionReference("manuals:list")
function canonicalizeDomain(input) {
return String(input || "")
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^\/\//, "")
.replace(/\/.*$/, "")
.replace(/:\d+$/, "")
.replace(/\.$/, "")
}
function parseArgs(argv) {
const domainIndex = argv.indexOf("--domain")
const domain = domainIndex >= 0 ? argv[domainIndex + 1] : ""
const minCountIndex = argv.indexOf("--min-count")
const minCount =
minCountIndex >= 0
? Number.parseInt(argv[minCountIndex + 1] || "", 10)
: 1
return {
domain,
minCount: Number.isFinite(minCount) && minCount > 0 ? minCount : 1,
}
}
function readConvexUrl() {
const value =
process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL || ""
return value.trim()
}
async function main() {
dotenv.config({ path: ".env.local", override: false })
dotenv.config({ path: ".env.staging", override: false })
const args = parseArgs(process.argv.slice(2))
const domain = canonicalizeDomain(
args.domain ||
process.env.MANUALS_TENANT_DOMAIN ||
process.env.NEXT_PUBLIC_SITE_DOMAIN
)
const convexUrl = readConvexUrl()
if (!convexUrl) {
throw new Error(
"NEXT_PUBLIC_CONVEX_URL (or CONVEX_URL) is required for Convex manuals gate."
)
}
if (!domain) {
throw new Error(
"A tenant domain is required. Set NEXT_PUBLIC_SITE_DOMAIN / MANUALS_TENANT_DOMAIN or pass --domain."
)
}
const convex = new ConvexHttpClient(convexUrl)
const manuals = await convex.query(LIST_MANUALS, { domain })
const count = Array.isArray(manuals) ? manuals.length : 0
console.log(
`[convex-manuals-gate] domain=${domain} count=${count} min=${args.minCount}`
)
if (count < args.minCount) {
throw new Error(
`Convex manuals gate failed for ${domain}: expected at least ${args.minCount} manuals, got ${count}.`
)
}
}
main().catch((error) => {
console.error("[convex-manuals-gate] failed", error)
process.exit(1)
})

View file

@ -112,35 +112,6 @@ function readValue(name) {
return String(process.env[name] ?? "").trim()
}
function canonicalizeDomain(input) {
return String(input || "")
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^\/\//, "")
.replace(/\/.*$/, "")
.replace(/:\d+$/, "")
.replace(/\.$/, "")
}
function isValidHttpUrl(value) {
if (!value) {
return false
}
try {
const url = new URL(value)
return url.protocol === "https:" || url.protocol === "http:"
} catch {
return false
}
}
function isValidHostname(value) {
const host = canonicalizeDomain(value)
return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host)
}
function hasVoiceRecordingConfig() {
return [
readValue("VOICE_RECORDING_ACCESS_KEY_ID") ||
@ -263,21 +234,6 @@ function main() {
console.log(`${group.label}: fallback in use`)
}
const convexUrl = readValue("NEXT_PUBLIC_CONVEX_URL")
if (!isValidHttpUrl(convexUrl)) {
failures.push(
"NEXT_PUBLIC_CONVEX_URL is malformed. It must be a full http(s) URL."
)
}
const siteDomain =
readValue("MANUALS_TENANT_DOMAIN") || readValue("NEXT_PUBLIC_SITE_DOMAIN")
if (!isValidHostname(siteDomain)) {
failures.push(
"NEXT_PUBLIC_SITE_DOMAIN (or MANUALS_TENANT_DOMAIN) is malformed. It must be a valid hostname."
)
}
if (!hasManualStorageCredentials()) {
failures.push(
"Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release."

View file

@ -1,166 +0,0 @@
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import { config as loadEnv } from "dotenv"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
loadEnv({ path: ".env.local" })
type ImportRecord = {
firstName?: string
lastName?: string
name?: string
email?: string
phone?: string
company?: string
notes?: string
}
function normalizePhone(value?: string | null) {
const digits = String(value || "").replace(/\D/g, "")
if (!digits) {
return ""
}
if (digits.length === 10) {
return `+1${digits}`
}
if (digits.length === 11 && digits.startsWith("1")) {
return `+${digits}`
}
if (digits.length >= 11) {
return `+${digits}`
}
return ""
}
function splitCsvLine(line: string) {
const values: string[] = []
let current = ""
let inQuotes = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
const next = line[index + 1]
if (char === '"' && inQuotes && next === '"') {
current += '"'
index += 1
continue
}
if (char === '"') {
inQuotes = !inQuotes
continue
}
if (char === "," && !inQuotes) {
values.push(current.trim())
current = ""
continue
}
current += char
}
values.push(current.trim())
return values
}
function parseCsv(content: string): ImportRecord[] {
const lines = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (lines.length < 2) {
return []
}
const headers = splitCsvLine(lines[0]).map((header) => header.toLowerCase())
return lines.slice(1).map((line) => {
const values = splitCsvLine(line)
const record: Record<string, string> = {}
headers.forEach((header, index) => {
record[header] = values[index] || ""
})
return {
firstName: record.firstname || record["first name"] || record.first_name,
lastName: record.lastname || record["last name"] || record.last_name,
name: record.name || record.fullname || record["full name"],
email: record.email || record["email address"],
phone: record.phone || record["phone number"] || record.mobile,
company: record.company || record["company name"],
notes: record.notes || record.note,
}
})
}
function parseJson(content: string): ImportRecord[] {
const value = JSON.parse(content)
if (!Array.isArray(value)) {
throw new Error("JSON import file must contain an array of contacts.")
}
return value
}
async function loadRecords(pathname: string) {
const absolutePath = resolve(pathname)
const content = await readFile(absolutePath, "utf8")
if (absolutePath.endsWith(".json")) {
return parseJson(content)
}
return parseCsv(content)
}
async function main() {
const inputPath = process.argv[2]
if (!inputPath) {
throw new Error("Usage: tsx scripts/import-ghl-contacts-to-contact-profiles.ts <contacts.json|contacts.csv>")
}
const records = await loadRecords(inputPath)
let imported = 0
let skipped = 0
for (const record of records) {
const normalizedPhone = normalizePhone(record.phone)
if (!normalizedPhone) {
skipped += 1
continue
}
const displayName =
record.name?.trim() ||
[record.firstName, record.lastName].filter(Boolean).join(" ").trim()
await fetchMutation(api.contactProfiles.upsertByPhone, {
normalizedPhone,
displayName: displayName || undefined,
firstName: record.firstName?.trim() || undefined,
lastName: record.lastName?.trim() || undefined,
email: record.email?.trim() || undefined,
company: record.company?.trim() || undefined,
reminderNotes: record.notes?.trim() || undefined,
source: "ghl-import",
})
imported += 1
}
console.log(
JSON.stringify(
{
imported,
skipped,
total: records.length,
},
null,
2
)
)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View file

@ -1,582 +0,0 @@
import { existsSync } from "node:fs"
import path from "node:path"
import process from "node:process"
import dotenv from "dotenv"
const DEFAULT_BASE_URL = "http://127.0.0.1:3000"
const DEFAULT_MANUAL_CARD_TEXT = "653-655-657-hot-drink-center-parts-manual"
const DEFAULT_MANUAL_FILENAME = "653-655-657-hot-drink-center-parts-manual.pdf"
const DEFAULT_PART_NUMBER = "CABINET"
const DEFAULT_PART_DESCRIPTION = "- CABINET ASSEMBLY (SEE FIGURES 27, 28, 29) -"
function loadEnvFile() {
const envPath = path.resolve(process.cwd(), ".env.local")
if (existsSync(envPath)) {
dotenv.config({ path: envPath, override: false })
}
}
function parseArgs(argv) {
const args = {
baseUrl: process.env.BASE_URL || DEFAULT_BASE_URL,
manualCardText: DEFAULT_MANUAL_CARD_TEXT,
manualFilename: DEFAULT_MANUAL_FILENAME,
partNumber: DEFAULT_PART_NUMBER,
partDescription: DEFAULT_PART_DESCRIPTION,
adminToken: process.env.ADMIN_API_TOKEN || "",
skipBrowser: process.env.SMOKE_SKIP_BROWSER === "1",
}
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (token === "--base-url") {
args.baseUrl = argv[index + 1] || args.baseUrl
index += 1
continue
}
if (token === "--manual-card-text") {
args.manualCardText = argv[index + 1] || args.manualCardText
index += 1
continue
}
if (token === "--manual-filename") {
args.manualFilename = argv[index + 1] || args.manualFilename
index += 1
continue
}
if (token === "--part-number") {
args.partNumber = argv[index + 1] || args.partNumber
index += 1
continue
}
if (token === "--part-description") {
args.partDescription = argv[index + 1] || args.partDescription
index += 1
continue
}
if (token === "--admin-token") {
args.adminToken = argv[index + 1] || args.adminToken
index += 1
continue
}
if (token === "--skip-browser") {
args.skipBrowser = true
}
}
return args
}
function normalizeBaseUrl(value) {
return value.replace(/\/+$/, "")
}
function canonicalizeDomain(input) {
return String(input || "")
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^\/\//, "")
.replace(/\/.*$/, "")
.replace(/:\d+$/, "")
.replace(/\.$/, "")
}
function envPresence(name) {
return Boolean(String(process.env[name] ?? "").trim())
}
function heading(title) {
console.log(`\n== ${title} ==`)
}
function report(name, state, detail = "") {
const suffix = detail ? `${detail}` : ""
console.log(`${name}: ${state}${suffix}`)
}
function summarizeCache(cache) {
if (!cache || typeof cache !== "object") {
return "no cache payload"
}
const status = String(cache.status ?? "unknown")
const listingCount = Number(cache.listingCount ?? cache.itemCount ?? 0)
const activeCount = Number(cache.activeListingCount ?? 0)
const lastError = typeof cache.lastError === "string" ? cache.lastError : ""
const freshnessMs =
typeof cache.freshnessMs === "number" ? `${cache.freshnessMs}ms` : "n/a"
return [
`status=${status}`,
`listings=${listingCount}`,
`active=${activeCount}`,
`freshness=${freshnessMs}`,
lastError ? `lastError=${lastError}` : null,
]
.filter(Boolean)
.join(", ")
}
function parseUrl(value) {
try {
return new URL(String(value || ""))
} catch {
return null
}
}
function listingLooksSynthetic(listing) {
const itemId = String(listing?.itemId || "").trim()
const viewItemUrl = String(listing?.viewItemUrl || "").trim()
const imageUrl = String(listing?.imageUrl || "").trim()
if (!itemId || itemId.startsWith("123456789")) {
return true
}
if (viewItemUrl.includes("123456789")) {
return true
}
const parsedImageUrl = parseUrl(imageUrl)
const imageHost = parsedImageUrl?.hostname?.toLowerCase?.() || ""
if (
imageHost.includes("images.unsplash.com") ||
imageHost.includes("via.placeholder.com") ||
imageHost.includes("placehold.co")
) {
return true
}
return false
}
function listingHasAffiliateCampaign(listing) {
const parsed = parseUrl(listing?.affiliateLink || "")
if (!parsed) {
return false
}
return Boolean(parsed.searchParams.get("campid"))
}
function hasFallbackCacheMessage(cache) {
const message = typeof cache?.message === "string" ? cache.message.toLowerCase() : ""
return (
message.includes("bundled manual cache") ||
message.includes("cached listings failed")
)
}
async function requestJson(url, init) {
const response = await fetch(url, {
redirect: "follow",
...init,
})
const text = await response.text()
let body = null
if (text.trim()) {
try {
body = JSON.parse(text)
} catch {
body = null
}
}
return { response, body, text }
}
async function checkPages(baseUrl, failures) {
heading("Public Pages")
const pages = ["/", "/contact-us", "/products", "/manuals"]
for (const pagePath of pages) {
const { response } = await requestJson(`${baseUrl}${pagePath}`)
const ok = response.status === 200
report(pagePath, ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET ${pagePath} returned ${response.status}`)
}
}
}
async function checkManualsPayload(baseUrl, failures) {
heading("Manuals Payload")
const { response, text } = await requestJson(`${baseUrl}/manuals`)
const ok = response.status === 200
report("GET /manuals payload", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET /manuals returned ${response.status}`)
return
}
if (text.includes("Manuals Library Temporarily Unavailable")) {
failures.push("Manuals page is in degraded mode.")
}
if (text.includes('"initialManuals":[]')) {
failures.push("Manuals page rendered with zero initial manuals.")
}
const expectedHost = canonicalizeDomain(new URL(baseUrl).host)
const domainMatch = text.match(/data-manuals-domain=\"([^\"]+)\"/)
const runtimeDomain = canonicalizeDomain(domainMatch?.[1] || "")
console.log(` expectedDomain: ${expectedHost}`)
console.log(` runtimeDomain: ${runtimeDomain || "missing"}`)
if (!runtimeDomain) {
failures.push("Manuals page is missing runtime tenant domain marker.")
} else if (runtimeDomain !== expectedHost) {
failures.push(
`Manuals runtime domain mismatch. Expected ${expectedHost}, got ${runtimeDomain}.`
)
}
}
async function checkEbaySearch(baseUrl, failures) {
heading("eBay Cache Search")
const url = new URL(`${baseUrl}/api/ebay/search`)
url.searchParams.set("keywords", "vending machine part")
url.searchParams.set("maxResults", "3")
url.searchParams.set("sortOrder", "BestMatch")
const { response, body, text } = await requestJson(url)
const ok = response.status === 200
const cache = body?.cache
const cacheStatus = cache?.status ?? "unknown"
report("GET /api/ebay/search", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET /api/ebay/search returned ${response.status}`)
console.log(text)
return
}
console.log(` cache: ${summarizeCache(cache)}`)
console.log(` results: ${Array.isArray(body?.results) ? body.results.length : 0}`)
const results = Array.isArray(body?.results) ? body.results : []
const trustedResults = results.filter((listing) => !listingLooksSynthetic(listing))
if (hasFallbackCacheMessage(cache)) {
failures.push("eBay search is serving fallback cache data instead of Convex cache.")
}
if (cacheStatus === "success" && Number(cache?.listingCount ?? cache?.itemCount ?? 0) === 0) {
failures.push(
"eBay search returned status=success but cache has zero listings; backend cache is not healthy."
)
}
if (results.some((listing) => listingLooksSynthetic(listing))) {
failures.push("eBay search returned synthetic placeholder listings.")
}
if (trustedResults.length === 0) {
failures.push("eBay search did not return any trusted listings.")
}
if (trustedResults.length > 0 && !trustedResults.some(listingHasAffiliateCampaign)) {
failures.push("eBay search trusted listings are missing affiliate campaign tracking.")
}
}
async function checkManualParts(baseUrl, failures, manualFilename, partNumber, partDescription) {
heading("Manual Parts Match")
const { response, body, text } = await requestJson(`${baseUrl}/api/ebay/manual-parts`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
manualFilename,
limit: 3,
parts: [
{
partNumber,
description: partDescription,
},
],
}),
})
const ok = response.status === 200
const cache = body?.cache
const cacheStatus = cache?.status ?? "unknown"
report("POST /api/ebay/manual-parts", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`POST /api/ebay/manual-parts returned ${response.status}`)
console.log(text)
return
}
const parts = Array.isArray(body?.parts) ? body.parts : []
const firstCount = Array.isArray(parts[0]?.ebayListings) ? parts[0].ebayListings.length : 0
console.log(` cache: ${summarizeCache(cache)}`)
console.log(` matched parts: ${parts.length}`)
console.log(` first part listings: ${firstCount}`)
const allListings = parts.flatMap((part) =>
Array.isArray(part?.ebayListings) ? part.ebayListings : []
)
const trustedListings = allListings.filter((listing) => !listingLooksSynthetic(listing))
if (hasFallbackCacheMessage(cache)) {
failures.push("Manual parts route is serving fallback cache data instead of Convex cache.")
}
if (cacheStatus === "success" && Number(cache?.listingCount ?? cache?.itemCount ?? 0) === 0) {
failures.push(
"Manual parts route returned status=success but cache has zero listings; backend cache is not healthy."
)
}
if (allListings.some((listing) => listingLooksSynthetic(listing))) {
failures.push("Manual parts route returned synthetic placeholder listings.")
}
if (trustedListings.length === 0) {
failures.push("Manual parts route did not return any trusted listings for the smoke manual.")
}
if (trustedListings.length > 0 && !trustedListings.some(listingHasAffiliateCampaign)) {
failures.push("Manual parts trusted listings are missing affiliate campaign tracking.")
}
if (!body?.manualFilename || body.manualFilename !== manualFilename) {
failures.push("Manual parts response did not echo the requested manualFilename.")
}
}
async function checkNotifications(baseUrl, failures) {
heading("eBay Notification Challenge")
const url = new URL(`${baseUrl}/api/ebay/notifications`)
url.searchParams.set("challenge_code", "diagnostic-test")
const { response, body, text } = await requestJson(url)
const ok = response.status === 200
report("GET /api/ebay/notifications", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET /api/ebay/notifications returned ${response.status}`)
console.log(text)
return
}
const challengeResponse = typeof body?.challengeResponse === "string"
? body.challengeResponse
: ""
console.log(` challengeResponse: ${challengeResponse ? "present" : "missing"}`)
if (!challengeResponse || challengeResponse.length < 32) {
failures.push("Notification challenge response is missing or malformed.")
}
}
async function checkAdminRefresh(baseUrl, failures, adminToken) {
heading("Admin Refresh")
if (!adminToken) {
report("POST /api/admin/ebay/refresh", "skipped", "no admin token provided")
return
}
const { response, body, text } = await requestJson(
`${baseUrl}/api/admin/ebay/refresh`,
{
method: "POST",
headers: {
"x-admin-token": adminToken,
},
}
)
const ok = response.status >= 200 && response.status < 300
report("POST /api/admin/ebay/refresh", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`POST /api/admin/ebay/refresh returned ${response.status}`)
console.log(text)
return
}
console.log(
` result: ${body && typeof body === "object" ? JSON.stringify(body) : text || "empty"}`
)
}
async function checkBrowser(baseUrl, manualCardText, failures) {
heading("Browser UI")
if (process.env.SMOKE_SKIP_BROWSER === "1") {
report("Browser smoke", "skipped", "SMOKE_SKIP_BROWSER=1")
return
}
let chromium
try {
chromium = (await import("playwright")).chromium
} catch (error) {
report(
"Browser smoke",
"skipped",
error instanceof Error ? error.message : "playwright unavailable"
)
return
}
let browser
try {
browser = await chromium.launch({ headless: true })
} catch (error) {
report(
"Browser smoke",
"skipped",
error instanceof Error ? error.message : "browser launch failed"
)
return
}
try {
const page = await browser.newPage()
const consoleErrors = []
page.on("console", (message) => {
if (message.type() === "error") {
const text = message.text()
if (!text.includes("Failed to load resource") && !text.includes("404")) {
consoleErrors.push(text)
}
}
})
await page.goto(`${baseUrl}/manuals`, { waitUntil: "domcontentloaded" })
await page.waitForTimeout(1200)
const titleVisible = await page
.getByRole("heading", { name: "Vending Machine Manuals" })
.isVisible()
if (!titleVisible) {
failures.push("Manuals page title was not visible in the browser smoke test.")
report("Manuals page", "fail", "title not visible")
return
}
const openButton = page.getByRole("button", { name: "View PDF" }).first()
await openButton.click({ force: true })
await page.waitForTimeout(1500)
const viewerOpen = await page.getByText("Parts").first().isVisible().catch(() => false)
const viewerFallback =
(await page
.getByText("No parts data extracted for this manual yet")
.first()
.isVisible()
.catch(() => false)) ||
(await page
.getByText("No cached eBay matches yet")
.first()
.isVisible()
.catch(() => false))
if (!viewerOpen && !viewerFallback) {
failures.push("Manual viewer did not open or did not show a parts/cache state.")
report("Manual viewer", "fail", "no parts state visible")
} else {
report("Manual viewer", "ok", viewerFallback ? "fallback state visible" : "viewer open")
}
if (consoleErrors.length > 0) {
failures.push(
`Browser smoke saw console errors: ${consoleErrors.slice(0, 3).join(" | ")}`
)
}
} catch (error) {
failures.push(
`Browser smoke failed: ${error instanceof Error ? error.message : String(error)}`
)
report("Browser smoke", "fail", error instanceof Error ? error.message : String(error))
} finally {
await browser?.close().catch(() => {})
}
}
async function main() {
loadEnvFile()
const args = parseArgs(process.argv.slice(2))
const baseUrl = normalizeBaseUrl(args.baseUrl)
const failures = []
heading("Environment")
report(
"NEXT_PUBLIC_CONVEX_URL",
envPresence("NEXT_PUBLIC_CONVEX_URL") ? "present" : "missing"
)
report("CONVEX_URL", envPresence("CONVEX_URL") ? "present" : "missing")
report("EBAY_APP_ID", envPresence("EBAY_APP_ID") ? "present" : "missing")
report(
"EBAY_AFFILIATE_CAMPAIGN_ID",
envPresence("EBAY_AFFILIATE_CAMPAIGN_ID") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_ENDPOINT",
envPresence("EBAY_NOTIFICATION_ENDPOINT") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_VERIFICATION_TOKEN",
envPresence("EBAY_NOTIFICATION_VERIFICATION_TOKEN") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_APP_ID",
envPresence("EBAY_NOTIFICATION_APP_ID") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_CERT_ID",
envPresence("EBAY_NOTIFICATION_CERT_ID") ? "present" : "missing"
)
report("Base URL", baseUrl)
await checkPages(baseUrl, failures)
await checkManualsPayload(baseUrl, failures)
await checkEbaySearch(baseUrl, failures)
await checkManualParts(
baseUrl,
failures,
args.manualFilename,
args.partNumber,
args.partDescription
)
await checkNotifications(baseUrl, failures)
await checkAdminRefresh(baseUrl, failures, args.adminToken)
if (args.skipBrowser) {
heading("Browser UI")
report("Browser smoke", "skipped", "--skip-browser was provided")
} else {
await checkBrowser(baseUrl, args.manualCardText, failures)
}
heading("Summary")
if (failures.length > 0) {
console.log("Failures:")
for (const failure of failures) {
console.log(`- ${failure}`)
}
process.exitCode = 1
return
}
console.log("All smoke checks passed.")
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View file

@ -12,11 +12,6 @@ import {
import { businessConfig } from "../lib/seo-config"
import { getSiteDomain } from "../lib/site-config"
import { selectManualsForSite } from "../lib/manuals-site-selection"
import {
canonicalizeTenantDomain,
resolveManualsTenantDomain,
tenantDomainVariants,
} from "../lib/manuals-tenant"
import {
getManualsFilesRoot,
getManualsThumbnailsRoot,
@ -78,15 +73,8 @@ function readConvexUrl() {
}
function canonicalSiteVisibility(siteDomain: string) {
const canonicalHost = canonicalizeTenantDomain(
new URL(businessConfig.website).hostname
)
return Array.from(
new Set([
...tenantDomainVariants(siteDomain),
...tenantDomainVariants(canonicalHost),
])
)
const canonicalHost = new URL(businessConfig.website).hostname
return Array.from(new Set([siteDomain, canonicalHost]))
}
async function uploadSelectedAssets(
@ -181,17 +169,7 @@ function normalizeManualForConvex(
async function main() {
const args = parseArgs(process.argv.slice(2))
const siteDomain = resolveManualsTenantDomain({
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
envSiteDomain: getSiteDomain(),
})
if (!siteDomain) {
throw new Error(
"Could not resolve manuals tenant domain. Set MANUALS_TENANT_DOMAIN or NEXT_PUBLIC_SITE_DOMAIN."
)
}
const siteDomain = getSiteDomain()
const allManuals = await scanManuals()
const selection = selectManualsForSite(allManuals, siteDomain)
const selectedManuals = args.limit