Compare commits

..

24 commits

Author SHA1 Message Date
656b78bf8e
deploy: polish public page formatting 2026-04-17 12:27:34 -06:00
7144aa4943
fix: keep tenant thumbnail paths in production manuals render 2026-04-16 16:36:14 -06:00
508a8bbe5e
fix: add shared manual asset path normalization utility 2026-04-16 16:24:06 -06:00
5d3ee2c4d7
fix: add missing ebay parts visibility helper module 2026-04-16 16:18:31 -06:00
23f1ed6297
fix: restore manual thumbnails and hide empty ebay parts UI 2026-04-16 16:07:42 -06:00
f077966bb2
fix: enforce tenant-scoped manuals and deployment gates 2026-04-16 15:09:50 -06:00
c5e40c5caf
fix: improve ghl conversation sync mapping 2026-04-16 14:23:25 -06:00
013a908d92
feat: improve ghl conversation sync and inbox actions 2026-04-16 14:05:12 -06:00
e294117e6e
feat: rebuild CRM inbox and contact mapping 2026-04-16 13:30:18 -06:00
14cb8ce1fc
deploy: polish public marketing pages 2026-04-16 13:03:12 -06:00
9dfee33e49
fix: normalize GHL CRM sync statuses 2026-04-16 12:08:47 -06:00
7786336cfb
fix: simplify CRM sync status messaging 2026-04-16 11:40:59 -06:00
133ed6d6f3
feat: add GHL CRM sync status and runner 2026-04-16 11:40:19 -06:00
a1799715c6
fix: prefer public origin for admin auth redirects 2026-04-16 11:14:15 -06:00
4828f044fa
fix: use public admin auth redirects 2026-04-16 11:11:29 -06:00
e326cc6bba
feat: ship CRM admin and staging sign-in 2026-04-16 11:02:22 -06:00
c0914c92b4
fix: cap rate-limit backoff to daily ebay poll window 2026-04-10 16:19:30 -06:00
e2953a382b
fix: point eBay refresh cron at public action 2026-04-10 16:17:35 -06:00
bcc39664de
fix: make ebay refresh action callable from admin API 2026-04-10 16:08:21 -06:00
5b6ad66c24
fix: restore admin ebay refresh via public convex action wrapper 2026-04-10 16:01:14 -06:00
1f46c2b390
fix: enforce trusted ebay cache listings for manuals affiliate flow 2026-04-10 15:21:00 -06:00
b67bb1e183
fix: degrade phone followups without calendar creds 2026-04-10 13:20:41 -06:00
bc2edc04f2
feat: add local RMV tool stack for phone agent 2026-04-10 13:17:34 -06:00
8fff380b24
deploy: stabilize manuals eBay cache flow and smoke diagnostics 2026-04-08 11:27:59 -06:00
97 changed files with 11659 additions and 979 deletions

View file

@ -33,6 +33,15 @@ ADMIN_EMAIL=
# Direct phone-call visibility # Direct phone-call visibility
PHONE_AGENT_INTERNAL_TOKEN= PHONE_AGENT_INTERNAL_TOKEN=
PHONE_CALL_SUMMARY_FROM_EMAIL= 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 # Placeholder for a later LiveKit rollout
LIVEKIT_URL= LIVEKIT_URL=

View file

@ -33,6 +33,15 @@ ADMIN_EMAIL=
ADMIN_PASSWORD= ADMIN_PASSWORD=
RESEND_API_KEY= RESEND_API_KEY=
PHONE_CALL_SUMMARY_FROM_EMAIL= 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_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=

View file

@ -10,10 +10,18 @@ import { FAQSection } from "@/components/faq-section"
import { ContactPage } from "@/components/contact-page" import { ContactPage } from "@/components/contact-page"
import { AboutPage } from "@/components/about-page" import { AboutPage } from "@/components/about-page"
import { WhoWeServePage } from "@/components/who-we-serve-page" import { WhoWeServePage } from "@/components/who-we-serve-page"
import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicProse,
PublicSurface,
} from "@/components/public-surface"
import { import {
generateLocationPageMetadata, generateLocationPageMetadata,
LocationLandingPage, LocationLandingPage,
} from "@/components/location-landing-page" } from "@/components/location-landing-page"
import Link from "next/link"
// Required for static export - ensures this route is statically generated // Required for static export - ensures this route is statically generated
export const dynamic = "force-static" export const dynamic = "force-static"
@ -48,6 +56,7 @@ const routeMapping: Record<string, string> = {
// Food & Beverage // Food & Beverage
"food-and-beverage/healthy-options": "healthy-vending", "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/traditional-options": "traditional-vending",
"food-and-beverage/suppliers": "food-and-beverage/suppliers":
"diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts", "diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts",
@ -348,6 +357,7 @@ export default async function WordPressPage({ params }: PageProps) {
"vending-machines-for-your-car-wash", "vending-machines-for-your-car-wash",
] ]
const isWhoWeServePage = whoWeServeSlugs.includes(pageSlug) const isWhoWeServePage = whoWeServeSlugs.includes(pageSlug)
const routePath = `/${slugArray.join("/")}`
return ( return (
<> <>
@ -377,13 +387,58 @@ export default async function WordPressPage({ params }: PageProps) {
pageSlug !== "contact-us" && pageSlug !== "contact-us" &&
pageSlug !== "about-us" && pageSlug !== "about-us" &&
!isWhoWeServePage && ( !isWhoWeServePage && (
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl"> <article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
<header className="mb-8"> <Breadcrumbs
<h1 className="text-4xl md:text-5xl font-bold mb-6"> className="mb-6"
{page.title || "Page"} items={[
</h1> { label: "Home", href: "/" },
</header> { label: page.title || "Page", href: routePath },
{content} ]}
/>
<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> </article>
)} )}
</> </>

View file

@ -50,7 +50,8 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
Phone Call Detail Phone Call Detail
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{normalizePhoneFromIdentity(detail.call.participantIdentity) || {detail.call.contactDisplayName ||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity} detail.call.participantIdentity}
</p> </p>
</div> </div>
@ -98,6 +99,22 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
{detail.call.participantIdentity || "Unknown"} {detail.call.participantIdentity || "Unknown"}
</p> </p>
</div> </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> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Call Status Call Status
@ -135,6 +152,22 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</p> </p>
<p className="font-medium">{detail.call.notificationStatus}</p> <p className="font-medium">{detail.call.notificationStatus}</p>
</div> </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"> <div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Summary Summary
@ -157,6 +190,26 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</p> </p>
<p className="font-medium">{detail.call.transcriptTurnCount}</p> <p className="font-medium">{detail.call.transcriptTurnCount}</p>
</div> </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 ? ( {detail.call.recordingUrl ? (
<div className="md:col-span-2"> <div className="md:col-span-2">
<Link <Link
@ -179,6 +232,18 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</p> </p>
</div> </div>
) : null} ) : 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> </CardContent>
</Card> </Card>
@ -237,6 +302,23 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
lead. lead.
</p> </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> </CardContent>
</Card> </Card>
</div> </div>

View file

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

View file

@ -0,0 +1,199 @@
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",
}

202
app/admin/contacts/page.tsx Normal file
View file

@ -0,0 +1,202 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,518 @@
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,5 +1,9 @@
import Link from "next/link"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { isAdminUiEnabled } from "@/lib/server/admin-auth" import {
getAdminUserFromCookies,
isAdminUiEnabled,
} from "@/lib/server/admin-auth"
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
@ -10,5 +14,29 @@ export default async function AdminLayout({
redirect("/") redirect("/")
} }
return <>{children}</> 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>
)
} }

View file

@ -1,5 +1,7 @@
import Link from "next/link" import Link from "next/link"
import { fetchQuery } from "convex/nextjs"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import { import {
Card, Card,
CardContent, CardContent,
@ -21,6 +23,8 @@ import {
Settings, Settings,
BarChart3, BarChart3,
Phone, Phone,
MessageSquare,
ContactRound,
} from "lucide-react" } from "lucide-react"
import { fetchAllProducts } from "@/lib/stripe/products" import { fetchAllProducts } from "@/lib/stripe/products"
@ -56,10 +60,25 @@ async function getOrdersCount() {
return mockAnalytics.totalOrders 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() { export default async function AdminDashboard() {
const [productsCount, ordersCount] = await Promise.all([ const [productsCount, ordersCount, sync] = await Promise.all([
getProductsCount(), getProductsCount(),
getOrdersCount(), getOrdersCount(),
fetchQuery(api.crm.getAdminSyncOverview, {}),
]) ])
const dashboardCards = [ const dashboardCards = [
@ -192,10 +211,22 @@ export default async function AdminDashboard() {
Admin Dashboard Admin Dashboard
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Overview of your store performance and management tools Manage orders, contacts, conversations, and calls
</p> </p>
</div> </div>
<div className="flex gap-2"> <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"> <Link href="/admin/calls">
<Button variant="outline"> <Button variant="outline">
<Phone className="h-4 w-4 mr-2" /> <Phone className="h-4 w-4 mr-2" />
@ -212,6 +243,25 @@ export default async function AdminDashboard() {
</div> </div>
</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 */} {/* Main Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{dashboardCards.map((card, index) => { {dashboardCards.map((card, index) => {

View file

@ -0,0 +1,70 @@
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

@ -0,0 +1,53 @@
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

@ -0,0 +1,36 @@
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

@ -0,0 +1,32 @@
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

@ -0,0 +1,49 @@
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

@ -0,0 +1,39 @@
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

@ -0,0 +1,40 @@
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

@ -0,0 +1,45 @@
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

@ -0,0 +1,31 @@
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

@ -0,0 +1,39 @@
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

@ -0,0 +1,213 @@
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,167 +1,63 @@
import { NextRequest, NextResponse } from "next/server" 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"
/** type CacheSource = "convex" | "fallback"
* eBay API Proxy Route
* Proxies requests to eBay Finding API to avoid CORS issues
*/
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 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 || ""
function getDisabledCacheState(message: string): EbayCacheState {
return { return {
itemId: item.itemId || "", key: "manual-parts",
title: item.title || "Unknown Item", status: "disabled",
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`, lastSuccessfulAt: null,
currency: currentPrice?.currencyId || "USD", lastAttemptAt: null,
imageUrl: first(item.galleryURL) || undefined, nextEligibleAt: null,
viewItemUrl, lastError: message,
condition: condition?.conditionDisplayName || undefined, consecutiveFailures: 0,
shippingCost: shippingCost?.value queryCount: 0,
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}` itemCount: 0,
: undefined, sourceQueries: [],
affiliateLink: generateAffiliateLink(viewItemUrl), freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
} }
} }
async function readEbayErrorMessage(response: Response) { function getErrorCacheState(message: string): EbayCacheState {
const text = await response.text().catch(() => "") const now = Date.now()
if (!text.trim()) { return {
return `eBay API error: ${response.status}` 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,
} }
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( export async function GET(request: Request) {
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 { searchParams } = new URL(request.url)
const keywords = searchParams.get("keywords")?.trim() || ""
const keywords = searchParams.get("keywords") const maxResults = Math.min(
const categoryId = searchParams.get("categoryId") || undefined Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
const sortOrder = searchParams.get("sortOrder") || "BestMatch" 20
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
const cacheKey = buildCacheKey(
keywords || "",
categoryId,
sortOrder,
maxResults
) )
if (!keywords) { if (!keywords) {
@ -171,114 +67,54 @@ export async function GET(request: NextRequest) {
) )
} }
const appId = process.env.EBAY_APP_ID?.trim() if (!hasConvexUrl()) {
const message =
if (!appId) { "Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
console.error("EBAY_APP_ID not configured") return NextResponse.json({
return NextResponse.json( query: keywords,
{ results: [],
error: cache: getDisabledCacheState(message),
"eBay API not configured. Please set EBAY_APP_ID environment variable.", cacheSource: "fallback" satisfies CacheSource,
}, error: message,
{ 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 { try {
const request = (async () => { const [overview, listings] = await Promise.all([
const response = await fetch(url.toString(), { fetchQuery(api.ebay.getCacheOverview, {}),
method: "GET", fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
headers: { ])
Accept: "application/json",
},
})
if (!response.ok) { const trustedListings = filterTrustedEbayListings(
const errorMessage = await readEbayErrorMessage(response) listings as CachedEbayListing[]
throw new Error(errorMessage) )
} const ranked = rankListingsForQuery(
keywords,
trustedListings,
maxResults
)
const data = await response.json() return NextResponse.json({
query: keywords,
// Parse eBay API response results: ranked,
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0] cache: overview,
if (!findItemsAdvancedResponse) { cacheSource: "convex" satisfies CacheSource,
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) { } catch (error) {
console.error("Error fetching from eBay API:", 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."
return NextResponse.json( return NextResponse.json(
{ {
error: query: keywords,
error instanceof Error results: [],
? error.message cache: getErrorCacheState(message),
: "Failed to fetch products from eBay", cacheSource: "fallback" satisfies CacheSource,
error: message,
}, },
{ status: 500 } { status: 200 }
) )
} finally {
inFlightSearchResponses.delete(cacheKey)
} }
} }

View file

@ -0,0 +1,51 @@
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

@ -0,0 +1,60 @@
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

@ -0,0 +1,70 @@
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

@ -0,0 +1,61 @@
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

@ -0,0 +1,29 @@
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

@ -0,0 +1,69 @@
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

@ -0,0 +1,39 @@
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

@ -0,0 +1,40 @@
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

@ -0,0 +1,179 @@
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

@ -0,0 +1,42 @@
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

@ -0,0 +1,37 @@
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,6 +14,13 @@ export async function POST(request: Request) {
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, { const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
sessionId: body.sessionId, sessionId: body.sessionId,
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined, 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", leadOutcome: body.leadOutcome || "none",
handoffRequested: handoffRequested:
typeof body.handoffRequested === "boolean" typeof body.handoffRequested === "boolean"
@ -22,6 +29,41 @@ export async function POST(request: Request) {
handoffReason: body.handoffReason handoffReason: body.handoffReason
? String(body.handoffReason) ? String(body.handoffReason)
: undefined, : 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 }) return NextResponse.json({ success: true, call: result })

View file

@ -1,7 +1,8 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs" import { fetchMutation, fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared" import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { normalizePhoneE164 } from "@/lib/phone-normalization"
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request) const authError = await requirePhoneAgentInternalAuth(request)
@ -11,16 +12,44 @@ export async function POST(request: Request) {
try { try {
const body = await request.json() 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( const result = await fetchMutation(
api.voiceSessions.upsertPhoneCallSession, api.voiceSessions.upsertPhoneCallSession,
{ {
roomName: String(body.roomName || ""), roomName: String(body.roomName || ""),
participantIdentity: String(body.participantIdentity || ""), participantIdentity: String(body.participantIdentity || ""),
callerPhone: callerPhone || undefined,
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined, siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
pathname: body.pathname ? String(body.pathname) : undefined, pathname: body.pathname ? String(body.pathname) : undefined,
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined, pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
source: "phone-agent", source: "phone-agent",
metadata: body.metadata ? String(body.metadata) : undefined, 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: startedAt:
typeof body.startedAt === "number" ? body.startedAt : undefined, typeof body.startedAt === "number" ? body.startedAt : undefined,
recordingDisclosureAt: recordingDisclosureAt:
@ -35,6 +64,10 @@ export async function POST(request: Request) {
success: true, success: true,
sessionId: result?._id, sessionId: result?._id,
roomName: result?.roomName, roomName: result?.roomName,
callerPhone,
contactProfile: contactContext?.contactProfile || null,
recentLead: contactContext?.recentLead || null,
recentSession: contactContext?.recentSession || null,
}) })
} catch (error) { } catch (error) {
console.error("Failed to start phone call sync:", error) console.error("Failed to start phone call sync:", error)

View file

@ -5,7 +5,14 @@ import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader" import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content" import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { Breadcrumbs } from "@/components/breadcrumbs" import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicProse,
PublicSurface,
} from "@/components/public-surface"
import type { Metadata } from "next" import type { Metadata } from "next"
import Link from "next/link"
const WORDPRESS_SLUG = "abandoned-vending-machines" const WORDPRESS_SLUG = "abandoned-vending-machines"
@ -81,17 +88,61 @@ export default async function AbandonedVendingMachinesPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<Breadcrumbs <article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
items={[ <Breadcrumbs
{ label: "Blog", href: "/blog" }, className="mb-6"
{ items={[
label: page.title || "Abandoned Vending Machines", { label: "Blog", href: "/blog" },
href: "/blog/abandoned-vending-machines", {
}, 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} />
<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>
</article> </article>
</> </>
) )

View file

@ -183,7 +183,8 @@
transition: transition:
color 0.2s ease, color 0.2s ease,
text-decoration-color 0.2s ease, text-decoration-color 0.2s ease,
opacity 0.2s ease; opacity 0.2s ease,
transform 0.2s ease;
} }
a:hover, a:hover,
@ -202,7 +203,8 @@
transition: transition:
color 0.2s ease, color 0.2s ease,
text-decoration-color 0.2s ease, text-decoration-color 0.2s ease,
opacity 0.2s ease; opacity 0.2s ease,
transform 0.2s ease;
} }
a[href]:hover, a[href]:hover,
@ -211,6 +213,29 @@
background-color: transparent; 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, a:focus-visible,
button:focus-visible, button:focus-visible,
[role="button"]:focus-visible, [role="button"]:focus-visible,

View file

@ -1,8 +1,9 @@
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
import { existsSync } from "fs" import { createHash } from "node:crypto"
import { join } from "path" import { existsSync } from "node:fs"
import { Metadata } from "next" import { Metadata } from "next"
import { headers } from "next/headers"
import { businessConfig } from "@/lib/seo-config" import { businessConfig } from "@/lib/seo-config"
import { ManualsPageExperience } from "@/components/manuals-page-experience" import { ManualsPageExperience } from "@/components/manuals-page-experience"
import { listConvexManuals } from "@/lib/convex-service" import { listConvexManuals } from "@/lib/convex-service"
@ -10,6 +11,8 @@ import { scanManuals } from "@/lib/manuals"
import { selectManualsForSite } from "@/lib/manuals-site-selection" import { selectManualsForSite } from "@/lib/manuals-site-selection"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo" import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths" import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
export const metadata: Metadata = generateSEOMetadata({ export const metadata: Metadata = generateSEOMetadata({
title: "Vending Machine Manuals | Rocky Mountain Vending", title: "Vending Machine Manuals | Rocky Mountain Vending",
@ -31,29 +34,59 @@ export const metadata: Metadata = generateSEOMetadata({
}) })
export default async function ManualsPage() { export default async function ManualsPage() {
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated. const requestHeaders = await headers()
const convexManuals = await listConvexManuals() 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
const allManuals = const allManuals =
convexManuals.length > 0 ? convexManuals : await scanManuals() convexManuals.length > 0 || !shouldUseFilesystemFallback
? convexManuals
: await scanManuals()
let manuals = let manuals =
convexManuals.length > 0 convexManuals.length > 0
? convexManuals ? convexManuals
: selectManualsForSite(allManuals).manuals : shouldUseFilesystemFallback
? selectManualsForSite(allManuals, manualsDomain).manuals
: []
// Hide broken local thumbnails so the public manuals page doesn't spam 404s. const shouldShowDegradedState =
const thumbnailsRoot = getManualsThumbnailsRoot() !shouldUseFilesystemFallback && manuals.length === 0
manuals = manuals.map((manual) => {
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
return manual
}
const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/") if (shouldShowDegradedState) {
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "") const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || ""
: manual.thumbnailUrl const convexUrlHash = convexUrl
? createHash("sha256").update(convexUrl).digest("hex").slice(0, 12)
: "missing"
return existsSync(join(thumbnailsRoot, relativeThumbnailPath)) console.error(
? manual JSON.stringify({
: { ...manual, thumbnailUrl: undefined } 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,
}) })
// Generate structured data for SEO // Generate structured data for SEO
@ -99,8 +132,22 @@ export default async function ManualsPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
/> />
<div className="public-page"> <div className="public-page" data-manuals-domain={manualsDomain}>
<ManualsPageExperience initialManuals={manuals} /> {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> </div>
</> </>
) )

View file

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

View file

@ -1,11 +1,9 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { import {
generateRegistryMetadata, generateRegistryMetadata,
generateRegistryStructuredData, generateRegistryStructuredData,
} from "@/lib/seo" } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader" import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { FAQSection } from "@/components/faq-section" import { FAQSection } from "@/components/faq-section"
import { ServiceAreasSection } from "@/components/service-areas-section" import { ServiceAreasSection } from "@/components/service-areas-section"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@ -58,21 +56,13 @@ export default async function RepairsPage() {
notFound() notFound()
} }
let imageMapping: any = {}
try {
imageMapping = loadImageMapping()
} catch (e) {
imageMapping = {}
}
// Extract FAQs from content // Extract FAQs from content
const faqs: Array<{ question: string; answer: string }> = [] const faqs: Array<{ question: string; answer: string }> = []
let contentWithoutFAQs = page.content || ""
let contentWithoutVirtualServices = ""
let virtualServicesContent = "" let virtualServicesContent = ""
if (page.content) { if (page.content) {
const contentStr = String(page.content) const contentStr = String(page.content)
let strippedContent = contentStr
// Extract FAQ items from accordion structure // Extract FAQ items from accordion structure
const questionMatches = contentStr.matchAll( const questionMatches = contentStr.matchAll(
@ -112,40 +102,28 @@ export default async function RepairsPage() {
if (faqs.length > 0) { if (faqs.length > 0) {
const faqSectionRegex = const faqSectionRegex =
/<h2[^>]*>.*?Answers\s+To\s+Common\s+Questions.*?<\/h2>[\s\S]*?(?=<h2[^>]*>.*?Virtual\s+Services|<h2[^>]*>.*?Service\s+Area|$)/i /<h2[^>]*>.*?Answers\s+To\s+Common\s+Questions.*?<\/h2>[\s\S]*?(?=<h2[^>]*>.*?Virtual\s+Services|<h2[^>]*>.*?Service\s+Area|$)/i
contentWithoutFAQs = contentStr.replace(faqSectionRegex, "").trim() strippedContent = contentStr.replace(faqSectionRegex, "").trim()
} }
// Extract Virtual Services section // Extract Virtual Services section
const virtualServicesRegex = const virtualServicesRegex =
/<h2[^>]*>.*?Virtual\s+Services.*?<\/h2>([\s\S]*?)(?=<h2[^>]*>.*?Service\s+Area|$)/i /<h2[^>]*>.*?Virtual\s+Services.*?<\/h2>([\s\S]*?)(?=<h2[^>]*>.*?Service\s+Area|$)/i
const virtualMatch = contentStr.match(virtualServicesRegex) const virtualMatch = strippedContent.match(virtualServicesRegex)
if (virtualMatch) { if (virtualMatch) {
virtualServicesContent = virtualMatch[1] 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", { const structuredData = generateRegistryStructuredData("repairs", {
datePublished: page.date, datePublished: page.date,
dateModified: page.modified || page.date, dateModified: page.modified || page.date,
}) })
const excerpt = String(page.excerpt || "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
const surfaceCardClass = 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)]" "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 = const insetCardClass =
@ -171,37 +149,14 @@ export default async function RepairsPage() {
align="center" align="center"
className="mb-8" className="mb-8"
eyebrow="Repair Services" eyebrow="Repair Services"
title={page.title || "Vending Machine Repairs and Service"} title="Vending machine repairs and service for Utah businesses"
description={ description={
"Rocky Mountain Vending delivers expert vending machine repair and maintenance services to keep your business thriving." "Get help with payment issues, refrigeration problems, machine errors, and ongoing maintenance from a local vending service team."
} }
> >
<p className="mx-auto max-w-3xl text-base leading-relaxed text-muted-foreground md:text-lg"> <p className="mx-auto max-w-3xl text-base leading-relaxed text-muted-foreground md:text-lg">
Rocky Mountain Vending delivers expert{" "} {excerpt ||
<Link "Rocky Mountain Vending helps businesses across Davis, Salt Lake, and Utah counties keep machines running with practical repair, maintenance, and support guidance."}
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> </p>
</PublicPageHeader> </PublicPageHeader>
{/* Images Carousel */} {/* Images Carousel */}
@ -211,15 +166,71 @@ export default async function RepairsPage() {
</div> </div>
</section> </section>
{contentWithoutVirtualServices ? ( <section className="py-16 md:py-20 bg-background">
<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]">
<div className="container mx-auto px-4 max-w-4xl"> <PublicSurface className="h-full">
<PublicSurface> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
<div className="max-w-none">{content}</div> Repair Overview
</PublicSurface> </p>
</div> <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
</section> Clear next steps when a machine is down, rejecting payments, or
) : null} 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>
{/* Services Section */} {/* Services Section */}
<section className="py-20 md:py-28 bg-muted/30"> <section className="py-20 md:py-28 bg-muted/30">

View file

@ -1,12 +1,35 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { isAdminUiEnabled } from "@/lib/server/admin-auth" import {
getAdminUserFromCookies,
isAdminCredentialLoginConfigured,
isAdminUiEnabled,
} from "@/lib/server/admin-auth"
import { PublicPageHeader, PublicSurface } from "@/components/public-surface" import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
export default function SignInPage() { type PageProps = {
searchParams: Promise<{
error?: string
}>
}
export default async function SignInPage({ searchParams }: PageProps) {
if (!isAdminUiEnabled()) { if (!isAdminUiEnabled()) {
redirect("/") 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 ( return (
<div className="px-4 py-8 md:py-12"> <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"> <div className="mx-auto flex min-h-[calc(100dvh-7rem)] max-w-3xl items-start justify-center md:items-center">
@ -19,13 +42,55 @@ export default function SignInPage() {
/> />
<PublicSurface className="p-6 text-center md:p-8"> <PublicSurface className="p-6 text-center md:p-8">
<h2 className="text-2xl font-semibold"> {isAdminCredentialLoginConfigured() ? (
Admin sign-in is not configured <form
</h2> action="/api/admin/auth/login"
<p className="mt-3 text-sm text-muted-foreground"> method="post"
Enable the admin UI and connect an auth provider before using this className="mx-auto max-w-sm space-y-4 text-left"
area. >
</p> <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>
</>
)}
</PublicSurface> </PublicSurface>
</div> </div>
</div> </div>

View file

@ -1,16 +1,74 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content" import type { Metadata } from "next"
import type { ImageMapping } from "@/lib/wordpress-content" import Image from "next/image"
import Link from "next/link"
import {
CheckCircle2,
CreditCard,
Refrigerator,
ShoppingCart,
} from "lucide-react"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo" import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader" import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content" import {
import type { Metadata } from "next" PublicInset,
import { PublicPageHeader, PublicSurface } from "@/components/public-surface" PublicPageHeader,
PublicSectionHeader,
PublicSurface,
} from "@/components/public-surface"
import { GetFreeMachineCta } from "@/components/get-free-machine-cta" import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
import { Breadcrumbs } from "@/components/breadcrumbs" import { Breadcrumbs } from "@/components/breadcrumbs"
import { Button } from "@/components/ui/button"
const WORDPRESS_SLUG = "vending-machines-for-sale-in-utah" 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> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG) const page = getPageBySlug(WORDPRESS_SLUG)
@ -39,13 +97,6 @@ export default async function MachinesForSalePage() {
notFound() notFound()
} }
let imageMapping: ImageMapping = {}
try {
imageMapping = loadImageMapping()
} catch {
imageMapping = {}
}
const structuredData = (() => { const structuredData = (() => {
try { try {
return generateStructuredData({ return generateStructuredData({
@ -70,6 +121,16 @@ 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 ( return (
<> <>
<script <script
@ -88,19 +149,168 @@ export default async function MachinesForSalePage() {
]} ]}
/> />
<PublicPageHeader <PublicPageHeader
align="center"
eyebrow="Machine Sales" eyebrow="Machine Sales"
title={page.title || "Vending Machines for Sale in Utah"} title="Compare vending machines, payment hardware, and purchase options with a local Utah team."
description="Compare machine options, payment hardware, and support with help from the Rocky Mountain Vending 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>
<PublicSurface className="mt-10"> <section className="mt-10 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="max-w-none"> <PublicSurface className="flex h-full flex-col justify-center">
{cleanWordPressContent(String(page.content || ""), { <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
imageMapping, Sales Overview
pageTitle: page.title, </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>
)
})} })}
</div> </div>
</PublicSurface> </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>
<section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]"> <section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<PublicSurface> <PublicSurface>
@ -111,9 +321,9 @@ export default async function MachinesForSalePage() {
Need a free machine instead of buying one? Need a free machine instead of buying one?
</h2> </h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground"> <p className="mt-3 text-base leading-relaxed text-muted-foreground">
If you&apos;re a business looking for placement instead of a If you are a business looking for placement instead of a
purchase, we can help you find the right setup for your purchase, we can help you figure out whether your location is a
location. fit before you spend money on equipment.
</p> </p>
<div className="mt-6"> <div className="mt-6">
<GetFreeMachineCta buttonLabel="Get Free Placement" /> <GetFreeMachineCta buttonLabel="Get Free Placement" />
@ -125,13 +335,23 @@ export default async function MachinesForSalePage() {
Need Sales Help? Need Sales Help?
</p> </p>
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance"> <h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
Talk through machine sales, placement, or feature questions. Talk through machine sales, placement, or payment questions.
</h3> </h3>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground"> <p className="mt-3 text-sm leading-relaxed text-muted-foreground">
We can help with new vs. used options, payment hardware, and We can help with new vs. used options, payment hardware, and
whether free placement or a direct purchase makes more sense whether free placement or a direct purchase makes more sense
for your location. for your location.
</p> </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> </div>
</PublicSurface> </PublicSurface>
</section> </section>

View file

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

View file

@ -29,7 +29,37 @@ 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." description="Use the form for repairs, moving, manuals, machine sales, or general questions. If you'd rather talk now, call us during business hours."
/> />
<div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)] lg:items-start"> <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">
<PublicSurface id="contact-form" as="section" className="p-5 md:p-7"> <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="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"> <div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
@ -45,7 +75,21 @@ export function ContactPage() {
/> />
</PublicSurface> </PublicSurface>
<aside className="space-y-5"> <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>
<PublicSurface className="overflow-hidden p-6"> <PublicSurface className="overflow-hidden p-6">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80"> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Direct Options Direct Options
@ -59,10 +103,10 @@ export function ContactPage() {
below. below.
</p> </p>
<div className="mt-6 space-y-4"> <div className="mt-5 space-y-3">
<a <a
href={businessConfig.publicCallUrl} href={businessConfig.publicCallUrl}
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35" className="flex items-start gap-4 rounded-2xl border border-border/55 bg-background/65 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"> <div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Phone className="h-5 w-5" /> <Phone className="h-5 w-5" />
@ -80,7 +124,7 @@ export function ContactPage() {
<a <a
href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`} href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`}
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35" className="flex items-start gap-4 rounded-2xl border border-border/55 bg-background/65 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"> <div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
@ -113,7 +157,7 @@ export function ContactPage() {
</div> </div>
</div> </div>
<div className="mt-5 space-y-2"> <div className="mt-4 space-y-2">
{businessHours.map((schedule) => ( {businessHours.map((schedule) => (
<PublicInset <PublicInset
key={schedule.day} key={schedule.day}

View file

@ -5,6 +5,7 @@ import { Breadcrumbs, type BreadcrumbItem } from "@/components/breadcrumbs"
import { import {
PublicInset, PublicInset,
PublicPageHeader, PublicPageHeader,
PublicProse,
PublicSurface, PublicSurface,
} from "@/components/public-surface" } from "@/components/public-surface"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -21,6 +22,7 @@ interface DropdownPageShellProps {
title: string title: string
description?: string description?: string
headerContent?: ReactNode headerContent?: ReactNode
contentIntro?: ReactNode
content: ReactNode content: ReactNode
contentClassName?: string contentClassName?: string
contentSurfaceClassName?: string contentSurfaceClassName?: string
@ -41,6 +43,7 @@ export function DropdownPageShell({
title, title,
description, description,
headerContent, headerContent,
contentIntro,
content, content,
contentClassName, contentClassName,
contentSurfaceClassName, contentSurfaceClassName,
@ -61,19 +64,43 @@ export function DropdownPageShell({
{headerContent} {headerContent}
</PublicPageHeader> </PublicPageHeader>
<section className="mt-10"> {contentIntro ? (
<section className="mt-10 grid gap-5 lg:grid-cols-2">{contentIntro}</section>
) : null}
<section className={cn(contentIntro ? "mt-8" : "mt-10")}>
<PublicSurface <PublicSurface
className={cn("overflow-hidden", contentSurfaceClassName)} className={cn(
"relative overflow-hidden p-0 md:p-0",
contentSurfaceClassName
)}
> >
<div className={cn("max-w-none", contentClassName)}>{content}</div> <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>
</PublicSurface> </PublicSurface>
</section> </section>
{sections ? <div className="mt-12 space-y-12">{sections}</div> : null} {sections ? <div className="mt-14 space-y-14">{sections}</div> : null}
{cta ? ( {cta ? (
<section className="mt-12"> <section className="mt-14">
<PublicSurface className="text-center"> <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%)]" />
{cta.eyebrow ? ( {cta.eyebrow ? (
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
{cta.eyebrow} {cta.eyebrow}
@ -99,7 +126,7 @@ export function DropdownPageShell({
))} ))}
</div> </div>
{cta.note ? ( {cta.note ? (
<PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center"> <PublicInset className="mx-auto mt-6 max-w-2xl border-primary/10 text-left sm:text-center">
{cta.note} {cta.note}
</PublicInset> </PublicInset>
) : null} ) : null}

View file

@ -8,15 +8,15 @@ const features = [
title: "Free Placement", title: "Free Placement",
description: description:
"For qualifying locations, we can place the machines, stock them, and stay responsible for day-to-day service after install.", "For qualifying locations, we can place the machines, stock them, and stay responsible for day-to-day service after install.",
link: "/about-us", link: "/#how-it-works",
linkText: "How placement works", linkText: "How placement works",
}, },
{ {
icon: Wrench, icon: Wrench,
title: "Repairs and Service", title: "Repairs and Services",
description: description:
"We handle repairs, restocking, and routine service so your team does not have to manage the machines.", "We handle repairs, restocking, and routine service so your team does not have to manage the machines.",
link: "/services/repairs", link: "/services/repairs#request-service",
linkText: "Repair services", 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="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]"> <div className="grid gap-6 lg:grid-cols-[1.2fr_0.9fr_0.9fr_1fr]">
{/* Company Info */} {/* Company Info */}
<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"> <div className="px-6 py-5 lg:col-span-1">
<Link href="/" className="inline-flex"> <Link href="/" className="inline-flex">
<Image <Image
src="/rmv-logo.png" src="/rmv-logo.png"
@ -104,7 +104,7 @@ export function Footer() {
</div> </div>
{/* Services */} {/* Services */}
<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)]"> <div className="px-5 py-5">
<h3 className="font-semibold mb-5 text-base">Services</h3> <h3 className="font-semibold mb-5 text-base">Services</h3>
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="space-y-3 text-sm text-muted-foreground">
<li> <li>
@ -159,7 +159,7 @@ export function Footer() {
</div> </div>
{/* Company */} {/* Company */}
<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)]"> <div className="px-5 py-5">
<h3 className="font-semibold mb-5 text-base">Company</h3> <h3 className="font-semibold mb-5 text-base">Company</h3>
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="space-y-3 text-sm text-muted-foreground">
<li> <li>
@ -198,7 +198,7 @@ export function Footer() {
</div> </div>
{/* Service Areas */} {/* Service Areas */}
<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)]"> <div className="px-5 py-5">
<h3 className="font-semibold mb-5 text-base">Service Areas</h3> <h3 className="font-semibold mb-5 text-base">Service Areas</h3>
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="space-y-3 text-sm text-muted-foreground">
<li> <li>

View file

@ -162,9 +162,16 @@ export function Header() {
{ label: "Reviews", href: "/reviews" }, { label: "Reviews", href: "/reviews" },
{ label: "FAQs", href: "/about/faqs" }, { 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 = const desktopLinkClassName =
"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" "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"
const mobileLinkClassName = 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" "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 = const mobileGroupButtonClassName =
@ -175,7 +182,7 @@ export function Header() {
return ( 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"> <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="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 lg:gap-6"> <div className="flex h-[var(--header-height)] items-center justify-between gap-3 md:hidden lg:gap-6">
{/* Logo */} {/* Logo */}
<Link <Link
href="/" href="/"
@ -192,7 +199,7 @@ export function Header() {
</Link> </Link>
{/* Desktop Navigation */} {/* Desktop Navigation */}
<nav className="hidden flex-1 items-center justify-center gap-1 md:flex lg:gap-2"> <nav className="hidden flex-1 items-center justify-center gap-1 2xl:flex 2xl:gap-2">
<Link href="/" className={desktopLinkClassName}> <Link href="/" className={desktopLinkClassName}>
Home Home
</Link> </Link>
@ -371,7 +378,7 @@ export function Header() {
</nav> </nav>
{/* Desktop CTA */} {/* Desktop CTA */}
<div className="hidden flex-shrink-0 items-center gap-2 md:flex lg:gap-3"> <div className="hidden flex-shrink-0 items-center gap-2 2xl:flex 2xl:gap-3">
<CartButton <CartButton
onClick={() => dispatch({ type: "SET_CART", value: true })} onClick={() => dispatch({ type: "SET_CART", value: true })}
/> />
@ -393,7 +400,7 @@ export function Header() {
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
<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 md: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 2xl:hidden"
onClick={() => dispatch({ type: "TOGGLE_MENU" })} onClick={() => dispatch({ type: "TOGGLE_MENU" })}
aria-label="Toggle menu" aria-label="Toggle menu"
aria-expanded={state.isMenuOpen} aria-expanded={state.isMenuOpen}
@ -406,6 +413,161 @@ export function Header() {
</button> </button>
</div> </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 */} {/* Mobile Navigation */}
{state.isMenuOpen && ( {state.isMenuOpen && (
<nav className="border-t border-border/40 py-5 md:hidden"> <nav className="border-t border-border/40 py-5 md:hidden">

View file

@ -114,11 +114,52 @@ export function LocationLandingPage({
}: { }: {
locationData: LocationData locationData: LocationData
}) { }) {
const isSaltLakeCity = locationData.slug === "salt-lake-city-utah"
const countyName = getCountyName(locationData.slug) const countyName = getCountyName(locationData.slug)
const industries = getIndustryFocus(locationData) const industries = getIndustryFocus(locationData)
const canonicalUrl = buildAbsoluteUrl(buildLocationRoute(locationData.slug)) const canonicalUrl = buildAbsoluteUrl(buildLocationRoute(locationData.slug))
const title = `Vending Machines in ${locationData.city}, ${locationData.stateAbbr}` 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 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 = { const structuredData = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -188,15 +229,15 @@ export function LocationLandingPage({
<PublicPageHeader <PublicPageHeader
align="center" align="center"
eyebrow="Local Service Area" eyebrow="Local Service Area"
title={`${locationData.city}, ${locationData.stateAbbr} vending machine service`} title={`Vending machine service for businesses in ${locationData.city}, ${locationData.stateAbbr}`}
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.`} 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.`}
className="mb-12 md:mb-16" className="mb-12 md:mb-16"
/> />
<div className="mx-auto mb-12 grid max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]"> <div className="mx-auto mb-14 grid max-w-5xl gap-6 lg:grid-cols-[1.08fr_0.92fr]">
<PublicSurface className="p-6 md:p-8"> <PublicSurface className="p-6 md:p-8">
<h2 className="text-2xl font-semibold tracking-tight text-balance"> <h2 className="text-2xl font-semibold tracking-tight text-balance">
Vending service for businesses across {locationData.city} A local vending partner for businesses across {locationData.city}
</h2> </h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground"> <p className="mt-4 text-base leading-relaxed text-muted-foreground">
If your business is in {locationData.neighborhoods.join(", ")}, or If your business is in {locationData.neighborhoods.join(", ")}, or
@ -219,11 +260,11 @@ export function LocationLandingPage({
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance"> <h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
Common business types we serve in {locationData.city} Common business types we serve in {locationData.city}
</h2> </h2>
<div className="mt-5 flex flex-wrap gap-2"> <div className="mt-5 flex flex-wrap gap-2.5">
{industries.map((industry) => ( {industries.map((industry) => (
<span <span
key={industry} key={industry}
className="rounded-full border border-border/60 bg-background px-3 py-1 text-sm text-muted-foreground" className="rounded-full border border-border/60 bg-background px-3 py-1.5 text-sm text-muted-foreground"
> >
{industry} {industry}
</span> </span>
@ -236,6 +277,15 @@ export function LocationLandingPage({
Utah service area. Utah service area.
</p> </p>
</PublicInset> </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> </PublicSurface>
</div> </div>
@ -243,7 +293,7 @@ export function LocationLandingPage({
<h2 className="text-3xl font-bold tracking-tight text-balance"> <h2 className="text-3xl font-bold tracking-tight text-balance">
Vending services available in {locationData.city} Vending services available in {locationData.city}
</h2> </h2>
<div className="mt-8 grid gap-6 md:grid-cols-2"> <div className="mt-8 grid gap-5 md:grid-cols-2">
{[ {[
{ {
title: "Free vending placement", title: "Free vending placement",
@ -270,7 +320,7 @@ export function LocationLandingPage({
cta: "View manuals and parts", cta: "View manuals and parts",
}, },
].map((service) => ( ].map((service) => (
<PublicSurface key={service.title} className="h-full p-6"> <PublicSurface key={service.title} className="h-full p-6 md:p-7">
<h3 className="text-xl font-semibold">{service.title}</h3> <h3 className="text-xl font-semibold">{service.title}</h3>
<p className="mt-3 leading-7 text-muted-foreground"> <p className="mt-3 leading-7 text-muted-foreground">
{service.body} {service.body}
@ -287,6 +337,95 @@ export function LocationLandingPage({
</div> </div>
</section> </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]"> <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"> <PublicSurface className="p-6 md:p-8">
<h2 className="text-3xl font-bold tracking-tight text-balance"> <h2 className="text-3xl font-bold tracking-tight text-balance">
@ -405,7 +544,7 @@ export function LocationLandingPage({
vending help you need. We&apos;ll follow up with the next best vending help you need. We&apos;ll follow up with the next best
option for your location. option for your location.
</p> </p>
<div className="mt-6 flex flex-col items-center gap-3"> <div className="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
<GetFreeMachineCta buttonLabel="See If Your Location Qualifies" /> <GetFreeMachineCta buttonLabel="See If Your Location Qualifies" />
<Link <Link
href="/contact-us#contact-form" href="/contact-us#contact-form"

View file

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

View file

@ -28,9 +28,6 @@ import {
ShoppingCart, ShoppingCart,
LayoutGrid, LayoutGrid,
List, List,
ExternalLink,
Loader2,
AlertCircle,
} from "lucide-react" } from "lucide-react"
import type { Manual, ManualGroup } from "@/lib/manuals-types" import type { Manual, ManualGroup } from "@/lib/manuals-types"
import { getManualUrl, getThumbnailUrl } from "@/lib/manuals-types" import { getManualUrl, getThumbnailUrl } from "@/lib/manuals-types"
@ -40,176 +37,6 @@ import {
} from "@/lib/manuals-config" } from "@/lib/manuals-config"
import { ManualViewer } from "@/components/manual-viewer" import { ManualViewer } from "@/components/manual-viewer"
import { getManualsWithParts } from "@/lib/parts-lookup" 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 { interface ManualsPageClientProps {
manuals: Manual[] manuals: Manual[]
@ -238,7 +65,7 @@ function ManualCard({
const thumbnailUrl = getThumbnailUrl(manual) const thumbnailUrl = getThumbnailUrl(manual)
return ( return (
<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)]"> <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)]">
{thumbnailUrl && ( {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))]"> <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 <Image
@ -250,13 +77,13 @@ function ManualCard({
/> />
</div> </div>
)} )}
<CardHeader className="px-5 pt-5"> <CardHeader className="space-y-3 px-5 pt-5">
<CardTitle className="line-clamp-2 text-base leading-snug"> <CardTitle className="line-clamp-2 text-base leading-snug">
{manual.filename.replace(/\.pdf$/i, "")} {manual.filename.replace(/\.pdf$/i, "")}
</CardTitle> </CardTitle>
{manual.commonNames && manual.commonNames.length > 0 && ( {manual.commonNames && manual.commonNames.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{manual.commonNames.map((name, index) => ( {manual.commonNames.slice(0, 3).map((name, index) => (
<Badge <Badge
key={index} key={index}
variant="secondary" variant="secondary"
@ -265,13 +92,21 @@ function ManualCard({
{name} {name}
</Badge> </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> </div>
)} )}
{manual.searchTerms && {manual.searchTerms &&
manual.searchTerms.length > 0 && manual.searchTerms.length > 0 &&
!manual.commonNames && ( !manual.commonNames && (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{manual.searchTerms.map((term, index) => ( {manual.searchTerms.slice(0, 4).map((term, index) => (
<Badge <Badge
key={index} key={index}
variant="secondary" variant="secondary"
@ -280,6 +115,14 @@ function ManualCard({
{term} {term}
</Badge> </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> </div>
)} )}
</CardHeader> </CardHeader>
@ -303,7 +146,7 @@ function ManualCard({
</Badge> </Badge>
)} )}
</div> </div>
<div className="text-sm text-muted-foreground space-y-1"> <div className="space-y-1.5 text-sm text-muted-foreground">
{showManufacturer && ( {showManufacturer && (
<p> <p>
<strong>Manufacturer:</strong> {manual.manufacturer} <strong>Manufacturer:</strong> {manual.manufacturer}
@ -647,9 +490,28 @@ export function ManualsPageClient({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Search and Filter Controls */} {/* Search and Filter Controls */}
<PublicSurface> <PublicSurface className="p-4 md:p-6">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="space-y-6"> <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>
{/* Search Bar */} {/* Search Bar */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
@ -663,12 +525,14 @@ export function ManualsPageClient({
</div> </div>
{/* Filters Row */} {/* Filters Row */}
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4 items-start sm:items-center"> <div className="rounded-[1.5rem] border border-border/60 bg-white/80 p-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 w-full sm:w-auto"> <div className="flex items-center gap-2 w-full sm:w-auto">
<Filter className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Filter className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium">Filters:</span> <span className="text-sm font-medium">Filters:</span>
</div> </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="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full sm:w-auto">
<div className="relative"> <div className="relative">
<Select <Select
@ -766,10 +630,12 @@ export function ManualsPageClient({
Clear Filters Clear Filters
</Button> </Button>
)} )}
</div>
</div>
</div> </div>
{/* View Mode Toggle and Results Count */} {/* 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"> <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 items-center gap-3 w-full sm:w-auto"> <div className="flex items-center gap-3 w-full sm:w-auto">
<span className="text-sm text-muted-foreground flex-shrink-0"> <span className="text-sm text-muted-foreground flex-shrink-0">
View: View:
@ -807,8 +673,8 @@ export function ManualsPageClient({
</div> </div>
<div className="text-sm text-muted-foreground w-full sm:w-auto text-center sm:text-left"> <div className="text-sm text-muted-foreground w-full sm:w-auto text-center sm:text-left">
Showing <strong>{filteredManuals.length}</strong> of{" "} View the full library grouped by manufacturer or switch to list
<strong>{manuals.length}</strong> manuals view for a faster scan.
</div> </div>
</div> </div>
</div> </div>
@ -836,11 +702,15 @@ export function ManualsPageClient({
{filteredGroupedManuals.map((group) => { {filteredGroupedManuals.map((group) => {
const organized = organizeCategories(group.categories) const organized = organizeCategories(group.categories)
return ( return (
<PublicSurface key={group.manufacturer} className="space-y-6"> <PublicSurface key={group.manufacturer} className="space-y-7 p-5 md:p-7">
<div className="border-b border-border/60 pb-3"> <div className="border-b border-border/60 pb-4">
<h2 className="text-2xl font-bold tracking-tight"> <h2 className="text-2xl font-bold tracking-tight">
{group.manufacturer} {group.manufacturer}
</h2> </h2>
<p className="mt-2 text-sm text-muted-foreground">
{Object.values(group.categories).flat().length} manuals
available from this manufacturer.
</p>
</div> </div>
{/* Machine Type Categories First */} {/* Machine Type Categories First */}
@ -878,7 +748,7 @@ export function ManualsPageClient({
</span> </span>
)} )}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{categoryManuals.map((manual) => ( {categoryManuals.map((manual) => (
<ManualCard <ManualCard
key={manual.path} key={manual.path}
@ -921,7 +791,7 @@ export function ManualsPageClient({
) )
</span> </span>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{categoryManuals.map((manual) => ( {categoryManuals.map((manual) => (
<ManualCard <ManualCard
key={manual.path} key={manual.path}
@ -938,28 +808,13 @@ export function ManualsPageClient({
</div> </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> </PublicSurface>
) )
})} })}
</div> </div>
) : ( ) : (
/* List View */ /* List View */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filteredManuals.map((manual) => ( {filteredManuals.map((manual) => (
<ManualCard <ManualCard
key={manual.path} key={manual.path}

View file

@ -3,32 +3,63 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react" import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { EbayCacheState } from "@/lib/ebay-parts-match"
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup" import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
import {
hasTrustedPartsListings,
shouldShowEbayPartsPanel,
} from "@/lib/ebay-parts-visibility"
interface PartsPanelProps { interface PartsPanelProps {
manualFilename: string manualFilename: string
className?: string className?: string
onStateChange?: (state: { isLoading: boolean; isVisible: boolean }) => void
} }
export function PartsPanel({ export function PartsPanel({
manualFilename, manualFilename,
className = "", className = "",
onStateChange,
}: PartsPanelProps) { }: PartsPanelProps) {
const [parts, setParts] = useState<PartForPage[]>([]) const [parts, setParts] = useState<PartForPage[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null) 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 () => { const loadParts = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
setParts([])
setCache(null)
try { try {
const result = await getTopPartsForManual(manualFilename, 5) const result = await getTopPartsForManual(manualFilename, 5)
setParts(result.parts) setParts(result.parts)
setError(result.error ?? null) setError(result.error ?? null)
setCache(result.cache ?? null)
} catch (err) { } catch (err) {
console.error("Error loading parts:", err) console.error("Error loading parts:", err)
setParts([]) setParts([])
setCache(null)
setError("Could not load parts") setError("Could not load parts")
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@ -41,37 +72,25 @@ export function PartsPanel({
} }
}, [loadParts, manualFilename]) }, [loadParts, manualFilename])
const hasListings = parts.some((part) => part.ebayListings.length > 0) const hasListings = hasTrustedPartsListings(parts)
const shouldShowPanel = shouldShowEbayPartsPanel({
isLoading,
parts,
cache,
error,
})
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
const renderStatusCard = (title: string, message: string) => ( useEffect(() => {
<div className={`flex flex-col h-full ${className}`}> if (!onStateChange) {
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30"> return
<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"> onStateChange({
Parts isLoading,
</span> isVisible: shouldShowPanel,
</div> })
</div> }, [isLoading, onStateChange, shouldShowPanel])
<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) { if (isLoading) {
return ( return (
@ -83,6 +102,14 @@ export function PartsPanel({
Parts Parts
</span> </span>
</div> </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>
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center"> <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" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@ -92,53 +119,8 @@ export function PartsPanel({
) )
} }
if (error && !hasListings) { if (!shouldShowPanel) {
const loweredError = error.toLowerCase() return null
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 ( return (
@ -150,6 +132,14 @@ export function PartsPanel({
Parts ({parts.length}) Parts ({parts.length})
</span> </span>
</div> </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>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2"> <div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
@ -159,15 +149,10 @@ export function PartsPanel({
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /> <AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium"> <p className="font-medium">
Live eBay listings are unavailable right now. Cached eBay listings are unavailable right now.
</p> </p>
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70"> <p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
{error.includes("eBay API not configured") {error}
? "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> </p>
</div> </div>
</div> </div>

View file

@ -20,6 +20,11 @@ type PublicPageHeaderProps = {
children?: ReactNode children?: ReactNode
} }
type PublicProseProps = {
className?: string
children: ReactNode
}
export function PublicSection({ export function PublicSection({
id, id,
tone = "default", tone = "default",
@ -102,7 +107,7 @@ export function PublicSurface({
return ( return (
<Component <Component
className={cn( className={cn(
"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", "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",
className className
)} )}
{...props} {...props}
@ -120,7 +125,7 @@ export function PublicInset({
return ( return (
<div <div
className={cn( className={cn(
"rounded-[var(--public-inset-radius)] border border-border/60 bg-white/95 p-4 shadow-[0_10px_28px_rgba(15,23,42,0.06)]", "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)]",
className className
)} )}
{...props} {...props}
@ -144,14 +149,29 @@ export function PublicSectionHeader({
className, className,
}: PublicSectionHeaderProps) { }: PublicSectionHeaderProps) {
return ( return (
<div className={cn("space-y-2", className)}> <div className={cn("space-y-2.5", className)}>
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80"> <p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
{eyebrow} {eyebrow}
</p> </p>
<h2 className="text-xl font-semibold tracking-tight text-foreground md:text-[1.375rem]"> <h2 className="text-xl font-semibold tracking-tight text-foreground md:text-[1.375rem]">
{title} {title}
</h2> </h2>
<p className="text-sm leading-6 text-muted-foreground">{description}</p> <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}
</div> </div>
) )
} }

View file

@ -11,7 +11,7 @@ export function RequestMachineSection() {
<section id="request-machine" className="bg-background py-16 md:py-24"> <section id="request-machine" className="bg-background py-16 md:py-24">
<div className="container mx-auto px-4"> <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"> <div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
<PublicSurface className="bg-white p-6 md:p-8 lg:sticky lg:top-28"> <PublicSurface className="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"> <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" /> <Package className="h-4 w-4" />
Free Placement Free Placement
@ -61,7 +61,7 @@ export function RequestMachineSection() {
</div> </div>
</PublicSurface> </PublicSurface>
<PublicSurface className="bg-white p-5 md:p-7"> <PublicSurface className="p-5 md:p-7">
<RequestMachineForm <RequestMachineForm
onSubmit={(data) => onSubmit={(data) =>
console.log("Machine request form submitted:", data) console.log("Machine request form submitted:", data)

View file

@ -9,6 +9,47 @@ import {
PublicSurface, PublicSurface,
} from "@/components/public-surface" } 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() { export function ReviewsPage() {
useEffect(() => { useEffect(() => {
const existingScript = document.querySelector( const existingScript = document.querySelector(
@ -44,8 +85,65 @@ export function ReviewsPage() {
align="center" align="center"
eyebrow="Customer Reviews" eyebrow="Customer Reviews"
title="What Utah businesses say about working with Rocky Mountain Vending." title="What Utah businesses say about working with Rocky Mountain Vending."
description="Browse the live Google review feed and see what Utah businesses say about placement, restocking, repairs, and service." 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>
<section className="mt-12"> <section className="mt-12">
<PublicSurface className="overflow-hidden p-5 md:p-7"> <PublicSurface className="overflow-hidden p-5 md:p-7">
@ -69,9 +167,9 @@ export function ReviewsPage() {
</div> </div>
</div> </div>
<div className="mt-6"> <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">
<iframe <iframe
className="lc_reviews_widget min-h-[900px] w-full rounded-[1.5rem] border border-border/60 bg-background" className="lc_reviews_widget min-h-[620px] w-full rounded-[1.35rem] border border-border/60 bg-background md:min-h-[780px]"
src="https://reputationhub.site/reputation/widgets/review_widget/YAoWLgNSid8oG44j9BjG" src="https://reputationhub.site/reputation/widgets/review_widget/YAoWLgNSid8oG44j9BjG"
frameBorder="0" frameBorder="0"
scrolling="no" scrolling="no"
@ -81,7 +179,7 @@ export function ReviewsPage() {
</PublicSurface> </PublicSurface>
</section> </section>
<section className="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]"> <section className="mt-12 grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<PublicSurface> <PublicSurface>
<h2 className="text-3xl font-semibold tracking-tight text-balance"> <h2 className="text-3xl font-semibold tracking-tight text-balance">
What businesses usually want to verify before they choose a vendor What businesses usually want to verify before they choose a vendor
@ -120,42 +218,65 @@ export function ReviewsPage() {
<PublicSurface className="flex flex-col justify-between"> <PublicSurface className="flex flex-col justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Next Step Why It Matters
</p> </p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance"> <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
Want to see whether your location qualifies? Reviews are usually the last confidence check before someone reaches out.
</h2> </h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground"> <p className="mt-3 text-base leading-relaxed text-muted-foreground">
Tell us about your traffic, breakroom, or customer area and Most businesses are trying to verify the same things: follow-through,
we&apos;ll help you decide between free placement, machine sales, communication, and whether the machines stay stocked and working
or service help. after install. If that sounds like your checklist too, we can help
you sort through next steps quickly.
</p> </p>
</div> </div>
<div className="mt-6 grid gap-4 sm:grid-cols-2"> <div className="mt-6 grid gap-4">
<Link <PublicInset className="p-5">
href="/#request-machine" <p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
className="rounded-[1.5rem] border border-border/60 bg-white p-5 text-left transition hover:border-primary/30 hover:text-primary" Common Questions
>
<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> </p>
</Link> <ul className="mt-3 space-y-3 text-sm leading-relaxed text-muted-foreground">
<Link <li>Does this location qualify for free placement?</li>
href="/contact-us#contact-form" <li>Can Rocky handle repairs and restocking without extra staff work on our side?</li>
className="rounded-[1.5rem] border border-border/60 bg-white p-5 text-left transition hover:border-primary/30 hover:text-primary" <li>Should we ask about placement, machine sales, or direct service help?</li>
> </ul>
<h3 className="text-lg font-semibold text-foreground"> </PublicInset>
Service or Sales <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
</h3> <Link
<p className="mt-2 text-sm leading-relaxed text-muted-foreground"> href="/#request-machine"
Reach out about repairs, moving, manuals, parts, or machine className="rounded-[1.5rem] border border-border/55 bg-background/70 p-5 text-left transition hover:border-primary/30 hover:text-primary"
sales. >
</p> <h3 className="text-lg font-semibold text-foreground">
</Link> 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>
</div> </div>
</PublicSurface> </PublicSurface>
</section> </section>

View file

@ -4,6 +4,8 @@ import { ReactNode } from "react"
import { CheckCircle2 } from "lucide-react" import { CheckCircle2 } from "lucide-react"
import { DropdownPageShell } from "@/components/dropdown-page-shell" import { DropdownPageShell } from "@/components/dropdown-page-shell"
import { PublicInset } from "@/components/public-surface" import { PublicInset } from "@/components/public-surface"
import { Button } from "@/components/ui/button"
import Link from "next/link"
interface WhoWeServePageProps { interface WhoWeServePageProps {
title: string title: string
@ -55,13 +57,58 @@ export function WhoWeServePage({
description || description ||
"See how Rocky Mountain Vending adapts machine placement, product mix, and ongoing service to the way this kind of location actually runs." "See how Rocky Mountain Vending adapts machine placement, product mix, and ongoing service to the way this kind of location actually runs."
} }
content={ headerContent={
<div className="text-foreground">{content}</div> <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>
} }
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" 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>
}
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"
sections={ sections={
<section> <section>
<div className="mx-auto mb-6 max-w-3xl text-center"> <div className="mx-auto mb-8 max-w-3xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Why Rocky Why Rocky
</p> </p>
@ -107,6 +154,12 @@ export function WhoWeServePage({
variant: "outline", 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>
),
}} }}
/> />
) )

114
convex/contactProfiles.ts Normal file
View file

@ -0,0 +1,114 @@
// @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)
},
})

1976
convex/crm.ts Normal file

File diff suppressed because it is too large Load diff

497
convex/crmModel.ts Normal file
View file

@ -0,0 +1,497 @@
// @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)
}

20
convex/crons.ts Normal file
View file

@ -0,0 +1,20 @@
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

648
convex/ebay.ts Normal file
View file

@ -0,0 +1,648 @@
// @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)
},
})

229
convex/ghlMirror.ts Normal file
View file

@ -0,0 +1,229 @@
// @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,6 +1,12 @@
// @ts-nocheck // @ts-nocheck
import { action, mutation } from "./_generated/server" import { action, mutation } from "./_generated/server"
import { v } from "convex/values" import { v } from "convex/values"
import {
ensureConversationParticipant,
upsertContactRecord,
upsertConversationRecord,
upsertMessageRecord,
} from "./crmModel"
const leadSyncStatus = v.union( const leadSyncStatus = v.union(
v.literal("pending"), v.literal("pending"),
@ -119,8 +125,60 @@ export const createLead = mutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const now = Date.now() 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", { return await ctx.db.insert("leadSubmissions", {
...args, ...args,
contactId: contact?._id,
conversationId: conversation?._id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
deliveredAt: args.status === "delivered" ? now : undefined, deliveredAt: args.status === "delivered" ? now : undefined,
@ -176,6 +234,54 @@ export const ingestLead = mutation({
const fallbackName = splitName(args.name) const fallbackName = splitName(args.name)
const type = mapServiceToType(args.service) const type = mapServiceToType(args.service)
const now = Date.now() 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", { const leadId = await ctx.db.insert("leadSubmissions", {
type, type,
status: "pending", status: "pending",
@ -198,6 +304,8 @@ export const ingestLead = mutation({
consentVersion: args.consentVersion, consentVersion: args.consentVersion,
consentCapturedAt: args.consentCapturedAt, consentCapturedAt: args.consentCapturedAt,
consentSourcePage: args.consentSourcePage, consentSourcePage: args.consentSourcePage,
contactId: contact?._id,
conversationId: conversation?._id,
usesendStatus: "pending", usesendStatus: "pending",
ghlStatus: "pending", ghlStatus: "pending",
createdAt: now, createdAt: now,
@ -241,6 +349,22 @@ export const updateLeadSyncStatus = mutation({
updatedAt: now, 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) return await ctx.db.get(args.leadId)
}, },
}) })

View file

@ -1,6 +1,11 @@
// @ts-nocheck // @ts-nocheck
import { mutation, query } from "./_generated/server" import { mutation, query } from "./_generated/server"
import { v } from "convex/values" import { v } from "convex/values"
import {
canonicalizeTenantDomain,
manualVisibleForTenant,
tenantDomainVariants,
} from "../lib/manuals-tenant"
const manualInput = v.object({ const manualInput = v.object({
filename: v.string(), filename: v.string(),
@ -23,10 +28,19 @@ const manualInput = v.object({
}) })
export const list = query({ export const list = query({
args: {}, args: {
handler: async (ctx) => { domain: v.string(),
},
handler: async (ctx, args) => {
const tenantDomain = canonicalizeTenantDomain(args.domain)
if (!tenantDomain) {
return []
}
const manuals = await ctx.db.query("manuals").collect() const manuals = await ctx.db.query("manuals").collect()
return manuals.sort((a, b) => a.filename.localeCompare(b.filename)) return manuals
.filter((manual) => manualVisibleForTenant(manual, tenantDomain))
.sort((a, b) => a.filename.localeCompare(b.filename))
}, },
}) })
@ -106,3 +120,64 @@ export const upsertMany = mutation({
return results 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,6 +90,50 @@ export default defineSchema({
.index("by_category", ["category"]) .index("by_category", ["category"])
.index("by_path", ["path"]), .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({ manualCategories: defineTable({
name: v.string(), name: v.string(),
slug: v.string(), slug: v.string(),
@ -145,6 +189,8 @@ export default defineSchema({
v.literal("skipped") v.literal("skipped")
) )
), ),
contactId: v.optional(v.id("contacts")),
conversationId: v.optional(v.id("conversations")),
error: v.optional(v.string()), error: v.optional(v.string()),
deliveredAt: v.optional(v.number()), deliveredAt: v.optional(v.number()),
createdAt: v.number(), createdAt: v.number(),
@ -199,6 +245,186 @@ export default defineSchema({
.index("by_kind", ["kind"]) .index("by_kind", ["kind"])
.index("by_status", ["status"]), .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({ voiceSessions: defineTable({
roomName: v.string(), roomName: v.string(),
participantIdentity: v.string(), participantIdentity: v.string(),
@ -248,6 +474,8 @@ export default defineSchema({
recordingUrl: v.optional(v.string()), recordingUrl: v.optional(v.string()),
recordingError: v.optional(v.string()), recordingError: v.optional(v.string()),
metadata: v.optional(v.string()), metadata: v.optional(v.string()),
contactId: v.optional(v.id("contacts")),
conversationId: v.optional(v.id("conversations")),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })

View file

@ -1,6 +1,68 @@
// @ts-nocheck // @ts-nocheck
import { mutation, query } from "./_generated/server" import { mutation, query } from "./_generated/server"
import { v } from "convex/values" 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({ export const getByRoom = query({
args: { args: {
@ -83,7 +145,7 @@ export const createSession = mutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const now = args.startedAt ?? Date.now() const now = args.startedAt ?? Date.now()
return await ctx.db.insert("voiceSessions", { const id = await ctx.db.insert("voiceSessions", {
...args, ...args,
startedAt: now, startedAt: now,
callStatus: args.callStatus, callStatus: args.callStatus,
@ -94,6 +156,13 @@ export const createSession = mutation({
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
const session = await ctx.db.get(id)
if (session) {
await syncPhoneConversation(ctx, session)
}
return id
}, },
}) })
@ -141,7 +210,11 @@ export const upsertPhoneCallSession = mutation({
notificationStatus: existing.notificationStatus || "pending", notificationStatus: existing.notificationStatus || "pending",
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
return await ctx.db.get(existing._id) const updated = await ctx.db.get(existing._id)
if (updated) {
await syncPhoneConversation(ctx, updated)
}
return updated
} }
const id = await ctx.db.insert("voiceSessions", { const id = await ctx.db.insert("voiceSessions", {
@ -164,7 +237,11 @@ export const upsertPhoneCallSession = mutation({
updatedAt: now, updatedAt: now,
}) })
return await ctx.db.get(id) const session = await ctx.db.get(id)
if (session) {
await syncPhoneConversation(ctx, session)
}
return session
}, },
}) })
@ -203,6 +280,33 @@ export const addTranscriptTurn = mutation({
: session.agentAnsweredAt, : session.agentAnsweredAt,
updatedAt: Date.now(), 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 return turnId
@ -231,8 +335,21 @@ export const linkPhoneCallLead = mutation({
handoffReason: args.handoffReason, handoffReason: args.handoffReason,
updatedAt: Date.now(), 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 await ctx.db.get(args.sessionId) return session
}, },
}) })
@ -260,7 +377,21 @@ export const updateRecording = mutation({
recordingError: args.recordingError, recordingError: args.recordingError,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
return await ctx.db.get(args.sessionId) 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
}, },
}) })
@ -310,7 +441,31 @@ export const completeSession = mutation({
notificationError: args.notificationError, notificationError: args.notificationError,
updatedAt: endedAt, updatedAt: endedAt,
}) })
return await ctx.db.get(args.sessionId) 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
}, },
}) })

View file

@ -0,0 +1,61 @@
# 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

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

516
lib/ebay-parts-match.ts Normal file
View file

@ -0,0 +1,516 @@
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

@ -0,0 +1,92 @@
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

@ -0,0 +1,43 @@
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
}

429
lib/google-calendar.ts Normal file
View file

@ -0,0 +1,429 @@
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

@ -0,0 +1,70 @@
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)
})

127
lib/manuals-asset-paths.ts Normal file
View file

@ -0,0 +1,127 @@
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

@ -0,0 +1,35 @@
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

@ -0,0 +1,33 @@
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

@ -0,0 +1,90 @@
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"
)
})

79
lib/manuals-tenant.ts Normal file
View file

@ -0,0 +1,79 @@
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

@ -0,0 +1,24 @@
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

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

View file

@ -15,9 +15,13 @@ export type AdminPhoneCallDetail = {
id: string id: string
roomName: string roomName: string
participantIdentity: string participantIdentity: string
callerPhone?: string
pathname?: string pathname?: string
pageUrl?: string pageUrl?: string
source?: string source?: string
contactProfileId?: string
contactDisplayName?: string
contactCompany?: string
startedAt: number startedAt: number
endedAt?: number endedAt?: number
durationMs: number | null durationMs: number | null
@ -33,6 +37,18 @@ export type AdminPhoneCallDetail = {
notificationStatus: "pending" | "sent" | "failed" | "disabled" notificationStatus: "pending" | "sent" | "failed" | "disabled"
notificationSentAt?: number notificationSentAt?: number
notificationError?: string 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?: recordingStatus?:
| "pending" | "pending"
| "starting" | "starting"
@ -55,6 +71,21 @@ export type AdminPhoneCallDetail = {
message?: string message?: string
createdAt: number 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[] turns: AdminPhoneCallTurn[]
} }
@ -121,20 +152,39 @@ export function buildPhoneCallSummary(
"" ""
const callerNumber = const callerNumber =
detail.call.callerPhone ||
normalizePhoneFromIdentity(detail.call.participantIdentity) || normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity detail.call.participantIdentity
const parts = [ const parts = [
`Caller: ${callerNumber || "Unknown caller"}.`, `Caller: ${detail.call.contactDisplayName || callerNumber || "Unknown caller"}.`,
answeredLabel, answeredLabel,
leadLabel, leadLabel,
] ]
if (detail.call.contactCompany) {
parts.push(`Company: ${detail.call.contactCompany}.`)
}
if (detail.call.handoffRequested) { if (detail.call.handoffRequested) {
parts.push( parts.push(
`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}` `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) { if (leadMessage) {
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`) parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`)
} }
@ -169,8 +219,10 @@ export async function sendPhoneCallSummaryEmail(args: {
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}` const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`
const summaryText = buildPhoneCallSummary(args.detail) const summaryText = buildPhoneCallSummary(args.detail)
const callerNumber = const callerNumber =
args.detail.call.callerPhone ||
normalizePhoneFromIdentity(args.detail.call.participantIdentity) || normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
"Unknown caller" "Unknown caller"
const callerLabel = args.detail.call.contactDisplayName || callerNumber
const statusLabel = args.detail.call.callStatus.toUpperCase() const statusLabel = args.detail.call.callStatus.toUpperCase()
const transcriptHtml = args.detail.turns const transcriptHtml = args.detail.turns
@ -189,13 +241,24 @@ export async function sendPhoneCallSummaryEmail(args: {
const html = ` const html = `
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.6;"> <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> <h1 style="font-size: 20px; margin-bottom: 16px;">Rocky Mountain Vending phone call summary</h1>
<p><strong>Caller:</strong> ${callerNumber}</p> <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>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p> <p><strong>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p>
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p> <p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
<p><strong>Call status:</strong> ${statusLabel}</p> <p><strong>Call status:</strong> ${statusLabel}</p>
<p><strong>Jessica answered:</strong> ${args.detail.call.answered ? "Yes" : "No"}</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>Lead outcome:</strong> ${args.detail.call.leadOutcome}</p>
<p><strong>Handoff requested:</strong> ${args.detail.call.handoffRequested ? "Yes" : "No"}</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 status:</strong> ${args.detail.call.recordingStatus || "Unavailable"}</p>
<p><strong>Recording URL:</strong> ${ <p><strong>Recording URL:</strong> ${
args.detail.call.recordingUrl args.detail.call.recordingUrl
@ -214,7 +277,7 @@ export async function sendPhoneCallSummaryEmail(args: {
await sendTransactionalEmail({ await sendTransactionalEmail({
from: fromEmail, from: fromEmail,
to: adminEmail, to: adminEmail,
subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`, subject: `[RMV Phone] ${statusLabel} call from ${callerLabel}`,
html, html,
}) })

View file

@ -0,0 +1,41 @@
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,4 +1,12 @@
import { createHash, randomBytes } from "node:crypto"
import { cookies } from "next/headers"
import { fetchMutation, fetchQuery } from "convex/nextjs"
import { NextResponse } from "next/server" 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) { function getProvidedToken(request: Request) {
const authHeader = request.headers.get("authorization") || "" const authHeader = request.headers.get("authorization") || ""
@ -30,3 +38,123 @@ export function requireAdminToken(request: Request) {
export function isAdminUiEnabled() { export function isAdminUiEnabled() {
return process.env.ADMIN_UI_ENABLED === "true" 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,12 +143,16 @@ function getConfiguredTenantDomains() {
} }
function defaultDeps(): LeadSubmissionDeps { function defaultDeps(): LeadSubmissionDeps {
const ghlSyncEnabled = String(process.env.ENABLE_GHL_SYNC || "")
.trim()
.toLowerCase() === "true"
return { return {
storageConfigured: isConvexConfigured(), storageConfigured: isConvexConfigured(),
emailConfigured: isEmailConfigured(), emailConfigured: isEmailConfigured(),
ghlConfigured: Boolean( ghlConfigured:
process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID ghlSyncEnabled &&
), Boolean(process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID),
ingest: ingestLead, ingest: ingestLead,
updateLeadStatus: updateLeadSyncStatus, updateLeadStatus: updateLeadSyncStatus,
sendEmail: (to, subject, html, replyTo) => sendEmail: (to, subject, html, replyTo) =>

130
lib/server/ghl-sync.ts Normal file
View file

@ -0,0 +1,130 @@
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

@ -0,0 +1,208 @@
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)
}

27
lib/service-knowledge.ts Normal file
View file

@ -0,0 +1,27 @@
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,12 +7,19 @@
"scripts": { "scripts": {
"build": "next build", "build": "next build",
"copy:check": "node scripts/check-public-copy.mjs", "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:env": "node scripts/deploy-readiness.mjs",
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build", "deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build && pnpm deploy:staging:convex-gate",
"deploy:staging:smoke": "node scripts/staging-smoke.mjs", "deploy:staging:smoke": "node scripts/staging-smoke.mjs",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "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": "tsx scripts/sync-manuals-to-convex.ts",
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run", "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.')\"", "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", "dev": "next dev",
"lint": "eslint .", "lint": "eslint .",
@ -30,6 +37,7 @@
"lighthouse:ci": "lighthouse-ci autorun", "lighthouse:ci": "lighthouse-ci autorun",
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
"generate:links": "node scripts/generate-internal-links.js", "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", "links": "node scripts/generate-internal-links.js",
"mcp": "pnpm dlx shadcn@latest mcp", "mcp": "pnpm dlx shadcn@latest mcp",
"seo:sitemap": "node scripts/seo-internal-link-tool.js sitemap", "seo:sitemap": "node scripts/seo-internal-link-tool.js sitemap",

View file

@ -0,0 +1,60 @@
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

@ -0,0 +1,82 @@
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,6 +112,35 @@ function readValue(name) {
return String(process.env[name] ?? "").trim() 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() { function hasVoiceRecordingConfig() {
return [ return [
readValue("VOICE_RECORDING_ACCESS_KEY_ID") || readValue("VOICE_RECORDING_ACCESS_KEY_ID") ||
@ -234,6 +263,21 @@ function main() {
console.log(`${group.label}: fallback in use`) 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()) { if (!hasManualStorageCredentials()) {
failures.push( failures.push(
"Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release." "Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release."

View file

@ -0,0 +1,166 @@
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)
})

582
scripts/staging-smoke.mjs Normal file
View file

@ -0,0 +1,582 @@
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,6 +12,11 @@ import {
import { businessConfig } from "../lib/seo-config" import { businessConfig } from "../lib/seo-config"
import { getSiteDomain } from "../lib/site-config" import { getSiteDomain } from "../lib/site-config"
import { selectManualsForSite } from "../lib/manuals-site-selection" import { selectManualsForSite } from "../lib/manuals-site-selection"
import {
canonicalizeTenantDomain,
resolveManualsTenantDomain,
tenantDomainVariants,
} from "../lib/manuals-tenant"
import { import {
getManualsFilesRoot, getManualsFilesRoot,
getManualsThumbnailsRoot, getManualsThumbnailsRoot,
@ -73,8 +78,15 @@ function readConvexUrl() {
} }
function canonicalSiteVisibility(siteDomain: string) { function canonicalSiteVisibility(siteDomain: string) {
const canonicalHost = new URL(businessConfig.website).hostname const canonicalHost = canonicalizeTenantDomain(
return Array.from(new Set([siteDomain, canonicalHost])) new URL(businessConfig.website).hostname
)
return Array.from(
new Set([
...tenantDomainVariants(siteDomain),
...tenantDomainVariants(canonicalHost),
])
)
} }
async function uploadSelectedAssets( async function uploadSelectedAssets(
@ -169,7 +181,17 @@ function normalizeManualForConvex(
async function main() { async function main() {
const args = parseArgs(process.argv.slice(2)) const args = parseArgs(process.argv.slice(2))
const siteDomain = getSiteDomain() 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 allManuals = await scanManuals() const allManuals = await scanManuals()
const selection = selectManualsForSite(allManuals, siteDomain) const selection = selectManualsForSite(allManuals, siteDomain)
const selectedManuals = args.limit const selectedManuals = args.limit