Compare commits
No commits in common. "main" and "codex/repo-cleanup-standardization" have entirely different histories.
main
...
codex/repo
97 changed files with 984 additions and 11664 deletions
|
|
@ -33,15 +33,6 @@ ADMIN_EMAIL=
|
|||
# Direct phone-call visibility
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
ENABLE_GHL_SYNC=false
|
||||
GOOGLE_CALENDAR_CLIENT_ID=
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
||||
GOOGLE_CALENDAR_ID=
|
||||
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
||||
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
||||
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
||||
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
||||
|
||||
# Placeholder for a later LiveKit rollout
|
||||
LIVEKIT_URL=
|
||||
|
|
|
|||
|
|
@ -33,15 +33,6 @@ ADMIN_EMAIL=
|
|||
ADMIN_PASSWORD=
|
||||
RESEND_API_KEY=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
ENABLE_GHL_SYNC=false
|
||||
GOOGLE_CALENDAR_CLIENT_ID=
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
||||
GOOGLE_CALENDAR_ID=
|
||||
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
||||
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
||||
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
||||
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
|
|
|
|||
|
|
@ -10,18 +10,10 @@ import { FAQSection } from "@/components/faq-section"
|
|||
import { ContactPage } from "@/components/contact-page"
|
||||
import { AboutPage } from "@/components/about-page"
|
||||
import { WhoWeServePage } from "@/components/who-we-serve-page"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicProse,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import {
|
||||
generateLocationPageMetadata,
|
||||
LocationLandingPage,
|
||||
} from "@/components/location-landing-page"
|
||||
import Link from "next/link"
|
||||
|
||||
// Required for static export - ensures this route is statically generated
|
||||
export const dynamic = "force-static"
|
||||
|
|
@ -56,7 +48,6 @@ const routeMapping: Record<string, string> = {
|
|||
|
||||
// Food & Beverage
|
||||
"food-and-beverage/healthy-options": "healthy-vending",
|
||||
"food-and-beverage/snack-and-drink-delivery": "snack-and-drink-delivery",
|
||||
"food-and-beverage/traditional-options": "traditional-vending",
|
||||
"food-and-beverage/suppliers":
|
||||
"diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts",
|
||||
|
|
@ -357,7 +348,6 @@ export default async function WordPressPage({ params }: PageProps) {
|
|||
"vending-machines-for-your-car-wash",
|
||||
]
|
||||
const isWhoWeServePage = whoWeServeSlugs.includes(pageSlug)
|
||||
const routePath = `/${slugArray.join("/")}`
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -387,58 +377,13 @@ export default async function WordPressPage({ params }: PageProps) {
|
|||
pageSlug !== "contact-us" &&
|
||||
pageSlug !== "about-us" &&
|
||||
!isWhoWeServePage && (
|
||||
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: page.title || "Page", href: routePath },
|
||||
]}
|
||||
/>
|
||||
<PublicPageHeader
|
||||
eyebrow={pageSlug.startsWith("blog") ? "Article" : "Information"}
|
||||
title={page.title || "Page"}
|
||||
description={
|
||||
page.seoDescription ||
|
||||
page.excerpt ||
|
||||
"Explore the details, service guidance, and next steps from Rocky Mountain Vending."
|
||||
}
|
||||
className="mx-auto mb-10 max-w-3xl text-center"
|
||||
align="center"
|
||||
/>
|
||||
<PublicSurface className="p-5 md:p-7 lg:p-9">
|
||||
<PublicProse className="mx-auto max-w-3xl">{content}</PublicProse>
|
||||
</PublicSurface>
|
||||
<PublicInset className="mx-auto mt-8 max-w-4xl border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.84))] p-5 md:p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Need Help Choosing The Right Next Step?
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
|
||||
Talk with Rocky Mountain Vending
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Reach out about placement, machine sales, repairs, moving help,
|
||||
manuals, or parts and we'll point you in the right direction.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
>
|
||||
Talk to Our Team
|
||||
</Link>
|
||||
<Link
|
||||
href="/#request-machine"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
||||
>
|
||||
See If You Qualify
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PublicInset>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">
|
||||
{page.title || "Page"}
|
||||
</h1>
|
||||
</header>
|
||||
{content}
|
||||
</article>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -50,8 +50,7 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
Phone Call Detail
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{detail.call.contactDisplayName ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
{normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
detail.call.participantIdentity}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -99,22 +98,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
{detail.call.participantIdentity || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Caller Phone
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.callerPhone ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
"Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Company
|
||||
</p>
|
||||
<p className="font-medium">{detail.call.contactCompany || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Call Status
|
||||
|
|
@ -152,22 +135,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
</p>
|
||||
<p className="font-medium">{detail.call.notificationStatus}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Reminder
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.reminderStatus || "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Warm Transfer
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.warmTransferStatus || "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Summary
|
||||
|
|
@ -190,26 +157,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
</p>
|
||||
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
||||
</div>
|
||||
{detail.call.reminderStartAt ? (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Reminder Time
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatPhoneCallTimestamp(detail.call.reminderStartAt)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.warmTransferFailureReason ? (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Transfer Detail
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.warmTransferFailureReason}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.recordingUrl ? (
|
||||
<div className="md:col-span-2">
|
||||
<Link
|
||||
|
|
@ -232,18 +179,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.reminderCalendarHtmlLink ? (
|
||||
<div className="md:col-span-2">
|
||||
<Link
|
||||
href={detail.call.reminderCalendarHtmlLink}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
Open reminder
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -302,23 +237,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
lead.
|
||||
</p>
|
||||
)}
|
||||
{detail.contactProfile ? (
|
||||
<div className="border-t pt-3">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Contact Profile
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.contactProfile.displayName ||
|
||||
[detail.contactProfile.firstName, detail.contactProfile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ") ||
|
||||
"Known caller"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{detail.contactProfile.company || detail.contactProfile.email || "No company or email yet"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1240px] text-sm">
|
||||
<table className="w-full min-w-[1050px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3 pr-4 font-medium">Caller</th>
|
||||
|
|
@ -116,8 +116,6 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
<th className="py-3 pr-4 font-medium">Recording</th>
|
||||
<th className="py-3 pr-4 font-medium">Lead</th>
|
||||
<th className="py-3 pr-4 font-medium">Email</th>
|
||||
<th className="py-3 pr-4 font-medium">Reminder</th>
|
||||
<th className="py-3 pr-4 font-medium">Transfer</th>
|
||||
<th className="py-3 pr-4 font-medium">Summary</th>
|
||||
<th className="py-3 font-medium">Open</th>
|
||||
</tr>
|
||||
|
|
@ -126,7 +124,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
{data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={13}
|
||||
colSpan={11}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
No phone calls matched this filter.
|
||||
|
|
@ -140,18 +138,12 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
>
|
||||
<td className="py-3 pr-4 font-medium">
|
||||
<div>
|
||||
{call.contactDisplayName ||
|
||||
normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) ||
|
||||
call.participantIdentity}
|
||||
{normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) || call.participantIdentity}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{call.contactCompany ||
|
||||
normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) ||
|
||||
call.roomName}
|
||||
{call.roomName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
|
|
@ -180,16 +172,6 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{call.notificationStatus}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.reminderStatus === "none"
|
||||
? "—"
|
||||
: call.reminderStatus}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.warmTransferStatus === "none"
|
||||
? "—"
|
||||
: call.warmTransferStatus}
|
||||
</td>
|
||||
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
|
||||
<span className="line-clamp-2">
|
||||
{call.summaryText || "No summary yet"}
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { ArrowLeft, ContactRound, MessageSquare } from "lucide-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AdminContactDetailPage({ params }: PageProps) {
|
||||
const { id } = await params
|
||||
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
|
||||
contactId: id,
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/admin/contacts"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to contacts
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
{detail.contact.displayName}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Contact details and activity history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ContactRound className="h-5 w-5" />
|
||||
Contact Profile
|
||||
</CardTitle>
|
||||
<CardDescription>Basic details and connected records.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Email
|
||||
</p>
|
||||
<p className="font-medium break-all">
|
||||
{detail.contact.email || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Phone
|
||||
</p>
|
||||
<p className="font-medium">{detail.contact.phone || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Company
|
||||
</p>
|
||||
<p className="font-medium">{detail.contact.company || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Status
|
||||
</p>
|
||||
<Badge className="mt-1" variant="secondary">
|
||||
{detail.contact.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
GHL Contact ID
|
||||
</p>
|
||||
<p className="font-medium break-all">
|
||||
{detail.contact.ghlContactId || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Last Activity
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatTimestamp(detail.contact.lastActivityAt)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Conversations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Conversations linked to this contact.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.conversations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No conversations are linked to this contact yet.
|
||||
</p>
|
||||
) : (
|
||||
detail.conversations.map((conversation: any) => (
|
||||
<div key={conversation.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{conversation.title || detail.contact.displayName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{conversation.channel} •{" "}
|
||||
{formatTimestamp(conversation.lastMessageAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/admin/conversations/${conversation.id}`}>
|
||||
<Badge variant="outline">{conversation.status}</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{conversation.lastMessagePreview || "No preview yet"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
<CardDescription>
|
||||
Calls, messages, recordings, and lead events in one stream.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.timeline.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No timeline activity for this contact yet.
|
||||
</p>
|
||||
) : (
|
||||
detail.timeline.map((item: any) => (
|
||||
<div key={`${item.type}-${item.id}`} className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-wide">{item.type}</span>
|
||||
<span>{formatTimestamp(item.timestamp)}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-medium">{item.title || "Untitled"}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{item.body || "—"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Contact Detail | Admin",
|
||||
description: "Review a contact and full interaction timeline",
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
import Link from "next/link"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { ContactRound, Search } from "lucide-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
search?: string
|
||||
page?: string
|
||||
}>
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function getSyncMessage(sync: any) {
|
||||
if (!sync.ghlConfigured) {
|
||||
return "Connect GHL to load contacts and conversations."
|
||||
}
|
||||
if (sync.stages.contacts.status === "running") {
|
||||
return "Contacts are syncing now."
|
||||
}
|
||||
if (sync.stages.contacts.error) {
|
||||
return "Contacts could not be loaded from GHL yet."
|
||||
}
|
||||
if (!sync.latestSyncAt) {
|
||||
return "No contacts yet."
|
||||
}
|
||||
return "Your contact list stays up to date from forms, calls, and GHL."
|
||||
}
|
||||
|
||||
export default async function AdminContactsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
|
||||
const search = params.search?.trim() || undefined
|
||||
|
||||
const data = await fetchQuery(api.crm.listAdminContacts, {
|
||||
search,
|
||||
page,
|
||||
limit: 25,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
Contacts
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
All customer contacts in one place.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin">
|
||||
<Button variant="outline">Back to Admin</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Status</CardTitle>
|
||||
<CardDescription>{getSyncMessage(data.sync)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">{data.sync.overallStatus}</Badge>
|
||||
<span>
|
||||
Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}
|
||||
</span>
|
||||
{!data.sync.ghlConfigured ? (
|
||||
<span>GHL is not connected.</span>
|
||||
) : null}
|
||||
{data.sync.stages.contacts.error ? (
|
||||
<span>{data.sync.stages.contacts.error}</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ContactRound className="h-5 w-5" />
|
||||
Contact Directory
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Search by name, email, phone, company, or tag.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
name="search"
|
||||
defaultValue={search || ""}
|
||||
placeholder="Search contacts"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Filter</Button>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3 pr-4 font-medium">Contact</th>
|
||||
<th className="py-3 pr-4 font-medium">Company</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 pr-4 font-medium">Conversations</th>
|
||||
<th className="py-3 pr-4 font-medium">Leads</th>
|
||||
<th className="py-3 pr-4 font-medium">Last Activity</th>
|
||||
<th className="py-3 font-medium">Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{search
|
||||
? "No contacts matched this search."
|
||||
: getSyncMessage(data.sync)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.items.map((contact: any) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
className="border-b align-top last:border-b-0"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium">{contact.displayName}</div>
|
||||
{contact.email ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{contact.email}
|
||||
</div>
|
||||
) : null}
|
||||
{contact.phone ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{contact.phone}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{contact.company || "—"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant="secondary">{contact.status}</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{contact.conversationCount}</td>
|
||||
<td className="py-3 pr-4">{contact.leadCount}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{formatTimestamp(contact.lastActivityAt)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Link href={`/admin/contacts/${contact.id}`}>
|
||||
<Button size="sm" variant="outline">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Contacts | Admin",
|
||||
description: "View Rocky customer contacts",
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { redirect } from "next/navigation"
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function AdminConversationDetailRedirect({
|
||||
params,
|
||||
}: PageProps) {
|
||||
const { id } = await params
|
||||
redirect(`/admin/conversations?conversationId=${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Conversation Detail | Admin",
|
||||
description: "Open a conversation in the inbox view",
|
||||
}
|
||||
|
|
@ -1,518 +0,0 @@
|
|||
import Link from "next/link"
|
||||
import { fetchAction, fetchQuery } from "convex/nextjs"
|
||||
import { MessageSquare, Phone, Search } from "lucide-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
search?: string
|
||||
channel?: "call" | "sms" | "chat" | "unknown"
|
||||
status?: "open" | "closed" | "archived"
|
||||
conversationId?: string
|
||||
error?: string
|
||||
page?: string
|
||||
}>
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function formatSidebarTimestamp(value?: number) {
|
||||
if (!value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
const now = new Date()
|
||||
const sameDay = date.toDateString() === now.toDateString()
|
||||
|
||||
return sameDay
|
||||
? date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(value?: number) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
const totalSeconds = Math.max(0, Math.round(value / 1000))
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function getSyncMessage(sync: any) {
|
||||
if (!sync.ghlConfigured) {
|
||||
return "Connect GHL to load contacts and conversations."
|
||||
}
|
||||
if (sync.stages.conversations.status === "running") {
|
||||
return "Conversations are syncing now."
|
||||
}
|
||||
if (sync.stages.conversations.error) {
|
||||
return "Conversations could not be loaded from GHL yet."
|
||||
}
|
||||
if (!sync.latestSyncAt) {
|
||||
return "No conversations yet."
|
||||
}
|
||||
return "Browse contacts and conversations in one inbox."
|
||||
}
|
||||
|
||||
function getInitials(value?: string) {
|
||||
const text = String(value || "").trim()
|
||||
if (!text) {
|
||||
return "RM"
|
||||
}
|
||||
|
||||
const parts = text.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase()
|
||||
}
|
||||
|
||||
function buildConversationHref(params: {
|
||||
search?: string
|
||||
channel?: string
|
||||
status?: string
|
||||
conversationId?: string
|
||||
}) {
|
||||
const nextParams = new URLSearchParams()
|
||||
if (params.search) {
|
||||
nextParams.set("search", params.search)
|
||||
}
|
||||
if (params.channel) {
|
||||
nextParams.set("channel", params.channel)
|
||||
}
|
||||
if (params.status) {
|
||||
nextParams.set("status", params.status)
|
||||
}
|
||||
if (params.conversationId) {
|
||||
nextParams.set("conversationId", params.conversationId)
|
||||
}
|
||||
|
||||
const query = nextParams.toString()
|
||||
return query ? `/admin/conversations?${query}` : "/admin/conversations"
|
||||
}
|
||||
|
||||
export default async function AdminConversationsPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const params = await searchParams
|
||||
const search = params.search?.trim() || undefined
|
||||
|
||||
const data = await fetchQuery(api.crm.listAdminConversations, {
|
||||
search,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
channel: params.channel,
|
||||
status: params.status,
|
||||
})
|
||||
|
||||
const selectedConversationId =
|
||||
(params.conversationId &&
|
||||
data.items.find((item: any) => item.id === params.conversationId)?.id) ||
|
||||
data.items[0]?.id
|
||||
|
||||
const detail = selectedConversationId
|
||||
? await fetchQuery(api.crm.getAdminConversationDetail, {
|
||||
conversationId: selectedConversationId,
|
||||
})
|
||||
: null
|
||||
|
||||
const hydratedDetail =
|
||||
detail &&
|
||||
detail.messages.length === 0 &&
|
||||
detail.conversation.ghlConversationId
|
||||
? await fetchAction(api.crm.hydrateConversationHistory, {
|
||||
conversationId: detail.conversation.id,
|
||||
}).then(async (result) => {
|
||||
if (result?.imported) {
|
||||
return await fetchQuery(api.crm.getAdminConversationDetail, {
|
||||
conversationId: detail.conversation.id,
|
||||
})
|
||||
}
|
||||
return detail
|
||||
})
|
||||
: detail
|
||||
|
||||
const timeline = hydratedDetail
|
||||
? [
|
||||
...hydratedDetail.messages.map((message: any) => ({
|
||||
id: `message-${message.id}`,
|
||||
type: "message" as const,
|
||||
timestamp: message.sentAt || 0,
|
||||
message,
|
||||
})),
|
||||
...hydratedDetail.recordings.map((recording: any) => ({
|
||||
id: `recording-${recording.id}`,
|
||||
type: "recording" as const,
|
||||
timestamp: recording.startedAt || recording.endedAt || 0,
|
||||
recording,
|
||||
})),
|
||||
].sort((a, b) => a.timestamp - b.timestamp)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
Conversations
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Review calls and messages in one inbox.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin">
|
||||
<Button variant="outline">Back to Admin</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="rounded-[2rem]">
|
||||
<CardContent className="flex flex-wrap items-center gap-3 px-6 py-4 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">{data.sync.overallStatus}</Badge>
|
||||
<span>{getSyncMessage(data.sync)}</span>
|
||||
<span>Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden rounded-[2rem] p-0">
|
||||
<div className="grid min-h-[720px] lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="border-b bg-white lg:border-b-0 lg:border-r">
|
||||
<div className="space-y-4 border-b px-5 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Conversation Inbox</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search and pick a conversation to review.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
name="search"
|
||||
defaultValue={search || ""}
|
||||
placeholder="Search contacts or messages"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||
<select
|
||||
name="channel"
|
||||
defaultValue={params.channel || ""}
|
||||
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All channels</option>
|
||||
<option value="call">Call</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={params.status || ""}
|
||||
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<Button type="submit">Filter</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[520px] lg:h-[640px]">
|
||||
<div className="divide-y">
|
||||
{data.items.length === 0 ? (
|
||||
<div className="px-5 py-8 text-sm text-muted-foreground">
|
||||
{search || params.channel || params.status
|
||||
? "No conversations matched this search."
|
||||
: getSyncMessage(data.sync)}
|
||||
</div>
|
||||
) : (
|
||||
data.items.map((conversation: any) => {
|
||||
const isSelected = conversation.id === selectedConversationId
|
||||
return (
|
||||
<Link
|
||||
key={conversation.id}
|
||||
href={buildConversationHref({
|
||||
search,
|
||||
channel: params.channel,
|
||||
status: params.status,
|
||||
conversationId: conversation.id,
|
||||
})}
|
||||
className={[
|
||||
"flex gap-3 px-5 py-4 transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 ring-1 ring-inset ring-primary/20"
|
||||
: "hover:bg-muted/40",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{getInitials(conversation.displayName)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
{conversation.displayName}
|
||||
</p>
|
||||
{conversation.secondaryLine ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{conversation.secondaryLine}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatSidebarTimestamp(conversation.lastMessageAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
|
||||
{conversation.lastMessagePreview ||
|
||||
"No messages or call notes yet."}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{conversation.channel}</Badge>
|
||||
<Badge variant="secondary">{conversation.status}</Badge>
|
||||
{conversation.recordingCount ? (
|
||||
<Badge variant="outline">
|
||||
{conversation.recordingCount} recording
|
||||
{conversation.recordingCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#faf8f3]">
|
||||
{hydratedDetail ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-white px-6 py-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{hydratedDetail.contact?.name ||
|
||||
hydratedDetail.conversation.title ||
|
||||
"Conversation"}
|
||||
</h2>
|
||||
{hydratedDetail.contact?.secondaryLine ||
|
||||
hydratedDetail.contact?.email ||
|
||||
hydratedDetail.contact?.phone ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hydratedDetail.contact?.secondaryLine ||
|
||||
hydratedDetail.contact?.phone ||
|
||||
hydratedDetail.contact?.email}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{hydratedDetail.conversation.channel}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{hydratedDetail.conversation.status}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{timeline.filter((item) => item.type === "message").length}{" "}
|
||||
messages
|
||||
</Badge>
|
||||
{hydratedDetail.recordings.length ? (
|
||||
<Badge variant="outline">
|
||||
{hydratedDetail.recordings.length} recording
|
||||
{hydratedDetail.recordings.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last activity:{" "}
|
||||
{formatTimestamp(hydratedDetail.conversation.lastMessageAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<form
|
||||
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/sync`}
|
||||
method="post"
|
||||
>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Refresh history
|
||||
</Button>
|
||||
</form>
|
||||
{params.error === "send" ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Rocky could not send that message through GHL.
|
||||
</p>
|
||||
) : null}
|
||||
{params.error === "sync" ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Rocky could not refresh that conversation from GHL.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[520px] px-4 py-5 lg:h-[640px] lg:px-6">
|
||||
<div className="space-y-4 pb-2">
|
||||
{timeline.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed bg-white/70 px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
No messages or recordings have been mirrored into this
|
||||
conversation yet. Use refresh history to pull the latest
|
||||
thread from GHL.
|
||||
</div>
|
||||
) : (
|
||||
timeline.map((item: any) => {
|
||||
if (item.type === "recording") {
|
||||
const recording = item.recording
|
||||
return (
|
||||
<div key={item.id} className="max-w-2xl rounded-2xl border bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
Call recording
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{recording.recordingStatus || "recording"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span>{formatTimestamp(recording.startedAt)}</span>
|
||||
<span>Duration: {formatDuration(recording.durationMs)}</span>
|
||||
</div>
|
||||
{recording.recordingUrl ? (
|
||||
<div className="mt-3">
|
||||
<a
|
||||
href={recording.recordingUrl}
|
||||
target="_blank"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Open recording
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{recording.transcriptionText ? (
|
||||
<div className="mt-3 rounded-xl border bg-muted/30 p-3 text-sm whitespace-pre-wrap text-foreground/90">
|
||||
{recording.transcriptionText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const message = item.message
|
||||
const isOutbound = message.direction === "outbound"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex ${isOutbound ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"max-w-[85%] rounded-3xl px-4 py-3 shadow-sm",
|
||||
isOutbound
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border bg-white",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-wide opacity-70">
|
||||
<span>{message.channel}</span>
|
||||
<span>{message.direction}</span>
|
||||
{message.status ? <span>{message.status}</span> : null}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">
|
||||
{message.body}
|
||||
</p>
|
||||
<div className="mt-2 text-right text-xs opacity-70">
|
||||
{formatTimestamp(message.sentAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="border-t bg-white px-4 py-4 lg:px-6">
|
||||
<form
|
||||
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/messages`}
|
||||
method="post"
|
||||
className="space-y-3"
|
||||
>
|
||||
<textarea
|
||||
name="body"
|
||||
rows={4}
|
||||
placeholder="Reply to this conversation"
|
||||
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 min-h-24 w-full rounded-2xl border bg-background px-4 py-3 text-sm shadow-sm outline-none focus-visible:ring-[3px]"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sends through GHL and mirrors the reply back into Rocky.
|
||||
</p>
|
||||
<Button type="submit">Send message</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[520px] items-center justify-center px-6 py-16">
|
||||
<div className="max-w-md text-center">
|
||||
<h2 className="text-2xl font-semibold">No conversation selected</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose a conversation from the left to open the full thread.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Conversations | Admin",
|
||||
description: "View Rocky customer conversations",
|
||||
}
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
import {
|
||||
getAdminUserFromCookies,
|
||||
isAdminUiEnabled,
|
||||
} from "@/lib/server/admin-auth"
|
||||
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
|
|
@ -14,29 +10,5 @@ export default async function AdminLayout({
|
|||
redirect("/")
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUserFromCookies()
|
||||
if (!adminUser) {
|
||||
redirect("/sign-in")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
<div className="border-b bg-background">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin" className="font-semibold hover:text-primary">
|
||||
Rocky Admin
|
||||
</Link>
|
||||
<span className="text-muted-foreground">{adminUser.email}</span>
|
||||
</div>
|
||||
<form action="/api/admin/auth/logout" method="post">
|
||||
<button className="text-muted-foreground hover:text-foreground">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import Link from "next/link"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -23,8 +21,6 @@ import {
|
|||
Settings,
|
||||
BarChart3,
|
||||
Phone,
|
||||
MessageSquare,
|
||||
ContactRound,
|
||||
} from "lucide-react"
|
||||
import { fetchAllProducts } from "@/lib/stripe/products"
|
||||
|
||||
|
|
@ -60,25 +56,10 @@ async function getOrdersCount() {
|
|||
return mockAnalytics.totalOrders
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number | null) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const [productsCount, ordersCount, sync] = await Promise.all([
|
||||
const [productsCount, ordersCount] = await Promise.all([
|
||||
getProductsCount(),
|
||||
getOrdersCount(),
|
||||
fetchQuery(api.crm.getAdminSyncOverview, {}),
|
||||
])
|
||||
|
||||
const dashboardCards = [
|
||||
|
|
@ -211,22 +192,10 @@ export default async function AdminDashboard() {
|
|||
Admin Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Manage orders, contacts, conversations, and calls
|
||||
Overview of your store performance and management tools
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/admin/contacts">
|
||||
<Button variant="outline">
|
||||
<ContactRound className="h-4 w-4 mr-2" />
|
||||
Contacts
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/conversations">
|
||||
<Button variant="outline">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Conversations
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/calls">
|
||||
<Button variant="outline">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
|
|
@ -243,25 +212,6 @@ export default async function AdminDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CRM Sync Status</CardTitle>
|
||||
<CardDescription>
|
||||
{!sync.ghlConfigured
|
||||
? "Connect GHL to load contacts and conversations."
|
||||
: "Customer data is mirrored here from GHL and your call flows."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">{sync.overallStatus}</Badge>
|
||||
<span>Last sync: {formatTimestamp(sync.latestSyncAt)}</span>
|
||||
{!sync.ghlConfigured ? <span>GHL is not connected.</span> : null}
|
||||
{!sync.livekitConfigured ? (
|
||||
<span>LiveKit recordings are not connected yet.</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{dashboardCards.map((card, index) => {
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
import { headers } from "next/headers"
|
||||
import { NextResponse } from "next/server"
|
||||
import {
|
||||
ADMIN_SESSION_COOKIE,
|
||||
createAdminSession,
|
||||
isAdminCredentialLoginConfigured,
|
||||
isAdminCredentialMatch,
|
||||
} from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminCredentialLoginConfigured()) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/sign-in?error=config", await getPublicOrigin(request))
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const email = String(formData.get("email") || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const password = String(formData.get("password") || "")
|
||||
|
||||
if (!isAdminCredentialMatch(email, password)) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/sign-in?error=invalid", await getPublicOrigin(request))
|
||||
)
|
||||
}
|
||||
|
||||
const session = await createAdminSession(email)
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/admin", await getPublicOrigin(request))
|
||||
)
|
||||
response.cookies.set(ADMIN_SESSION_COOKIE, session.token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
path: "/",
|
||||
expires: new Date(session.expiresAt),
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function getPublicOrigin(request: Request) {
|
||||
const headerStore = await headers()
|
||||
const origin = headerStore.get("origin")
|
||||
if (origin) {
|
||||
return origin
|
||||
}
|
||||
|
||||
const referer = headerStore.get("referer")
|
||||
if (referer) {
|
||||
return new URL(referer).origin
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
if (siteUrl) {
|
||||
return siteUrl
|
||||
}
|
||||
|
||||
const forwardedProto = headerStore.get("x-forwarded-proto")
|
||||
const forwardedHost = headerStore.get("x-forwarded-host")
|
||||
const host = forwardedHost || headerStore.get("host")
|
||||
|
||||
if (host) {
|
||||
return `${forwardedProto || "https"}://${host}`
|
||||
}
|
||||
|
||||
return new URL(request.url).origin
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { cookies, headers } from "next/headers"
|
||||
import {
|
||||
ADMIN_SESSION_COOKIE,
|
||||
destroyAdminSession,
|
||||
} from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const cookieStore = await cookies()
|
||||
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value || null
|
||||
await destroyAdminSession(rawToken)
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/sign-in", await getPublicOrigin(request))
|
||||
)
|
||||
response.cookies.set(ADMIN_SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
path: "/",
|
||||
expires: new Date(0),
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function getPublicOrigin(request: Request) {
|
||||
const headerStore = await headers()
|
||||
const origin = headerStore.get("origin")
|
||||
if (origin) {
|
||||
return origin
|
||||
}
|
||||
|
||||
const referer = headerStore.get("referer")
|
||||
if (referer) {
|
||||
return new URL(referer).origin
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
if (siteUrl) {
|
||||
return siteUrl
|
||||
}
|
||||
|
||||
const forwardedProto = headerStore.get("x-forwarded-proto")
|
||||
const forwardedHost = headerStore.get("x-forwarded-host")
|
||||
const host = forwardedHost || headerStore.get("host")
|
||||
|
||||
if (host) {
|
||||
return `${forwardedProto || "https"}://${host}`
|
||||
}
|
||||
|
||||
return new URL(request.url).origin
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: RouteContext) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
|
||||
contactId: id,
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Contact not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(detail)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin contact detail:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load contact detail" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search")?.trim() || undefined
|
||||
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
|
||||
|
||||
const data = await fetchQuery(api.crm.listAdminContacts, {
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin contacts:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load contacts" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminSession } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: RouteContext) {
|
||||
const adminUser = await requireAdminSession(request)
|
||||
if (!adminUser) {
|
||||
return NextResponse.redirect(new URL("/sign-in", request.url))
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const formData = await request.formData()
|
||||
const body = String(formData.get("body") || "").trim()
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/admin/conversations?conversationId=${encodeURIComponent(id)}`, request.url)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchAction(api.crm.sendAdminConversationMessage, {
|
||||
conversationId: id,
|
||||
body,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/admin/conversations?conversationId=${encodeURIComponent(id)}`,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to send admin conversation message:", error)
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/admin/conversations?conversationId=${encodeURIComponent(id)}&error=send`,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: RouteContext) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const detail = await fetchQuery(api.crm.getAdminConversationDetail, {
|
||||
conversationId: id,
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Conversation not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(detail)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin conversation detail:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load conversation detail" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminSession } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: RouteContext) {
|
||||
const adminUser = await requireAdminSession(request)
|
||||
if (!adminUser) {
|
||||
return NextResponse.redirect(new URL("/sign-in", request.url))
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
await fetchAction(api.crm.hydrateConversationHistory, {
|
||||
conversationId: id,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/admin/conversations?conversationId=${encodeURIComponent(id)}`,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh conversation history:", error)
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/admin/conversations?conversationId=${encodeURIComponent(id)}&error=sync`,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search")?.trim() || undefined
|
||||
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
|
||||
const channel = searchParams.get("channel")
|
||||
const status = searchParams.get("status")
|
||||
|
||||
const data = await fetchQuery(api.crm.listAdminConversations, {
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
channel:
|
||||
channel === "call" ||
|
||||
channel === "sms" ||
|
||||
channel === "chat" ||
|
||||
channel === "unknown"
|
||||
? channel
|
||||
: undefined,
|
||||
status:
|
||||
status === "open" || status === "closed" || status === "archived"
|
||||
? status
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin conversations:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load conversations" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchAction(api.ebay.refreshCache, {
|
||||
reason: "admin",
|
||||
force: true,
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh eBay cache:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to refresh eBay cache",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const result = await fetchAction(api.crm.runGhlMirror, {
|
||||
reason: "admin",
|
||||
forceFullBackfill: Boolean(body.forceFullBackfill),
|
||||
maxPagesPerRun:
|
||||
typeof body.maxPagesPerRun === "number" ? body.maxPagesPerRun : undefined,
|
||||
contactsLimit:
|
||||
typeof body.contactsLimit === "number" ? body.contactsLimit : undefined,
|
||||
messagesLimit:
|
||||
typeof body.messagesLimit === "number" ? body.messagesLimit : undefined,
|
||||
recordingsPageSize:
|
||||
typeof body.recordingsPageSize === "number"
|
||||
? body.recordingsPageSize
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error("Failed to run admin GHL sync:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to run GHL sync",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
import {
|
||||
filterTrustedEbayListings,
|
||||
rankListingsForPart,
|
||||
type CachedEbayListing,
|
||||
type EbayCacheState,
|
||||
type ManualPartInput,
|
||||
} from "@/lib/ebay-parts-match"
|
||||
|
||||
type MatchPart = ManualPartInput & {
|
||||
key?: string
|
||||
ebayListings?: CachedEbayListing[]
|
||||
}
|
||||
|
||||
type ManualPartsMatchResponse = {
|
||||
manualFilename: string
|
||||
parts: Array<
|
||||
MatchPart & {
|
||||
ebayListings: CachedEbayListing[]
|
||||
}
|
||||
>
|
||||
cache: EbayCacheState
|
||||
cacheSource: "convex" | "fallback"
|
||||
error?: string
|
||||
}
|
||||
|
||||
type ManualPartsRequest = {
|
||||
manualFilename?: string
|
||||
parts?: unknown[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
function getDisabledCacheState(message: string): EbayCacheState {
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "disabled",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: null,
|
||||
nextEligibleAt: null,
|
||||
lastError: message,
|
||||
consecutiveFailures: 0,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCacheState(message: string): EbayCacheState {
|
||||
const now = Date.now()
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "error",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: now,
|
||||
nextEligibleAt: null,
|
||||
lastError: message,
|
||||
consecutiveFailures: 1,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyListingsParts(parts: MatchPart[]) {
|
||||
return parts.map((part) => ({
|
||||
...part,
|
||||
ebayListings: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizePartInput(value: unknown): MatchPart | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const part = value as Record<string, unknown>
|
||||
const partNumber = typeof part.partNumber === "string" ? part.partNumber.trim() : ""
|
||||
const description = typeof part.description === "string" ? part.description.trim() : ""
|
||||
|
||||
if (!partNumber && !description) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
key: typeof part.key === "string" ? part.key : undefined,
|
||||
partNumber,
|
||||
description,
|
||||
manufacturer:
|
||||
typeof part.manufacturer === "string" ? part.manufacturer.trim() : undefined,
|
||||
category:
|
||||
typeof part.category === "string" ? part.category.trim() : undefined,
|
||||
manualFilename:
|
||||
typeof part.manualFilename === "string"
|
||||
? part.manualFilename.trim()
|
||||
: undefined,
|
||||
ebayListings: Array.isArray(part.ebayListings)
|
||||
? (part.ebayListings as CachedEbayListing[])
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let payload: ManualPartsRequest | null = null
|
||||
|
||||
try {
|
||||
payload = (await request.json()) as ManualPartsRequest
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
const manualFilename = payload?.manualFilename?.trim() || ""
|
||||
const limit = Math.min(
|
||||
Math.max(Number.parseInt(String(payload?.limit ?? 5), 10) || 5, 1),
|
||||
10
|
||||
)
|
||||
const parts: MatchPart[] = (payload?.parts || [])
|
||||
.map(normalizePartInput)
|
||||
.filter((part): part is MatchPart => Boolean(part))
|
||||
|
||||
if (!manualFilename) {
|
||||
return NextResponse.json(
|
||||
{ error: "manualFilename is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
const message = "No manual parts were provided."
|
||||
return NextResponse.json({
|
||||
manualFilename,
|
||||
parts: [],
|
||||
cache: getDisabledCacheState(message),
|
||||
cacheSource: "fallback",
|
||||
error: message,
|
||||
} satisfies ManualPartsMatchResponse)
|
||||
}
|
||||
|
||||
if (!hasConvexUrl()) {
|
||||
const message =
|
||||
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
|
||||
return NextResponse.json({
|
||||
manualFilename,
|
||||
parts: createEmptyListingsParts(parts),
|
||||
cache: getDisabledCacheState(message),
|
||||
cacheSource: "fallback",
|
||||
error: message,
|
||||
} satisfies ManualPartsMatchResponse)
|
||||
}
|
||||
|
||||
try {
|
||||
const [overview, listings] = await Promise.all([
|
||||
fetchQuery(api.ebay.getCacheOverview, {}),
|
||||
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
|
||||
])
|
||||
const trustedListings = filterTrustedEbayListings(
|
||||
listings as CachedEbayListing[]
|
||||
)
|
||||
|
||||
const rankedParts = parts
|
||||
.map((part) => ({
|
||||
...part,
|
||||
ebayListings: rankListingsForPart(part, trustedListings, limit),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aCount = a.ebayListings.length
|
||||
const bCount = b.ebayListings.length
|
||||
if (aCount !== bCount) {
|
||||
return bCount - aCount
|
||||
}
|
||||
|
||||
const aFreshness = a.ebayListings[0]?.lastSeenAt ?? a.ebayListings[0]?.fetchedAt ?? 0
|
||||
const bFreshness = b.ebayListings[0]?.lastSeenAt ?? b.ebayListings[0]?.fetchedAt ?? 0
|
||||
return bFreshness - aFreshness
|
||||
})
|
||||
.slice(0, limit)
|
||||
|
||||
return NextResponse.json({
|
||||
manualFilename,
|
||||
parts: rankedParts,
|
||||
cache: overview,
|
||||
cacheSource: "convex",
|
||||
} satisfies ManualPartsMatchResponse)
|
||||
} catch (error) {
|
||||
console.error("Failed to load cached eBay matches:", error)
|
||||
const message =
|
||||
error instanceof Error
|
||||
? `Cached eBay listings are unavailable: ${error.message}`
|
||||
: "Cached eBay listings are unavailable."
|
||||
return NextResponse.json(
|
||||
{
|
||||
manualFilename,
|
||||
parts: createEmptyListingsParts(parts),
|
||||
cache: getErrorCacheState(message),
|
||||
cacheSource: "fallback",
|
||||
error: message,
|
||||
} satisfies ManualPartsMatchResponse,
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +1,167 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
import {
|
||||
filterTrustedEbayListings,
|
||||
rankListingsForQuery,
|
||||
type CachedEbayListing,
|
||||
type EbayCacheState,
|
||||
} from "@/lib/ebay-parts-match"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
type CacheSource = "convex" | "fallback"
|
||||
/**
|
||||
* eBay API Proxy Route
|
||||
* Proxies requests to eBay Finding API to avoid CORS issues
|
||||
*/
|
||||
|
||||
function getDisabledCacheState(message: string): EbayCacheState {
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "disabled",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: null,
|
||||
nextEligibleAt: null,
|
||||
lastError: message,
|
||||
consecutiveFailures: 0,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message,
|
||||
interface eBaySearchParams {
|
||||
keywords: string
|
||||
categoryId?: string
|
||||
sortOrder?: string
|
||||
maxResults?: number
|
||||
}
|
||||
|
||||
interface eBaySearchResult {
|
||||
itemId: string
|
||||
title: string
|
||||
price: string
|
||||
currency: string
|
||||
imageUrl?: string
|
||||
viewItemUrl: string
|
||||
condition?: string
|
||||
shippingCost?: string
|
||||
affiliateLink: string
|
||||
}
|
||||
|
||||
type MaybeArray<T> = T | T[]
|
||||
|
||||
const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
|
||||
const searchResponseCache = new Map<
|
||||
string,
|
||||
{ results: eBaySearchResult[]; timestamp: number }
|
||||
>()
|
||||
const inFlightSearchResponses = new Map<string, Promise<eBaySearchResult[]>>()
|
||||
|
||||
// Affiliate campaign ID for generating links
|
||||
const AFFILIATE_CAMPAIGN_ID =
|
||||
process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
|
||||
|
||||
// Generate eBay affiliate link
|
||||
function generateAffiliateLink(viewItemUrl: string): string {
|
||||
if (!AFFILIATE_CAMPAIGN_ID) {
|
||||
return viewItemUrl
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(viewItemUrl)
|
||||
url.searchParams.set("mkcid", "1")
|
||||
url.searchParams.set("mkrid", "711-53200-19255-0")
|
||||
url.searchParams.set("siteid", "0")
|
||||
url.searchParams.set("campid", AFFILIATE_CAMPAIGN_ID)
|
||||
url.searchParams.set("toolid", "10001")
|
||||
url.searchParams.set("mkevt", "1")
|
||||
return url.toString()
|
||||
} catch {
|
||||
return viewItemUrl
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCacheState(message: string): EbayCacheState {
|
||||
const now = Date.now()
|
||||
function first<T>(value: MaybeArray<T> | undefined): T | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
function normalizeItem(item: any): eBaySearchResult {
|
||||
const currentPrice = first(item.sellingStatus?.currentPrice)
|
||||
const shippingCost = first(item.shippingInfo?.shippingServiceCost)
|
||||
const condition = first(item.condition)
|
||||
const viewItemUrl = item.viewItemURL || item.viewItemUrl || ""
|
||||
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "error",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: now,
|
||||
nextEligibleAt: null,
|
||||
lastError: message,
|
||||
consecutiveFailures: 1,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message,
|
||||
itemId: item.itemId || "",
|
||||
title: item.title || "Unknown Item",
|
||||
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
|
||||
currency: currentPrice?.currencyId || "USD",
|
||||
imageUrl: first(item.galleryURL) || undefined,
|
||||
viewItemUrl,
|
||||
condition: condition?.conditionDisplayName || undefined,
|
||||
shippingCost: shippingCost?.value
|
||||
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
|
||||
: undefined,
|
||||
affiliateLink: generateAffiliateLink(viewItemUrl),
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
async function readEbayErrorMessage(response: Response) {
|
||||
const text = await response.text().catch(() => "")
|
||||
if (!text.trim()) {
|
||||
return `eBay API error: ${response.status}`
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as any
|
||||
const messages = parsed?.errorMessage?.[0]?.error?.[0]
|
||||
const message = Array.isArray(messages?.message)
|
||||
? messages.message[0]
|
||||
: messages?.message
|
||||
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
const errorId = Array.isArray(messages?.errorId)
|
||||
? messages.errorId[0]
|
||||
: messages?.errorId
|
||||
return errorId
|
||||
? `eBay API error ${errorId}: ${message}`
|
||||
: `eBay API error: ${message}`
|
||||
}
|
||||
} catch {
|
||||
// Fall through to returning the raw text below.
|
||||
}
|
||||
|
||||
return text.trim() || `eBay API error: ${response.status}`
|
||||
}
|
||||
|
||||
function buildCacheKey(
|
||||
keywords: string,
|
||||
categoryId: string | undefined,
|
||||
sortOrder: string,
|
||||
maxResults: number
|
||||
): string {
|
||||
return [
|
||||
keywords.trim().toLowerCase(),
|
||||
categoryId || "",
|
||||
sortOrder || "BestMatch",
|
||||
String(maxResults),
|
||||
].join("|")
|
||||
}
|
||||
|
||||
function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null {
|
||||
const cached = searchResponseCache.get(cacheKey)
|
||||
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Date.now() - cached.timestamp > SEARCH_CACHE_TTL) {
|
||||
searchResponseCache.delete(cacheKey)
|
||||
return null
|
||||
}
|
||||
|
||||
return cached.results
|
||||
}
|
||||
|
||||
function setCachedSearchResults(cacheKey: string, results: eBaySearchResult[]) {
|
||||
searchResponseCache.set(cacheKey, {
|
||||
results,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const keywords = searchParams.get("keywords")?.trim() || ""
|
||||
const maxResults = Math.min(
|
||||
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
|
||||
20
|
||||
|
||||
const keywords = searchParams.get("keywords")
|
||||
const categoryId = searchParams.get("categoryId") || undefined
|
||||
const sortOrder = searchParams.get("sortOrder") || "BestMatch"
|
||||
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
|
||||
const cacheKey = buildCacheKey(
|
||||
keywords || "",
|
||||
categoryId,
|
||||
sortOrder,
|
||||
maxResults
|
||||
)
|
||||
|
||||
if (!keywords) {
|
||||
|
|
@ -67,54 +171,114 @@ export async function GET(request: Request) {
|
|||
)
|
||||
}
|
||||
|
||||
if (!hasConvexUrl()) {
|
||||
const message =
|
||||
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
|
||||
return NextResponse.json({
|
||||
query: keywords,
|
||||
results: [],
|
||||
cache: getDisabledCacheState(message),
|
||||
cacheSource: "fallback" satisfies CacheSource,
|
||||
error: message,
|
||||
})
|
||||
const appId = process.env.EBAY_APP_ID?.trim()
|
||||
|
||||
if (!appId) {
|
||||
console.error("EBAY_APP_ID not configured")
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"eBay API not configured. Please set EBAY_APP_ID environment variable.",
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const cachedResults = getCachedSearchResults(cacheKey)
|
||||
if (cachedResults) {
|
||||
return NextResponse.json(cachedResults)
|
||||
}
|
||||
|
||||
const inFlight = inFlightSearchResponses.get(cacheKey)
|
||||
if (inFlight) {
|
||||
try {
|
||||
const results = await inFlight
|
||||
return NextResponse.json(results)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch products from eBay",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Build eBay Finding API URL
|
||||
const baseUrl = "https://svcs.ebay.com/services/search/FindingService/v1"
|
||||
const url = new URL(baseUrl)
|
||||
|
||||
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
|
||||
url.searchParams.set("SERVICE-VERSION", "1.0.0")
|
||||
url.searchParams.set("SECURITY-APPNAME", appId)
|
||||
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
|
||||
url.searchParams.set("REST-PAYLOAD", "true")
|
||||
url.searchParams.set("keywords", keywords)
|
||||
url.searchParams.set("sortOrder", sortOrder)
|
||||
url.searchParams.set("paginationInput.entriesPerPage", maxResults.toString())
|
||||
|
||||
if (categoryId) {
|
||||
url.searchParams.set("categoryId", categoryId)
|
||||
}
|
||||
|
||||
try {
|
||||
const [overview, listings] = await Promise.all([
|
||||
fetchQuery(api.ebay.getCacheOverview, {}),
|
||||
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
|
||||
])
|
||||
const request = (async () => {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
const trustedListings = filterTrustedEbayListings(
|
||||
listings as CachedEbayListing[]
|
||||
)
|
||||
const ranked = rankListingsForQuery(
|
||||
keywords,
|
||||
trustedListings,
|
||||
maxResults
|
||||
)
|
||||
if (!response.ok) {
|
||||
const errorMessage = await readEbayErrorMessage(response)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
query: keywords,
|
||||
results: ranked,
|
||||
cache: overview,
|
||||
cacheSource: "convex" satisfies CacheSource,
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
// Parse eBay API response
|
||||
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
|
||||
if (!findItemsAdvancedResponse) {
|
||||
return []
|
||||
}
|
||||
|
||||
const searchResult = findItemsAdvancedResponse.searchResult?.[0]
|
||||
if (
|
||||
!searchResult ||
|
||||
!searchResult.item ||
|
||||
searchResult.item.length === 0
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = Array.isArray(searchResult.item)
|
||||
? searchResult.item
|
||||
: [searchResult.item]
|
||||
|
||||
return items.map((item: any) => normalizeItem(item))
|
||||
})()
|
||||
|
||||
inFlightSearchResponses.set(cacheKey, request)
|
||||
|
||||
const results = await request
|
||||
setCachedSearchResults(cacheKey, results)
|
||||
return NextResponse.json(results)
|
||||
} catch (error) {
|
||||
console.error("Failed to load cached eBay listings:", error)
|
||||
const message =
|
||||
error instanceof Error
|
||||
? `Cached eBay listings are unavailable: ${error.message}`
|
||||
: "Cached eBay listings are unavailable."
|
||||
console.error("Error fetching from eBay API:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
query: keywords,
|
||||
results: [],
|
||||
cache: getErrorCacheState(message),
|
||||
cacheSource: "fallback" satisfies CacheSource,
|
||||
error: message,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch products from eBay",
|
||||
},
|
||||
{ status: 200 }
|
||||
{ status: 500 }
|
||||
)
|
||||
} finally {
|
||||
inFlightSearchResponses.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import { timingSafeEqual } from "node:crypto"
|
||||
import { NextResponse } from "next/server"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
|
||||
function readBearerToken(request: Request) {
|
||||
const authHeader = request.headers.get("authorization") || ""
|
||||
if (!authHeader.toLowerCase().startsWith("bearer ")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return authHeader.slice("bearer ".length).trim()
|
||||
}
|
||||
|
||||
function tokensMatch(expected: string, provided: string) {
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
const providedBuffer = Buffer.from(provided)
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer)
|
||||
}
|
||||
|
||||
export function getGhlSyncToken() {
|
||||
return String(process.env.GHL_SYNC_CRON_TOKEN || "").trim()
|
||||
}
|
||||
|
||||
export async function requireGhlSyncAuth(request: Request) {
|
||||
if (!hasConvexUrl()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Convex is not configured for GHL sync" },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const configuredToken = getGhlSyncToken()
|
||||
if (!configuredToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "GHL sync token is not configured" },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const providedToken = readBearerToken(request)
|
||||
if (!providedToken || !tokensMatch(configuredToken, providedToken)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
import { fetchGhlContacts } from "@/lib/server/ghl-sync"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||
const fetched = providedItems
|
||||
? {
|
||||
items: providedItems,
|
||||
nextCursor:
|
||||
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||
}
|
||||
: await fetchGhlContacts({
|
||||
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||
})
|
||||
|
||||
const imported = []
|
||||
for (const item of fetched.items) {
|
||||
const result = await fetchMutation(api.crm.importContact, {
|
||||
provider: "ghl",
|
||||
entityId: String(item.id || ""),
|
||||
payload: item,
|
||||
})
|
||||
imported.push(result?._id || result?.id || null)
|
||||
}
|
||||
|
||||
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||
provider: "ghl",
|
||||
entityType: "contacts",
|
||||
entityId: "contacts",
|
||||
cursor: fetched.nextCursor,
|
||||
status: "synced",
|
||||
metadata: JSON.stringify({
|
||||
imported: imported.length,
|
||||
}),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported: imported.length,
|
||||
nextCursor: fetched.nextCursor,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to sync GHL contacts:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sync GHL contacts" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||
const fetched = providedItems
|
||||
? {
|
||||
items: providedItems,
|
||||
nextCursor:
|
||||
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||
}
|
||||
: await fetchGhlMessages({
|
||||
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||
channel: body.channel === "Call" ? "Call" : "SMS",
|
||||
})
|
||||
|
||||
const grouped = new Map<string, any>()
|
||||
for (const item of fetched.items) {
|
||||
const conversationId = String(item.conversationId || item.id || "")
|
||||
if (!conversationId || grouped.has(conversationId)) {
|
||||
continue
|
||||
}
|
||||
grouped.set(conversationId, item)
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
for (const [entityId, item] of grouped.entries()) {
|
||||
await fetchMutation(api.crm.importConversation, {
|
||||
provider: "ghl",
|
||||
entityId,
|
||||
payload: item,
|
||||
})
|
||||
imported += 1
|
||||
}
|
||||
|
||||
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||
provider: "ghl",
|
||||
entityType: "conversations",
|
||||
entityId: "conversations",
|
||||
cursor: fetched.nextCursor,
|
||||
status: "synced",
|
||||
metadata: JSON.stringify({
|
||||
imported,
|
||||
}),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported,
|
||||
nextCursor: fetched.nextCursor,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to sync GHL conversations:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sync GHL conversations" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||
const fetched = providedItems
|
||||
? {
|
||||
items: providedItems,
|
||||
nextCursor:
|
||||
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||
}
|
||||
: await fetchGhlMessages({
|
||||
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||
channel: body.channel === "Call" ? "Call" : "SMS",
|
||||
})
|
||||
|
||||
let imported = 0
|
||||
for (const item of fetched.items) {
|
||||
await fetchMutation(api.crm.importMessage, {
|
||||
provider: "ghl",
|
||||
entityId: String(item.id || ""),
|
||||
payload: item,
|
||||
})
|
||||
imported += 1
|
||||
}
|
||||
|
||||
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||
provider: "ghl",
|
||||
entityType: "messages",
|
||||
entityId: "messages",
|
||||
cursor: fetched.nextCursor,
|
||||
status: "synced",
|
||||
metadata: JSON.stringify({
|
||||
imported,
|
||||
}),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported,
|
||||
nextCursor: fetched.nextCursor,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to sync GHL messages:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sync GHL messages" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const result = await fetchMutation(api.crm.reconcileExternalState, {
|
||||
provider: body.provider ? String(body.provider) : "ghl",
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to reconcile mirrored external state:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reconcile mirrored external state" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
import { fetchGhlCallLogs } from "@/lib/server/ghl-sync"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||
const fetched = providedItems
|
||||
? {
|
||||
items: providedItems,
|
||||
page: typeof body.page === "number" ? body.page : 1,
|
||||
total: providedItems.length,
|
||||
pageSize: providedItems.length,
|
||||
}
|
||||
: await fetchGhlCallLogs({
|
||||
page: typeof body.page === "number" ? body.page : undefined,
|
||||
pageSize: typeof body.pageSize === "number" ? body.pageSize : undefined,
|
||||
})
|
||||
|
||||
let imported = 0
|
||||
for (const item of fetched.items) {
|
||||
await fetchMutation(api.crm.importRecording, {
|
||||
provider: "ghl",
|
||||
entityId: String(item.id || item.messageId || ""),
|
||||
payload: {
|
||||
...item,
|
||||
recordingId: item.messageId || item.id,
|
||||
transcript: item.transcript,
|
||||
recordingUrl: item.recordingUrl,
|
||||
recordingStatus: item.transcript ? "completed" : "pending",
|
||||
},
|
||||
})
|
||||
imported += 1
|
||||
}
|
||||
|
||||
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||
provider: "ghl",
|
||||
entityType: "recordings",
|
||||
entityId: "recordings",
|
||||
cursor: `${fetched.page}`,
|
||||
status: "synced",
|
||||
metadata: JSON.stringify({
|
||||
imported,
|
||||
total: fetched.total,
|
||||
}),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported,
|
||||
page: fetched.page,
|
||||
total: fetched.total,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to sync GHL recordings:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sync GHL recordings" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const result = await fetchAction(api.crm.runGhlMirror, {
|
||||
reason: body.reason ? String(body.reason) : "internal",
|
||||
forceFullBackfill: Boolean(body.forceFullBackfill),
|
||||
maxPagesPerRun:
|
||||
typeof body.maxPagesPerRun === "number" ? body.maxPagesPerRun : undefined,
|
||||
contactsLimit:
|
||||
typeof body.contactsLimit === "number" ? body.contactsLimit : undefined,
|
||||
messagesLimit:
|
||||
typeof body.messagesLimit === "number" ? body.messagesLimit : undefined,
|
||||
recordingsPageSize:
|
||||
typeof body.recordingsPageSize === "number"
|
||||
? body.recordingsPageSize
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error("Failed to run GHL sync:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to run GHL sync",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { normalizePhoneE164 } from "@/lib/phone-normalization"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const normalizedPhone = normalizePhoneE164(body.phone)
|
||||
|
||||
if (!normalizedPhone) {
|
||||
return NextResponse.json(
|
||||
{ error: "phone is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const context = await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
||||
normalizedPhone,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
normalizedPhone,
|
||||
...context,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to look up phone agent contact context:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to look up phone agent contact context" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import {
|
||||
buildSameDayReminderWindow,
|
||||
createFollowupReminderEvent,
|
||||
isGoogleCalendarConfigured,
|
||||
} from "@/lib/google-calendar"
|
||||
import { normalizePhoneE164, splitDisplayName } from "@/lib/phone-normalization"
|
||||
|
||||
function buildReminderTitle(args: {
|
||||
kind: "scheduled" | "same-day"
|
||||
callerName?: string
|
||||
company?: string
|
||||
phone?: string
|
||||
}) {
|
||||
const label = args.kind === "same-day" ? "Same-day callback" : "Callback reminder"
|
||||
const identity = [args.callerName, args.company, args.phone]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
.join(" | ")
|
||||
|
||||
return identity ? `${label}: ${identity}` : label
|
||||
}
|
||||
|
||||
function buildReminderDescription(args: {
|
||||
callerName?: string
|
||||
company?: string
|
||||
phone?: string
|
||||
reason?: string
|
||||
summaryText?: string
|
||||
adminCallUrl: string
|
||||
}) {
|
||||
return [
|
||||
args.callerName ? `Caller: ${args.callerName}` : "",
|
||||
args.company ? `Company: ${args.company}` : "",
|
||||
args.phone ? `Phone: ${args.phone}` : "",
|
||||
args.reason ? `Reason: ${args.reason}` : "",
|
||||
args.summaryText ? `Summary: ${args.summaryText}` : "",
|
||||
`RMV admin call detail: ${args.adminCallUrl}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const sessionId = String(body.sessionId || "").trim()
|
||||
const kind =
|
||||
body.kind === "same-day" ? ("same-day" as const) : ("scheduled" as const)
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: "sessionId is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const adminCallUrl = `${url.origin}/admin/calls/${sessionId}`
|
||||
const normalizedPhone = normalizePhoneE164(body.phone)
|
||||
const callerName = String(body.callerName || "").trim()
|
||||
const company = String(body.company || "").trim()
|
||||
const reason = String(body.reason || "").trim()
|
||||
const summaryText = String(body.summaryText || "").trim()
|
||||
const calendarConfigured = isGoogleCalendarConfigured()
|
||||
|
||||
let startAt: Date
|
||||
let endAt: Date
|
||||
if (kind === "same-day") {
|
||||
const reminderWindow = buildSameDayReminderWindow()
|
||||
startAt = reminderWindow.startAt
|
||||
endAt = reminderWindow.endAt
|
||||
} else {
|
||||
startAt = new Date(String(body.startAt || ""))
|
||||
endAt = new Date(String(body.endAt || ""))
|
||||
if (Number.isNaN(endAt.getTime()) && !Number.isNaN(startAt.getTime())) {
|
||||
endAt = new Date(startAt.getTime() + 15 * 60 * 1000)
|
||||
}
|
||||
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || startAt.getTime() <= Date.now()) {
|
||||
return NextResponse.json(
|
||||
{ error: "A future startAt and endAt are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "scheduled" && !calendarConfigured) {
|
||||
return NextResponse.json(
|
||||
{ error: "Google Calendar follow-up scheduling is not configured" },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const reminder = calendarConfigured
|
||||
? await createFollowupReminderEvent({
|
||||
title: buildReminderTitle({
|
||||
kind,
|
||||
callerName,
|
||||
company,
|
||||
phone: normalizedPhone || String(body.phone || "").trim(),
|
||||
}),
|
||||
description: buildReminderDescription({
|
||||
callerName,
|
||||
company,
|
||||
phone: normalizedPhone || String(body.phone || "").trim(),
|
||||
reason,
|
||||
summaryText,
|
||||
adminCallUrl,
|
||||
}),
|
||||
startAt,
|
||||
endAt,
|
||||
})
|
||||
: {
|
||||
eventId: "",
|
||||
htmlLink: "",
|
||||
}
|
||||
|
||||
let contactProfileId: string | undefined
|
||||
if (normalizedPhone) {
|
||||
const nameParts = splitDisplayName(callerName)
|
||||
const profile = await fetchMutation(api.contactProfiles.upsertByPhone, {
|
||||
normalizedPhone,
|
||||
displayName: callerName || undefined,
|
||||
firstName: nameParts.firstName || undefined,
|
||||
lastName: nameParts.lastName || undefined,
|
||||
company: company || undefined,
|
||||
lastSummaryText: summaryText || reason || undefined,
|
||||
lastReminderAt: Date.now(),
|
||||
reminderNotes: reason || undefined,
|
||||
source: "phone-agent",
|
||||
})
|
||||
contactProfileId = profile?._id
|
||||
}
|
||||
|
||||
const call = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
||||
sessionId,
|
||||
contactProfileId,
|
||||
contactDisplayName: callerName || undefined,
|
||||
contactCompany: company || undefined,
|
||||
reminderStatus: kind === "same-day" ? "sameDay" : "scheduled",
|
||||
reminderRequestedAt: Date.now(),
|
||||
reminderStartAt: startAt.getTime(),
|
||||
reminderEndAt: endAt.getTime(),
|
||||
reminderCalendarEventId: reminder.eventId || undefined,
|
||||
reminderCalendarHtmlLink: reminder.htmlLink || undefined,
|
||||
reminderNote:
|
||||
reason ||
|
||||
summaryText ||
|
||||
(!calendarConfigured ? "Manual follow-up reminder created without Google Calendar." : undefined),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
calendarConfigured,
|
||||
reminder: {
|
||||
kind,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
eventId: reminder.eventId || null,
|
||||
htmlLink: reminder.htmlLink || null,
|
||||
},
|
||||
call,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create phone agent follow-up reminder:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create phone agent follow-up reminder" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import {
|
||||
isGoogleCalendarConfigured,
|
||||
listFutureCallbackSlots,
|
||||
} from "@/lib/google-calendar"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const limit =
|
||||
typeof body.limit === "number" && body.limit > 0
|
||||
? Math.min(body.limit, 5)
|
||||
: 3
|
||||
|
||||
if (!isGoogleCalendarConfigured()) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
calendarConfigured: false,
|
||||
slots: [],
|
||||
})
|
||||
}
|
||||
|
||||
const slots = await listFutureCallbackSlots(limit)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
calendarConfigured: true,
|
||||
slots,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to list phone agent callback slots:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to list phone agent callback slots" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { searchServiceKnowledge } from "@/lib/service-knowledge"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const query = String(body.query || "").trim()
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "query is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const results = await searchServiceKnowledge({
|
||||
query,
|
||||
limit:
|
||||
typeof body.limit === "number" && body.limit > 0
|
||||
? Math.min(body.limit, 6)
|
||||
: 4,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to search phone agent service knowledge:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to search phone agent service knowledge" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,13 +14,6 @@ export async function POST(request: Request) {
|
|||
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
||||
sessionId: body.sessionId,
|
||||
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
|
||||
contactProfileId: body.contactProfileId || undefined,
|
||||
contactDisplayName: body.contactDisplayName
|
||||
? String(body.contactDisplayName)
|
||||
: undefined,
|
||||
contactCompany: body.contactCompany
|
||||
? String(body.contactCompany)
|
||||
: undefined,
|
||||
leadOutcome: body.leadOutcome || "none",
|
||||
handoffRequested:
|
||||
typeof body.handoffRequested === "boolean"
|
||||
|
|
@ -29,41 +22,6 @@ export async function POST(request: Request) {
|
|||
handoffReason: body.handoffReason
|
||||
? String(body.handoffReason)
|
||||
: undefined,
|
||||
reminderStatus: body.reminderStatus || undefined,
|
||||
reminderRequestedAt:
|
||||
typeof body.reminderRequestedAt === "number"
|
||||
? body.reminderRequestedAt
|
||||
: undefined,
|
||||
reminderStartAt:
|
||||
typeof body.reminderStartAt === "number"
|
||||
? body.reminderStartAt
|
||||
: undefined,
|
||||
reminderEndAt:
|
||||
typeof body.reminderEndAt === "number"
|
||||
? body.reminderEndAt
|
||||
: undefined,
|
||||
reminderCalendarEventId: body.reminderCalendarEventId
|
||||
? String(body.reminderCalendarEventId)
|
||||
: undefined,
|
||||
reminderCalendarHtmlLink: body.reminderCalendarHtmlLink
|
||||
? String(body.reminderCalendarHtmlLink)
|
||||
: undefined,
|
||||
reminderNote: body.reminderNote ? String(body.reminderNote) : undefined,
|
||||
warmTransferStatus: body.warmTransferStatus || undefined,
|
||||
warmTransferTarget: body.warmTransferTarget
|
||||
? String(body.warmTransferTarget)
|
||||
: undefined,
|
||||
warmTransferAttemptedAt:
|
||||
typeof body.warmTransferAttemptedAt === "number"
|
||||
? body.warmTransferAttemptedAt
|
||||
: undefined,
|
||||
warmTransferConnectedAt:
|
||||
typeof body.warmTransferConnectedAt === "number"
|
||||
? body.warmTransferConnectedAt
|
||||
: undefined,
|
||||
warmTransferFailureReason: body.warmTransferFailureReason
|
||||
? String(body.warmTransferFailureReason)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, call: result })
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { normalizePhoneE164 } from "@/lib/phone-normalization"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
|
|
@ -12,44 +11,16 @@ export async function POST(request: Request) {
|
|||
|
||||
try {
|
||||
const body = await request.json()
|
||||
let metadata: Record<string, unknown> = {}
|
||||
if (typeof body.metadata === "string" && body.metadata.trim()) {
|
||||
try {
|
||||
metadata = JSON.parse(body.metadata)
|
||||
} catch {
|
||||
metadata = {}
|
||||
}
|
||||
}
|
||||
const callerPhone = normalizePhoneE164(
|
||||
metadata.participantPhone || body.participantIdentity
|
||||
)
|
||||
const contactContext = callerPhone
|
||||
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
||||
normalizedPhone: callerPhone,
|
||||
})
|
||||
: null
|
||||
|
||||
const result = await fetchMutation(
|
||||
api.voiceSessions.upsertPhoneCallSession,
|
||||
{
|
||||
roomName: String(body.roomName || ""),
|
||||
participantIdentity: String(body.participantIdentity || ""),
|
||||
callerPhone: callerPhone || undefined,
|
||||
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
||||
pathname: body.pathname ? String(body.pathname) : undefined,
|
||||
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
||||
source: "phone-agent",
|
||||
metadata: body.metadata ? String(body.metadata) : undefined,
|
||||
contactProfileId: contactContext?.contactProfile?.id,
|
||||
contactDisplayName:
|
||||
contactContext?.contactProfile?.displayName ||
|
||||
(contactContext?.recentLead
|
||||
? `${contactContext.recentLead.firstName} ${contactContext.recentLead.lastName}`.trim()
|
||||
: undefined),
|
||||
contactCompany:
|
||||
contactContext?.contactProfile?.company ||
|
||||
contactContext?.recentLead?.company ||
|
||||
undefined,
|
||||
startedAt:
|
||||
typeof body.startedAt === "number" ? body.startedAt : undefined,
|
||||
recordingDisclosureAt:
|
||||
|
|
@ -64,10 +35,6 @@ export async function POST(request: Request) {
|
|||
success: true,
|
||||
sessionId: result?._id,
|
||||
roomName: result?.roomName,
|
||||
callerPhone,
|
||||
contactProfile: contactContext?.contactProfile || null,
|
||||
recentLead: contactContext?.recentLead || null,
|
||||
recentSession: contactContext?.recentSession || null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to start phone call sync:", error)
|
||||
|
|
|
|||
|
|
@ -5,14 +5,7 @@ import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
|||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicProse,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
|
||||
const WORDPRESS_SLUG = "abandoned-vending-machines"
|
||||
|
||||
|
|
@ -88,61 +81,17 @@ export default async function AbandonedVendingMachinesPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{
|
||||
label: page.title || "Abandoned Vending Machines",
|
||||
href: "/blog/abandoned-vending-machines",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PublicPageHeader
|
||||
eyebrow="Article"
|
||||
title={page.title || "Abandoned Vending Machines"}
|
||||
description={
|
||||
page.seoDescription ||
|
||||
page.excerpt ||
|
||||
"Guidance, next steps, and practical considerations from Rocky Mountain Vending."
|
||||
}
|
||||
align="center"
|
||||
className="mx-auto mb-10 max-w-3xl"
|
||||
/>
|
||||
<PublicSurface className="p-5 md:p-7 lg:p-9">
|
||||
<PublicProse className="mx-auto max-w-3xl">{content}</PublicProse>
|
||||
</PublicSurface>
|
||||
<PublicInset className="mx-auto mt-8 max-w-4xl border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.84))] p-5 md:p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Need Help With A Machine Situation?
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
|
||||
Get the right kind of support quickly
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Reach out if you need help with abandoned machines, service questions,
|
||||
moving help, or figuring out the right next step for your location.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
>
|
||||
Talk to Our Team
|
||||
</Link>
|
||||
<Link
|
||||
href="/services/repairs"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
||||
>
|
||||
Explore Repair Help
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PublicInset>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{
|
||||
label: page.title || "Abandoned Vending Machines",
|
||||
href: "/blog/abandoned-vending-machines",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
{content}
|
||||
</article>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -183,8 +183,7 @@
|
|||
transition:
|
||||
color 0.2s ease,
|
||||
text-decoration-color 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
|
|
@ -203,8 +202,7 @@
|
|||
transition:
|
||||
color 0.2s ease,
|
||||
text-decoration-color 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
a[href]:hover,
|
||||
|
|
@ -213,29 +211,6 @@
|
|||
background-color: transparent;
|
||||
}
|
||||
|
||||
button a,
|
||||
[role="button"] a,
|
||||
.bg-primary a,
|
||||
.text-primary-foreground a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button a:hover,
|
||||
button a:focus,
|
||||
[role="button"] a:hover,
|
||||
[role="button"] a:focus,
|
||||
.bg-primary a:hover,
|
||||
.bg-primary a:focus,
|
||||
.text-primary-foreground a:hover,
|
||||
.text-primary-foreground a:focus {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
export const dynamic = "force-dynamic"
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync } from "node:fs"
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { Metadata } from "next"
|
||||
import { headers } from "next/headers"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { ManualsPageExperience } from "@/components/manuals-page-experience"
|
||||
import { listConvexManuals } from "@/lib/convex-service"
|
||||
|
|
@ -11,8 +10,6 @@ import { scanManuals } from "@/lib/manuals"
|
|||
import { selectManualsForSite } from "@/lib/manuals-site-selection"
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
|
||||
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
||||
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
|
||||
|
||||
export const metadata: Metadata = generateSEOMetadata({
|
||||
title: "Vending Machine Manuals | Rocky Mountain Vending",
|
||||
|
|
@ -34,59 +31,29 @@ export const metadata: Metadata = generateSEOMetadata({
|
|||
})
|
||||
|
||||
export default async function ManualsPage() {
|
||||
const requestHeaders = await headers()
|
||||
const requestHost =
|
||||
requestHeaders.get("x-forwarded-host") || requestHeaders.get("host")
|
||||
const manualsDomain = resolveManualsTenantDomain({
|
||||
requestHost,
|
||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
||||
})
|
||||
|
||||
const convexManuals = manualsDomain
|
||||
? await listConvexManuals(manualsDomain)
|
||||
: []
|
||||
|
||||
const isLocalDevelopment = process.env.NODE_ENV === "development"
|
||||
const shouldUseFilesystemFallback = isLocalDevelopment
|
||||
|
||||
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
|
||||
const convexManuals = await listConvexManuals()
|
||||
const allManuals =
|
||||
convexManuals.length > 0 || !shouldUseFilesystemFallback
|
||||
? convexManuals
|
||||
: await scanManuals()
|
||||
convexManuals.length > 0 ? convexManuals : await scanManuals()
|
||||
let manuals =
|
||||
convexManuals.length > 0
|
||||
? convexManuals
|
||||
: shouldUseFilesystemFallback
|
||||
? selectManualsForSite(allManuals, manualsDomain).manuals
|
||||
: []
|
||||
: selectManualsForSite(allManuals).manuals
|
||||
|
||||
const shouldShowDegradedState =
|
||||
!shouldUseFilesystemFallback && manuals.length === 0
|
||||
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
|
||||
const thumbnailsRoot = getManualsThumbnailsRoot()
|
||||
manuals = manuals.map((manual) => {
|
||||
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
|
||||
return manual
|
||||
}
|
||||
|
||||
if (shouldShowDegradedState) {
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || ""
|
||||
const convexUrlHash = convexUrl
|
||||
? createHash("sha256").update(convexUrl).digest("hex").slice(0, 12)
|
||||
: "missing"
|
||||
const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/")
|
||||
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "")
|
||||
: manual.thumbnailUrl
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "manuals.degraded_empty_tenant",
|
||||
severity: "error",
|
||||
domain: manualsDomain || "missing",
|
||||
host: requestHost || "missing",
|
||||
manualCount: manuals.length,
|
||||
convexManualCount: convexManuals.length,
|
||||
convexUrlHash,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
manuals = sanitizeManualThumbnailsForRuntime(manuals, {
|
||||
isLocalDevelopment,
|
||||
thumbnailsRoot: getManualsThumbnailsRoot(),
|
||||
fileExists: existsSync,
|
||||
return existsSync(join(thumbnailsRoot, relativeThumbnailPath))
|
||||
? manual
|
||||
: { ...manual, thumbnailUrl: undefined }
|
||||
})
|
||||
|
||||
// Generate structured data for SEO
|
||||
|
|
@ -132,22 +99,8 @@ export default async function ManualsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
||||
/>
|
||||
<div className="public-page" data-manuals-domain={manualsDomain}>
|
||||
{shouldShowDegradedState ? (
|
||||
<div className="mx-auto max-w-[var(--public-shell-max)] px-4 py-10 sm:px-5 lg:px-6">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-6">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Manuals Library Temporarily Unavailable
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
We are restoring tenant catalog data for this domain. Please
|
||||
refresh shortly or contact support if this persists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ManualsPageExperience initialManuals={manuals} />
|
||||
)}
|
||||
<div className="public-page">
|
||||
<ManualsPageExperience initialManuals={manuals} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ function LocationCard({
|
|||
}) {
|
||||
return (
|
||||
<Link href={href} className="group block h-full">
|
||||
<PublicInset className="h-full p-4 transition-all hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[0_20px_48px_rgba(0,0,0,0.09)] md:p-5">
|
||||
<PublicSurface className="h-full p-5 transition-all hover:-translate-y-0.5 hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)] md:p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-foreground transition-colors group-hover:text-primary">
|
||||
<h3 className="text-xl font-semibold text-foreground transition-colors group-hover:text-primary">
|
||||
{city}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{zipCode}</p>
|
||||
|
|
@ -49,15 +49,15 @@ function LocationCard({
|
|||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-[1.1rem] border border-border/45 bg-background/60 p-3">
|
||||
<PublicInset className="mt-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/75">
|
||||
Popular Areas
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{neighborhoods.slice(0, 2).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</PublicInset>
|
||||
</PublicInset>
|
||||
</PublicSurface>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -222,7 +222,7 @@ export default function ServiceAreasPage() {
|
|||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
<section className="mt-12 space-y-8">
|
||||
<section className="mt-12 space-y-12">
|
||||
{[
|
||||
{
|
||||
title: "Salt Lake County",
|
||||
|
|
@ -243,24 +243,19 @@ export default function ServiceAreasPage() {
|
|||
items: utahCounty,
|
||||
},
|
||||
].map((section) => (
|
||||
<PublicSurface key={section.title} className="p-5 md:p-6">
|
||||
<div className="flex flex-col gap-4 border-b border-border/55 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Coverage Area
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-balance">
|
||||
{section.title}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-3xl text-base leading-relaxed text-muted-foreground">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-fit rounded-full border border-border/55 bg-background/70 px-3 py-1 text-sm text-muted-foreground">
|
||||
{section.items.length} cities
|
||||
</div>
|
||||
<div key={section.title} className="space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Coverage Area
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-balance">
|
||||
{section.title}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-3xl text-base leading-relaxed text-muted-foreground">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{section.items.map((location) => (
|
||||
<LocationCard
|
||||
key={location.slug}
|
||||
|
|
@ -271,11 +266,11 @@ export default function ServiceAreasPage() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-2">
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<PublicSurface>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">
|
||||
Why businesses choose Rocky Mountain Vending
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import {
|
||||
generateRegistryMetadata,
|
||||
generateRegistryStructuredData,
|
||||
} from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { FAQSection } from "@/components/faq-section"
|
||||
import { ServiceAreasSection } from "@/components/service-areas-section"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
|
@ -56,13 +58,21 @@ export default async function RepairsPage() {
|
|||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
// Extract FAQs from content
|
||||
const faqs: Array<{ question: string; answer: string }> = []
|
||||
let contentWithoutFAQs = page.content || ""
|
||||
let contentWithoutVirtualServices = ""
|
||||
let virtualServicesContent = ""
|
||||
|
||||
if (page.content) {
|
||||
const contentStr = String(page.content)
|
||||
let strippedContent = contentStr
|
||||
|
||||
// Extract FAQ items from accordion structure
|
||||
const questionMatches = contentStr.matchAll(
|
||||
|
|
@ -102,28 +112,40 @@ export default async function RepairsPage() {
|
|||
if (faqs.length > 0) {
|
||||
const faqSectionRegex =
|
||||
/<h2[^>]*>.*?Answers\s+To\s+Common\s+Questions.*?<\/h2>[\s\S]*?(?=<h2[^>]*>.*?Virtual\s+Services|<h2[^>]*>.*?Service\s+Area|$)/i
|
||||
strippedContent = contentStr.replace(faqSectionRegex, "").trim()
|
||||
contentWithoutFAQs = contentStr.replace(faqSectionRegex, "").trim()
|
||||
}
|
||||
|
||||
// Extract Virtual Services section
|
||||
const virtualServicesRegex =
|
||||
/<h2[^>]*>.*?Virtual\s+Services.*?<\/h2>([\s\S]*?)(?=<h2[^>]*>.*?Service\s+Area|$)/i
|
||||
const virtualMatch = strippedContent.match(virtualServicesRegex)
|
||||
const virtualMatch = contentStr.match(virtualServicesRegex)
|
||||
if (virtualMatch) {
|
||||
virtualServicesContent = virtualMatch[1]
|
||||
// Remove Virtual Services from main content
|
||||
contentWithoutVirtualServices = contentWithoutFAQs
|
||||
.replace(virtualServicesRegex, "")
|
||||
.trim()
|
||||
} else {
|
||||
contentWithoutVirtualServices = contentWithoutFAQs
|
||||
}
|
||||
}
|
||||
|
||||
const content = contentWithoutVirtualServices ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(contentWithoutVirtualServices), {
|
||||
imageMapping,
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
)
|
||||
|
||||
const structuredData = generateRegistryStructuredData("repairs", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
const excerpt = String(page.excerpt || "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
|
||||
const surfaceCardClass =
|
||||
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] shadow-[var(--public-surface-shadow)]"
|
||||
const insetCardClass =
|
||||
|
|
@ -149,14 +171,37 @@ export default async function RepairsPage() {
|
|||
align="center"
|
||||
className="mb-8"
|
||||
eyebrow="Repair Services"
|
||||
title="Vending machine repairs and service for Utah businesses"
|
||||
title={page.title || "Vending Machine Repairs and Service"}
|
||||
description={
|
||||
"Get help with payment issues, refrigeration problems, machine errors, and ongoing maintenance from a local vending service team."
|
||||
"Rocky Mountain Vending delivers expert vending machine repair and maintenance services to keep your business thriving."
|
||||
}
|
||||
>
|
||||
<p className="mx-auto max-w-3xl text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||
{excerpt ||
|
||||
"Rocky Mountain Vending helps businesses across Davis, Salt Lake, and Utah counties keep machines running with practical repair, maintenance, and support guidance."}
|
||||
Rocky Mountain Vending delivers expert{" "}
|
||||
<Link
|
||||
href="/services/repairs"
|
||||
className="text-primary hover:underline font-semibold"
|
||||
>
|
||||
vending machine repair
|
||||
</Link>{" "}
|
||||
and maintenance services to keep your business thriving. From
|
||||
resolving jammed coin slots and refrigeration issues to fixing
|
||||
non-dispensing machines, our skilled technicians ensure reliable
|
||||
performance. For all your{" "}
|
||||
<Link
|
||||
href="/services/parts"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
vending machine parts
|
||||
</Link>{" "}
|
||||
needs and professional{" "}
|
||||
<Link
|
||||
href="/services/moving"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
vending machine moving
|
||||
</Link>{" "}
|
||||
services, contact us today for fast, professional solutions!
|
||||
</p>
|
||||
</PublicPageHeader>
|
||||
{/* Images Carousel */}
|
||||
|
|
@ -166,71 +211,15 @@ export default async function RepairsPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 md:py-20 bg-background">
|
||||
<div className="container mx-auto grid max-w-6xl gap-6 px-4 lg:grid-cols-[1.08fr_0.92fr]">
|
||||
<PublicSurface className="h-full">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Repair Overview
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
|
||||
Clear next steps when a machine is down, rejecting payments, or
|
||||
needs service.
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
|
||||
We help with common vending issues like bill acceptor problems,
|
||||
refrigeration failures, card reader troubleshooting, controller
|
||||
errors, and recurring maintenance needs. If the issue can be
|
||||
handled virtually, we can talk through that too.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="#request-service"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:opacity-95"
|
||||
>
|
||||
Request Service
|
||||
</Link>
|
||||
<Link
|
||||
href="/services/parts"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-background px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
||||
>
|
||||
Need Parts Instead?
|
||||
</Link>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
<PublicInset className="h-full p-5 md:p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Before You Reach Out
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
||||
The more detail you share, the faster we can point you in the
|
||||
right direction.
|
||||
</h2>
|
||||
<ul className="mt-5 space-y-3">
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
|
||||
<span className="text-sm leading-6 text-muted-foreground">
|
||||
Include the machine model, brand, and whether the issue is
|
||||
intermittent or constant.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
|
||||
<span className="text-sm leading-6 text-muted-foreground">
|
||||
Tell us if the problem is payment-related, refrigeration,
|
||||
dispensing, display errors, or a recent setup change.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
|
||||
<span className="text-sm leading-6 text-muted-foreground">
|
||||
Photos or short videos can make remote triage much easier
|
||||
before an on-site visit is scheduled.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</PublicInset>
|
||||
</div>
|
||||
</section>
|
||||
{contentWithoutVirtualServices ? (
|
||||
<section className="py-16 md:py-20 bg-background">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<PublicSurface>
|
||||
<div className="max-w-none">{content}</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Services Section */}
|
||||
<section className="py-20 md:py-28 bg-muted/30">
|
||||
|
|
|
|||
|
|
@ -1,35 +1,12 @@
|
|||
import { redirect } from "next/navigation"
|
||||
import {
|
||||
getAdminUserFromCookies,
|
||||
isAdminCredentialLoginConfigured,
|
||||
isAdminUiEnabled,
|
||||
} from "@/lib/server/admin-auth"
|
||||
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
|
||||
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function SignInPage({ searchParams }: PageProps) {
|
||||
export default function SignInPage() {
|
||||
if (!isAdminUiEnabled()) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUserFromCookies()
|
||||
if (adminUser) {
|
||||
redirect("/admin")
|
||||
}
|
||||
|
||||
const params = await searchParams
|
||||
const errorMessage =
|
||||
params.error === "invalid"
|
||||
? "That email or password was not accepted."
|
||||
: params.error === "config"
|
||||
? "Admin access is not available right now."
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="px-4 py-8 md:py-12">
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-7rem)] max-w-3xl items-start justify-center md:items-center">
|
||||
|
|
@ -42,55 +19,13 @@ export default async function SignInPage({ searchParams }: PageProps) {
|
|||
/>
|
||||
|
||||
<PublicSurface className="p-6 text-center md:p-8">
|
||||
{isAdminCredentialLoginConfigured() ? (
|
||||
<form
|
||||
action="/api/admin/auth/login"
|
||||
method="post"
|
||||
className="mx-auto max-w-sm space-y-4 text-left"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
<button className="inline-flex h-11 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Admin sign-in is not configured
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Admin access is not available right now.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Admin sign-in is not configured
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Enable the admin UI and connect an auth provider before using this
|
||||
area.
|
||||
</p>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,74 +1,16 @@
|
|||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
Refrigerator,
|
||||
ShoppingCart,
|
||||
} from "lucide-react"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import type { ImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSectionHeader,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import type { Metadata } from "next"
|
||||
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const WORDPRESS_SLUG = "vending-machines-for-sale-in-utah"
|
||||
|
||||
const machineHighlights = [
|
||||
"Snack, drink, combo, and card-reader-ready equipment options",
|
||||
"New and used machine guidance based on traffic, budget, and product mix",
|
||||
"Local help with payment hardware, installation planning, and next-step questions",
|
||||
]
|
||||
|
||||
const machineOptions = [
|
||||
{
|
||||
title: "Snack and drink machines",
|
||||
description:
|
||||
"Traditional snack, beverage, and combo machines for breakrooms, customer spaces, and mixed-traffic locations.",
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
{
|
||||
title: "Cashless payment hardware",
|
||||
description:
|
||||
"Card reader and mobile-payment options that help modernize older machines or support new installs.",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: "Refrigerated equipment",
|
||||
description:
|
||||
"Cold drink and refrigerated machine options for workplaces that need dependable temperature-controlled service.",
|
||||
icon: Refrigerator,
|
||||
},
|
||||
]
|
||||
|
||||
const buyingSteps = [
|
||||
{
|
||||
title: "Tell us about the location",
|
||||
body: "We learn about traffic, available space, product needs, and whether you are comparing free placement with a direct purchase.",
|
||||
},
|
||||
{
|
||||
title: "Compare the right machine options",
|
||||
body: "We help narrow down machine styles, payment hardware, and new-versus-used tradeoffs so you are looking at realistic fits.",
|
||||
},
|
||||
{
|
||||
title: "Plan install and support",
|
||||
body: "Once you know what you want, we can talk through delivery, setup, payment configuration, and any follow-up service needs.",
|
||||
},
|
||||
]
|
||||
|
||||
function normalizeWpImageUrl(url?: string) {
|
||||
if (!url) return null
|
||||
return url.replace("https:///", "https://rockymountainvending.com/")
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
|
|
@ -97,6 +39,13 @@ export default async function MachinesForSalePage() {
|
|||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: ImageMapping = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping()
|
||||
} catch {
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const structuredData = (() => {
|
||||
try {
|
||||
return generateStructuredData({
|
||||
|
|
@ -121,16 +70,6 @@ export default async function MachinesForSalePage() {
|
|||
}
|
||||
})()
|
||||
|
||||
const heroImage =
|
||||
normalizeWpImageUrl(page.images?.[0]?.url) ??
|
||||
"https://rockymountainvending.com/wp-content/uploads/2024/01/EH0A1551-HDR.webp"
|
||||
const comboImage =
|
||||
normalizeWpImageUrl(page.images?.[1]?.url) ??
|
||||
"https://rockymountainvending.com/wp-content/uploads/2022/06/Seage-HY900-Combo.webp"
|
||||
const paymentImage =
|
||||
normalizeWpImageUrl(page.images?.[2]?.url) ??
|
||||
"https://rockymountainvending.com/wp-content/uploads/2024/01/Parlevel-Pay-Plus.jpg"
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
|
|
@ -149,168 +88,19 @@ export default async function MachinesForSalePage() {
|
|||
]}
|
||||
/>
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Machine Sales"
|
||||
title="Compare vending machines, payment hardware, and purchase options with a local Utah team."
|
||||
description="If you are looking at buying equipment instead of free placement, we can help you compare machine styles, payment systems, and next-step support without sending you through a generic catalog dump."
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Button asChild size="lg" className="min-h-11 rounded-full px-6">
|
||||
<Link href="/contact-us#contact-form">Ask About Sales</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="min-h-11 rounded-full px-6"
|
||||
>
|
||||
<Link href="/products">Browse Product Listings</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</PublicPageHeader>
|
||||
title={page.title || "Vending Machines for Sale in Utah"}
|
||||
description="Compare machine options, payment hardware, and support with help from the Rocky Mountain Vending team."
|
||||
/>
|
||||
|
||||
<section className="mt-10 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<PublicSurface className="flex h-full flex-col justify-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Sales Overview
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
|
||||
Buying a machine should feel clear before you spend money.
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
|
||||
We help Utah businesses sort through machine type, payment
|
||||
hardware, and install considerations so you can decide whether a
|
||||
direct purchase is the right move for your location.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3">
|
||||
{machineHighlights.map((highlight) => (
|
||||
<li key={highlight} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
|
||||
<span className="text-sm leading-6 text-foreground md:text-base">
|
||||
{highlight}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PublicSurface>
|
||||
<div className="relative min-h-[320px] overflow-hidden rounded-[var(--public-surface-radius)] border border-border/65 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.96))] p-3 shadow-[0_20px_52px_rgba(15,23,42,0.075)]">
|
||||
<Image
|
||||
src={heroImage}
|
||||
alt="Vending machine option available for sale in Utah"
|
||||
fill
|
||||
className="rounded-[calc(var(--public-surface-radius)-0.45rem)] object-cover"
|
||||
sizes="(max-width: 1280px) 100vw, 560px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<PublicSectionHeader
|
||||
eyebrow="Machine Options"
|
||||
title="What businesses usually want help comparing"
|
||||
description="Most sales conversations come down to the machine type, payment setup, and whether a direct purchase makes more sense than placement."
|
||||
className="mx-auto mb-8 max-w-3xl text-center"
|
||||
/>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{machineOptions.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<PublicInset key={option.title} className="h-full p-5 md:p-6">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-semibold tracking-tight text-foreground">
|
||||
{option.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
|
||||
{option.description}
|
||||
</p>
|
||||
</PublicInset>
|
||||
)
|
||||
<PublicSurface className="mt-10">
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content || ""), {
|
||||
imageMapping,
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[0.98fr_1.02fr]">
|
||||
<PublicSurface className="overflow-hidden p-0">
|
||||
<div className="grid gap-0 md:grid-cols-2">
|
||||
<div className="relative min-h-[260px]">
|
||||
<Image
|
||||
src={comboImage}
|
||||
alt="Combo vending machine for sale"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 360px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center p-5 md:p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
New vs. Used
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
||||
We can help you sort through budget, features, and condition.
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
|
||||
Some buyers need dependable starter equipment. Others need a
|
||||
cleaner, more modern machine with stronger payment support.
|
||||
We can talk through both without pushing you into the wrong
|
||||
setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
<PublicSurface className="overflow-hidden p-0">
|
||||
<div className="grid gap-0 md:grid-cols-2">
|
||||
<div className="order-2 flex flex-col justify-center p-5 md:order-1 md:p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Payment Hardware
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
||||
Card readers and cashless upgrades are often part of the decision.
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
|
||||
If you are trying to modernize how people pay, we can help
|
||||
you think through card readers, mobile payments, and
|
||||
compatibility before you commit to a machine.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative order-1 min-h-[260px] md:order-2">
|
||||
<Image
|
||||
src={paymentImage}
|
||||
alt="Cashless payment hardware for vending machines"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 360px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<PublicSectionHeader
|
||||
eyebrow="Buying Process"
|
||||
title="A simpler way to move from questions to a real option"
|
||||
description="You do not need to have the exact model picked out before you reach out. Most of the work is narrowing to the right fit."
|
||||
className="mx-auto mb-8 max-w-3xl text-center"
|
||||
/>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{buyingSteps.map((step, index) => (
|
||||
<PublicInset key={step.title} className="h-full p-5 md:p-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-lg font-semibold text-primary-foreground">
|
||||
{index + 1}
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-semibold tracking-tight text-foreground">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
|
||||
{step.body}
|
||||
</p>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PublicSurface>
|
||||
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<PublicSurface>
|
||||
|
|
@ -321,9 +111,9 @@ export default async function MachinesForSalePage() {
|
|||
Need a free machine instead of buying one?
|
||||
</h2>
|
||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
If you are a business looking for placement instead of a
|
||||
purchase, we can help you figure out whether your location is a
|
||||
fit before you spend money on equipment.
|
||||
If you're a business looking for placement instead of a
|
||||
purchase, we can help you find the right setup for your
|
||||
location.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<GetFreeMachineCta buttonLabel="Get Free Placement" />
|
||||
|
|
@ -335,23 +125,13 @@ export default async function MachinesForSalePage() {
|
|||
Need Sales Help?
|
||||
</p>
|
||||
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
||||
Talk through machine sales, placement, or payment questions.
|
||||
Talk through machine sales, placement, or feature questions.
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
We can help with new vs. used options, payment hardware, and
|
||||
whether free placement or a direct purchase makes more sense
|
||||
for your location.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="min-h-11 rounded-full px-6"
|
||||
>
|
||||
<Link href="/contact-us#contact-form">Talk to Sales</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
|
||||
export function AboutPage() {
|
||||
return (
|
||||
<div className="public-page">
|
||||
<div className="public-page max-w-6xl">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[{ label: "About Us", href: "/about-us" }]}
|
||||
|
|
|
|||
|
|
@ -29,37 +29,7 @@ export function ContactPage() {
|
|||
description="Use the form for repairs, moving, manuals, machine sales, or general questions. If you'd rather talk now, call us during business hours."
|
||||
/>
|
||||
|
||||
<section className="mt-10 grid gap-4 lg:grid-cols-3">
|
||||
<PublicInset className="h-full p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Repairs
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Include the machine model, what the machine is doing, and any photos
|
||||
or videos that can help us triage the issue faster.
|
||||
</p>
|
||||
</PublicInset>
|
||||
<PublicInset className="h-full p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Sales or Placement
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Tell us about your location, traffic, and whether you are asking
|
||||
about free placement, machine sales, or both.
|
||||
</p>
|
||||
</PublicInset>
|
||||
<PublicInset className="h-full p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Manuals or Parts
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Share the machine brand and model so we can point you toward the
|
||||
right part, manual, or support path.
|
||||
</p>
|
||||
</PublicInset>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] lg:items-start">
|
||||
<div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)] lg:items-start">
|
||||
<PublicSurface id="contact-form" as="section" className="p-5 md:p-7">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
|
|
@ -75,21 +45,7 @@ export function ContactPage() {
|
|||
/>
|
||||
</PublicSurface>
|
||||
|
||||
<aside className="space-y-4 lg:sticky lg:top-28">
|
||||
<PublicSurface className="p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Quick Guidance
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-foreground">
|
||||
We'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's fine. Send the details you
|
||||
have and we'll help sort it out.
|
||||
</p>
|
||||
</PublicSurface>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<PublicSurface className="overflow-hidden p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Direct Options
|
||||
|
|
@ -103,10 +59,10 @@ export function ContactPage() {
|
|||
below.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="mt-6 space-y-4">
|
||||
<a
|
||||
href={businessConfig.publicCallUrl}
|
||||
className="flex items-start gap-4 rounded-2xl border border-border/55 bg-background/65 px-4 py-4 transition hover:border-primary/35"
|
||||
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Phone className="h-5 w-5" />
|
||||
|
|
@ -124,7 +80,7 @@ export function ContactPage() {
|
|||
|
||||
<a
|
||||
href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`}
|
||||
className="flex items-start gap-4 rounded-2xl border border-border/55 bg-background/65 px-4 py-4 transition hover:border-primary/35"
|
||||
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Mail className="h-5 w-5" />
|
||||
|
|
@ -157,7 +113,7 @@ export function ContactPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="mt-5 space-y-2">
|
||||
{businessHours.map((schedule) => (
|
||||
<PublicInset
|
||||
key={schedule.day}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { Breadcrumbs, type BreadcrumbItem } from "@/components/breadcrumbs"
|
|||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicProse,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
|
@ -22,7 +21,6 @@ interface DropdownPageShellProps {
|
|||
title: string
|
||||
description?: string
|
||||
headerContent?: ReactNode
|
||||
contentIntro?: ReactNode
|
||||
content: ReactNode
|
||||
contentClassName?: string
|
||||
contentSurfaceClassName?: string
|
||||
|
|
@ -43,7 +41,6 @@ export function DropdownPageShell({
|
|||
title,
|
||||
description,
|
||||
headerContent,
|
||||
contentIntro,
|
||||
content,
|
||||
contentClassName,
|
||||
contentSurfaceClassName,
|
||||
|
|
@ -64,43 +61,19 @@ export function DropdownPageShell({
|
|||
{headerContent}
|
||||
</PublicPageHeader>
|
||||
|
||||
{contentIntro ? (
|
||||
<section className="mt-10 grid gap-5 lg:grid-cols-2">{contentIntro}</section>
|
||||
) : null}
|
||||
|
||||
<section className={cn(contentIntro ? "mt-8" : "mt-10")}>
|
||||
<section className="mt-10">
|
||||
<PublicSurface
|
||||
className={cn(
|
||||
"relative overflow-hidden p-0 md:p-0",
|
||||
contentSurfaceClassName
|
||||
)}
|
||||
className={cn("overflow-hidden", contentSurfaceClassName)}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-24 bg-[radial-gradient(circle_at_top,rgba(41,160,71,0.09),transparent_74%)]" />
|
||||
<div className="relative p-5 md:p-7 lg:p-10">
|
||||
<div className="mb-8 flex items-center justify-between gap-4 border-b border-border/55 pb-5">
|
||||
<div>
|
||||
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
|
||||
Location Guide
|
||||
</p>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
How Rocky Mountain Vending typically approaches this type of
|
||||
location, from placement fit to service expectations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PublicProse className={cn("mx-auto max-w-3xl", contentClassName)}>
|
||||
{content}
|
||||
</PublicProse>
|
||||
</div>
|
||||
<div className={cn("max-w-none", contentClassName)}>{content}</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
{sections ? <div className="mt-14 space-y-14">{sections}</div> : null}
|
||||
{sections ? <div className="mt-12 space-y-12">{sections}</div> : null}
|
||||
|
||||
{cta ? (
|
||||
<section className="mt-14">
|
||||
<PublicSurface className="overflow-hidden text-center">
|
||||
<div className="absolute inset-x-0 top-0 h-20 bg-[radial-gradient(circle_at_top,rgba(41,160,71,0.10),transparent_70%)]" />
|
||||
<section className="mt-12">
|
||||
<PublicSurface className="text-center">
|
||||
{cta.eyebrow ? (
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
{cta.eyebrow}
|
||||
|
|
@ -126,7 +99,7 @@ export function DropdownPageShell({
|
|||
))}
|
||||
</div>
|
||||
{cta.note ? (
|
||||
<PublicInset className="mx-auto mt-6 max-w-2xl border-primary/10 text-left sm:text-center">
|
||||
<PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center">
|
||||
{cta.note}
|
||||
</PublicInset>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ const features = [
|
|||
title: "Free Placement",
|
||||
description:
|
||||
"For qualifying locations, we can place the machines, stock them, and stay responsible for day-to-day service after install.",
|
||||
link: "/#how-it-works",
|
||||
link: "/about-us",
|
||||
linkText: "How placement works",
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Repairs and Services",
|
||||
title: "Repairs and Service",
|
||||
description:
|
||||
"We handle repairs, restocking, and routine service so your team does not have to manage the machines.",
|
||||
link: "/services/repairs#request-service",
|
||||
link: "/services/repairs",
|
||||
linkText: "Repair services",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function Footer() {
|
|||
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 py-14 sm:px-5 md:py-20 lg:px-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.9fr_0.9fr_1fr]">
|
||||
{/* Company Info */}
|
||||
<div className="px-6 py-5 lg:col-span-1">
|
||||
<div className="rounded-[2rem] border border-border/60 bg-white/92 p-6 shadow-[0_18px_48px_rgba(15,23,42,0.07)] lg:col-span-1">
|
||||
<Link href="/" className="inline-flex">
|
||||
<Image
|
||||
src="/rmv-logo.png"
|
||||
|
|
@ -104,7 +104,7 @@ export function Footer() {
|
|||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div className="px-5 py-5">
|
||||
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
|
||||
<h3 className="font-semibold mb-5 text-base">Services</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
|
|
@ -159,7 +159,7 @@ export function Footer() {
|
|||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div className="px-5 py-5">
|
||||
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
|
||||
<h3 className="font-semibold mb-5 text-base">Company</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
|
|
@ -198,7 +198,7 @@ export function Footer() {
|
|||
</div>
|
||||
|
||||
{/* Service Areas */}
|
||||
<div className="px-5 py-5">
|
||||
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
|
||||
<h3 className="font-semibold mb-5 text-base">Service Areas</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
|
|
|
|||
|
|
@ -162,16 +162,9 @@ export function Header() {
|
|||
{ label: "Reviews", href: "/reviews" },
|
||||
{ label: "FAQs", href: "/about/faqs" },
|
||||
]
|
||||
const moreItems = [
|
||||
{ label: "Food & Beverage", href: "/food-and-beverage/healthy-options" },
|
||||
{ label: "Blog Posts", href: "/blog" },
|
||||
{ label: "About Us", href: "/about-us" },
|
||||
{ label: "Products", href: "/products" },
|
||||
{ label: "Service Areas", href: "/service-areas" },
|
||||
]
|
||||
|
||||
const desktopLinkClassName =
|
||||
"inline-flex items-center whitespace-nowrap rounded-full px-2.5 py-2 text-[0.95rem] font-medium text-foreground transition hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 lg:px-3 lg:text-sm"
|
||||
"rounded-full px-3 py-2 text-sm font-medium text-foreground transition hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||
const mobileLinkClassName =
|
||||
"flex min-h-11 items-center rounded-[1rem] px-4 text-sm font-medium text-foreground transition hover:bg-primary/6 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||
const mobileGroupButtonClassName =
|
||||
|
|
@ -182,7 +175,7 @@ export function Header() {
|
|||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border/50 bg-white/92 shadow-[0_10px_35px_rgba(15,23,42,0.06)] backdrop-blur supports-[backdrop-filter]:bg-white/80">
|
||||
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 sm:px-5 lg:px-6">
|
||||
<div className="flex h-[var(--header-height)] items-center justify-between gap-3 md:hidden lg:gap-6">
|
||||
<div className="flex h-[var(--header-height)] items-center justify-between gap-3 lg:gap-6">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
|
|
@ -199,7 +192,7 @@ export function Header() {
|
|||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden flex-1 items-center justify-center gap-1 2xl:flex 2xl:gap-2">
|
||||
<nav className="hidden flex-1 items-center justify-center gap-1 md:flex lg:gap-2">
|
||||
<Link href="/" className={desktopLinkClassName}>
|
||||
Home
|
||||
</Link>
|
||||
|
|
@ -378,7 +371,7 @@ export function Header() {
|
|||
</nav>
|
||||
|
||||
{/* Desktop CTA */}
|
||||
<div className="hidden flex-shrink-0 items-center gap-2 2xl:flex 2xl:gap-3">
|
||||
<div className="hidden flex-shrink-0 items-center gap-2 md:flex lg:gap-3">
|
||||
<CartButton
|
||||
onClick={() => dispatch({ type: "SET_CART", value: true })}
|
||||
/>
|
||||
|
|
@ -400,7 +393,7 @@ export function Header() {
|
|||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-white text-foreground transition hover:border-primary/35 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 2xl:hidden"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-white text-foreground transition hover:border-primary/35 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 md:hidden"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={state.isMenuOpen}
|
||||
|
|
@ -413,161 +406,6 @@ export function Header() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="flex min-h-[4.75rem] items-center justify-between gap-4 py-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex min-w-0 flex-shrink-0 items-center gap-2 rounded-full"
|
||||
>
|
||||
<Image
|
||||
src="/rmv-logo.png"
|
||||
alt="Rocky Mountain Vending"
|
||||
width={220}
|
||||
height={55}
|
||||
className="h-12 w-auto object-contain lg:h-14"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="flex min-w-0 flex-shrink-0 items-center gap-2 lg:gap-3">
|
||||
<CartButton
|
||||
onClick={() => dispatch({ type: "SET_CART", value: true })}
|
||||
/>
|
||||
<a
|
||||
href="tel:+14352339668"
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-full border border-border/60 bg-white px-3 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary lg:px-4"
|
||||
>
|
||||
<Phone className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden xl:inline">(435) 233-9668</span>
|
||||
<span className="xl:hidden">Call</span>
|
||||
</a>
|
||||
<Button
|
||||
onClick={() => dispatch({ type: "SET_MODAL", value: true })}
|
||||
className="h-10 whitespace-nowrap rounded-full bg-primary px-4 text-sm hover:bg-primary/90 lg:px-5"
|
||||
size="lg"
|
||||
>
|
||||
Get Free Machine
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[3.5rem] items-center justify-center border-t border-border/45 py-2">
|
||||
<nav className="flex flex-wrap items-center justify-center gap-x-1 gap-y-2 lg:gap-x-2">
|
||||
<Link href="/" className={desktopLinkClassName}>
|
||||
Home
|
||||
</Link>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
Who We Serve
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-56", desktopDropdownClassName)}
|
||||
>
|
||||
{whoWeServeItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
Vending Machines
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-64", desktopDropdownClassName)}
|
||||
>
|
||||
{vendingMachinesItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
Services
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-72", desktopDropdownClassName)}
|
||||
>
|
||||
{servicesItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Link href="/contact-us" className={desktopLinkClassName}>
|
||||
Contact Us
|
||||
</Link>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
More
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn("w-56", desktopDropdownClassName)}
|
||||
>
|
||||
{moreItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{state.isMenuOpen && (
|
||||
<nav className="border-t border-border/40 py-5 md:hidden">
|
||||
|
|
|
|||
|
|
@ -114,52 +114,11 @@ export function LocationLandingPage({
|
|||
}: {
|
||||
locationData: LocationData
|
||||
}) {
|
||||
const isSaltLakeCity = locationData.slug === "salt-lake-city-utah"
|
||||
const countyName = getCountyName(locationData.slug)
|
||||
const industries = getIndustryFocus(locationData)
|
||||
const canonicalUrl = buildAbsoluteUrl(buildLocationRoute(locationData.slug))
|
||||
const title = `Vending Machines in ${locationData.city}, ${locationData.stateAbbr}`
|
||||
const description = `Rocky Mountain Vending provides free placement for qualifying locations, machine sales, repairs, and vending service for businesses in ${locationData.city}, ${locationData.stateAbbr}.`
|
||||
const comparisonRows = [
|
||||
["Credit card readers", "Yes", "Maybe", "Yes"],
|
||||
["Locally owned", "Yes", "Yes", "Maybe"],
|
||||
["Fast service", "Yes", "Maybe", "Maybe"],
|
||||
["Large selection of products", "Yes", "Maybe", "Yes"],
|
||||
["Quality of equipment used", "Excellent", "Varies", "Excellent"],
|
||||
["Locked into Coke or Pepsi equipment", "No", "Maybe", "Probably"],
|
||||
]
|
||||
const saltLakeServiceLinks = [
|
||||
{
|
||||
title: "Traditional snacks and drinks",
|
||||
body: "Stock the machine with the classic snacks, sodas, and convenience items most locations still want every day.",
|
||||
href: "/food-and-beverage/traditional-options",
|
||||
},
|
||||
{
|
||||
title: "Healthy snacks and drinks",
|
||||
body: "Offer protein bars, better-for-you snacks, and drink choices that fit health-conscious teams and customers.",
|
||||
href: "/food-and-beverage/healthy-options",
|
||||
},
|
||||
{
|
||||
title: "Snack and drink delivery",
|
||||
body: "Need product delivery or a broader refreshment setup beyond standard machine placement? We can help there too.",
|
||||
href: "/food-and-beverage/snack-and-drink-delivery",
|
||||
},
|
||||
{
|
||||
title: "Vending machine sales",
|
||||
body: "Compare purchase options if you want equipment ownership instead of a free-placement arrangement.",
|
||||
href: "/vending-machines/machines-for-sale",
|
||||
},
|
||||
{
|
||||
title: "Parts, repairs, and moving",
|
||||
body: "Get support for repair work, machine moving, replacement parts, and operational issues that need direct service help.",
|
||||
href: "/services/parts",
|
||||
},
|
||||
{
|
||||
title: "Training and support",
|
||||
body: "Browse the support pages and machine guides if you need help with specific models, manuals, or machine operation questions.",
|
||||
href: "/blog",
|
||||
},
|
||||
]
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
|
|
@ -229,15 +188,15 @@ export function LocationLandingPage({
|
|||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Local Service Area"
|
||||
title={`Vending machine service for businesses in ${locationData.city}, ${locationData.stateAbbr}`}
|
||||
description={`Rocky Mountain Vending helps businesses in ${locationData.city} with free placement for qualifying locations, machine sales, repairs, parts, and ongoing service across ${countyName} and nearby communities.`}
|
||||
title={`${locationData.city}, ${locationData.stateAbbr} vending machine service`}
|
||||
description={`Rocky Mountain Vending serves businesses in ${locationData.city}, ${locationData.stateAbbr} with free placement for qualifying locations, machine sales, repairs, parts, and ongoing restocking and service across ${countyName} and nearby communities.`}
|
||||
className="mb-12 md:mb-16"
|
||||
/>
|
||||
|
||||
<div className="mx-auto mb-14 grid max-w-5xl gap-6 lg:grid-cols-[1.08fr_0.92fr]">
|
||||
<div className="mx-auto mb-12 grid max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<PublicSurface className="p-6 md:p-8">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
A local vending partner for businesses across {locationData.city}
|
||||
Vending service for businesses across {locationData.city}
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
||||
If your business is in {locationData.neighborhoods.join(", ")}, or
|
||||
|
|
@ -260,11 +219,11 @@ export function LocationLandingPage({
|
|||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
||||
Common business types we serve in {locationData.city}
|
||||
</h2>
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{industries.map((industry) => (
|
||||
<span
|
||||
key={industry}
|
||||
className="rounded-full border border-border/60 bg-background px-3 py-1.5 text-sm text-muted-foreground"
|
||||
className="rounded-full border border-border/60 bg-background px-3 py-1 text-sm text-muted-foreground"
|
||||
>
|
||||
{industry}
|
||||
</span>
|
||||
|
|
@ -277,15 +236,6 @@ export function LocationLandingPage({
|
|||
Utah service area.
|
||||
</p>
|
||||
</PublicInset>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<GetFreeMachineCta buttonLabel="Check Placement Fit" />
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
Talk to Our Team
|
||||
</Link>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
|
||||
|
|
@ -293,7 +243,7 @@ export function LocationLandingPage({
|
|||
<h2 className="text-3xl font-bold tracking-tight text-balance">
|
||||
Vending services available in {locationData.city}
|
||||
</h2>
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-2">
|
||||
<div className="mt-8 grid gap-6 md:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
title: "Free vending placement",
|
||||
|
|
@ -320,7 +270,7 @@ export function LocationLandingPage({
|
|||
cta: "View manuals and parts",
|
||||
},
|
||||
].map((service) => (
|
||||
<PublicSurface key={service.title} className="h-full p-6 md:p-7">
|
||||
<PublicSurface key={service.title} className="h-full p-6">
|
||||
<h3 className="text-xl font-semibold">{service.title}</h3>
|
||||
<p className="mt-3 leading-7 text-muted-foreground">
|
||||
{service.body}
|
||||
|
|
@ -337,95 +287,6 @@ export function LocationLandingPage({
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{isSaltLakeCity ? (
|
||||
<section className="mx-auto mb-16 max-w-5xl space-y-6">
|
||||
<PublicSurface className="p-6 md:p-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Why Rocky Mountain Vending
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight text-balance">
|
||||
What Salt Lake City businesses usually want to verify before they choose a vendor.
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
||||
Most businesses care about the same things: service speed,
|
||||
product flexibility, local ownership, and whether the machines
|
||||
feel modern and dependable after install.
|
||||
</p>
|
||||
<div className="mt-6 overflow-hidden rounded-[1.5rem] border border-border/60">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[680px] border-collapse text-sm">
|
||||
<thead className="bg-muted/55">
|
||||
<tr className="border-b border-border/60">
|
||||
<th className="px-4 py-3 text-left font-semibold text-foreground">
|
||||
Comparison point
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-foreground">
|
||||
Rocky Mountain Vending
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-foreground">
|
||||
Small Vendor
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-foreground">
|
||||
Large Vendor
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparisonRows.map((row, index) => (
|
||||
<tr
|
||||
key={row[0]}
|
||||
className={`border-b border-border/50 ${index % 2 === 0 ? "bg-background" : "bg-muted/20"}`}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{row[0]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{row[1]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{row[2]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{row[3]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
|
||||
<PublicSurface className="p-6 md:p-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Salt Lake City Services
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight text-balance">
|
||||
The service paths Salt Lake City businesses usually ask about first.
|
||||
</h2>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{saltLakeServiceLinks.map((item) => (
|
||||
<PublicInset key={item.title} className="flex h-full flex-col">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{item.body}
|
||||
</p>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Learn more
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mx-auto mb-16 grid max-w-5xl gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<PublicSurface className="p-6 md:p-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-balance">
|
||||
|
|
@ -544,7 +405,7 @@ export function LocationLandingPage({
|
|||
vending help you need. We'll follow up with the next best
|
||||
option for your location.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<GetFreeMachineCta buttonLabel="See If Your Location Qualifies" />
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ export function ManualViewer({
|
|||
const [pdfError, setPdfError] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showPartsPanel, setShowPartsPanel] = useState(true)
|
||||
const [partsPanelLoading, setPartsPanelLoading] = useState(true)
|
||||
const [partsPanelVisible, setPartsPanelVisible] = useState(true)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// Reset error state when manual URL changes
|
||||
|
|
@ -51,8 +49,6 @@ export function ManualViewer({
|
|||
if (isOpen) {
|
||||
setPdfError(false)
|
||||
setIsLoading(true)
|
||||
setPartsPanelLoading(true)
|
||||
setPartsPanelVisible(true)
|
||||
}
|
||||
}, [manualUrl, isOpen])
|
||||
|
||||
|
|
@ -69,17 +65,6 @@ export function ManualViewer({
|
|||
setPdfError(true)
|
||||
}
|
||||
|
||||
const showPartsPanelWithData =
|
||||
showPartsPanel && (partsPanelLoading || partsPanelVisible)
|
||||
const canToggleParts = partsPanelLoading || partsPanelVisible
|
||||
const partsToggleLabel = partsPanelLoading
|
||||
? "Checking Parts..."
|
||||
: partsPanelVisible
|
||||
? showPartsPanel
|
||||
? "Hide Parts"
|
||||
: "Show Parts"
|
||||
: "Parts Unavailable"
|
||||
|
||||
// Mobile layout - use Sheet
|
||||
if (isMobile) {
|
||||
return (
|
||||
|
|
@ -217,10 +202,9 @@ export function ManualViewer({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPartsPanel(!showPartsPanel)}
|
||||
disabled={!canToggleParts}
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-1" />
|
||||
{partsToggleLabel}
|
||||
{showPartsPanel ? "Hide" : "Show"} Parts
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -255,7 +239,7 @@ export function ManualViewer({
|
|||
{/* PDF Viewer - responsive width based on parts panel */}
|
||||
<div
|
||||
className={`overflow-hidden min-h-0 relative transition-all duration-300 h-full ${
|
||||
showPartsPanelWithData ? "w-[75%] lg:w-[80%]" : "w-full"
|
||||
showPartsPanel ? "w-[75%] lg:w-[80%]" : "w-full"
|
||||
}`}
|
||||
>
|
||||
{isLoading && !pdfError && (
|
||||
|
|
@ -313,14 +297,10 @@ export function ManualViewer({
|
|||
)}
|
||||
</div>
|
||||
{/* Parts Panel - right side, responsive width */}
|
||||
{showPartsPanelWithData && (
|
||||
{showPartsPanel && (
|
||||
<PartsPanel
|
||||
manualFilename={filename}
|
||||
className={`border-l border-yellow-300/20 bg-yellow-50 dark:bg-yellow-950/90 overflow-y-auto h-full ${"w-[25%] lg:w-[20%]"}`}
|
||||
onStateChange={(state) => {
|
||||
setPartsPanelLoading(state.isLoading)
|
||||
setPartsPanelVisible(state.isVisible)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import {
|
|||
ShoppingCart,
|
||||
LayoutGrid,
|
||||
List,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react"
|
||||
import type { Manual, ManualGroup } from "@/lib/manuals-types"
|
||||
import { getManualUrl, getThumbnailUrl } from "@/lib/manuals-types"
|
||||
|
|
@ -37,6 +40,176 @@ import {
|
|||
} from "@/lib/manuals-config"
|
||||
import { ManualViewer } from "@/components/manual-viewer"
|
||||
import { getManualsWithParts } from "@/lib/parts-lookup"
|
||||
import { ebayClient } from "@/lib/ebay-api"
|
||||
|
||||
// Product Suggestion Component
|
||||
interface ProductSuggestion {
|
||||
itemId: string
|
||||
title: string
|
||||
price: string
|
||||
currency: string
|
||||
imageUrl?: string
|
||||
viewItemUrl: string
|
||||
affiliateLink: string
|
||||
condition?: string
|
||||
}
|
||||
|
||||
interface ProductSuggestionsProps {
|
||||
manual: Manual
|
||||
className?: string
|
||||
}
|
||||
|
||||
function ProductSuggestions({
|
||||
manual,
|
||||
className = "",
|
||||
}: ProductSuggestionsProps) {
|
||||
const [suggestions, setSuggestions] = useState<ProductSuggestion[]>([])
|
||||
const [isLoading, setIsLoading] = useState(ebayClient.isConfigured())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ebayClient.isConfigured()) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
async function loadSuggestions() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Generate search query from manual content
|
||||
const query = `${manual.manufacturer} ${manual.category} vending machine`
|
||||
const results = await ebayClient.searchItems({
|
||||
keywords: query,
|
||||
maxResults: 6,
|
||||
sortOrder: "BestMatch",
|
||||
})
|
||||
|
||||
setSuggestions(results)
|
||||
} catch (err) {
|
||||
console.error("Error loading product suggestions:", err)
|
||||
setError("Could not load product suggestions")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
loadSuggestions()
|
||||
}
|
||||
}, [manual])
|
||||
|
||||
if (!ebayClient.isConfigured()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-5 w-5 text-yellow-700 dark:text-yellow-300 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<AlertCircle className="h-6 w-6 text-red-500" />
|
||||
<span className="ml-2 text-sm text-red-500">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<AlertCircle className="h-6 w-6 text-yellow-500" />
|
||||
<span className="ml-2 text-sm text-yellow-600">
|
||||
No products found in sandbox environment
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ShoppingCart className="h-4 w-4 text-yellow-700 dark:text-yellow-300" />
|
||||
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
Related Products
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{suggestions.map((product) => (
|
||||
<a
|
||||
key={product.itemId}
|
||||
href={product.affiliateLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block group"
|
||||
>
|
||||
<div className="bg-white dark:bg-yellow-900/30 rounded border border-yellow-300/40 dark:border-yellow-700/40 p-2 hover:bg-yellow-50 dark:hover:bg-yellow-900/40 transition-colors">
|
||||
{/* Image */}
|
||||
{product.imageUrl && (
|
||||
<div className="mb-2 rounded overflow-hidden bg-yellow-100 dark:bg-yellow-900/50">
|
||||
<img
|
||||
src={product.imageUrl}
|
||||
alt={product.title}
|
||||
className="w-full h-16 object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = `https://via.placeholder.com/120x80/fbbf24/1f2937?text=${encodeURIComponent(product.title)}`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!product.imageUrl && (
|
||||
<div className="mb-2 rounded overflow-hidden bg-yellow-100 dark:bg-yellow-900/50 h-16 flex items-center justify-center">
|
||||
<span className="text-[10px] text-yellow-700 dark:text-yellow-300">
|
||||
No Image
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] text-yellow-900 dark:text-yellow-100 line-clamp-2 min-h-[1.5rem]">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
{product.price}
|
||||
</span>
|
||||
<ExternalLink className="h-3 w-3 text-yellow-700 dark:text-yellow-300 group-hover:text-yellow-900 dark:group-hover:text-yellow-100 transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
{product.condition && (
|
||||
<div className="text-[9px] text-yellow-700/80 dark:text-yellow-300/80">
|
||||
{product.condition}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ManualsPageClientProps {
|
||||
manuals: Manual[]
|
||||
|
|
@ -65,7 +238,7 @@ function ManualCard({
|
|||
const thumbnailUrl = getThumbnailUrl(manual)
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden rounded-[1.75rem] border border-border/70 bg-background shadow-[0_16px_40px_rgba(15,23,42,0.075)] transition-all hover:-translate-y-0.5 hover:shadow-[0_22px_54px_rgba(15,23,42,0.11)]">
|
||||
<Card className="overflow-hidden rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_60px_rgba(15,23,42,0.12)]">
|
||||
{thumbnailUrl && (
|
||||
<div className="relative h-48 min-h-[192px] w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.12),transparent_52%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,255,255,0.98))]">
|
||||
<Image
|
||||
|
|
@ -77,13 +250,13 @@ function ManualCard({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="space-y-3 px-5 pt-5">
|
||||
<CardHeader className="px-5 pt-5">
|
||||
<CardTitle className="line-clamp-2 text-base leading-snug">
|
||||
{manual.filename.replace(/\.pdf$/i, "")}
|
||||
</CardTitle>
|
||||
{manual.commonNames && manual.commonNames.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{manual.commonNames.slice(0, 3).map((name, index) => (
|
||||
{manual.commonNames.map((name, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
|
|
@ -92,21 +265,13 @@ function ManualCard({
|
|||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
{manual.commonNames.length > 3 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-full border border-border/60 bg-muted/35 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
+{manual.commonNames.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{manual.searchTerms &&
|
||||
manual.searchTerms.length > 0 &&
|
||||
!manual.commonNames && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{manual.searchTerms.slice(0, 4).map((term, index) => (
|
||||
{manual.searchTerms.map((term, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
|
|
@ -115,14 +280,6 @@ function ManualCard({
|
|||
{term}
|
||||
</Badge>
|
||||
))}
|
||||
{manual.searchTerms.length > 4 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-full border border-border/60 bg-muted/35 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
+{manual.searchTerms.length - 4} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
|
@ -146,7 +303,7 @@ function ManualCard({
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{showManufacturer && (
|
||||
<p>
|
||||
<strong>Manufacturer:</strong> {manual.manufacturer}
|
||||
|
|
@ -490,28 +647,9 @@ export function ManualsPageClient({
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search and Filter Controls */}
|
||||
<PublicSurface className="p-4 md:p-6">
|
||||
<CardContent className="p-0">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-4 rounded-[1.5rem] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.94))] p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Start With Search
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-foreground">
|
||||
Find the manual first, then narrow it down
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
Search by model, manufacturer, or category. Use filters if
|
||||
you already know the brand or want manuals with parts.
|
||||
</p>
|
||||
</div>
|
||||
<PublicInset className="w-full rounded-[1.25rem] px-4 py-3 text-sm text-muted-foreground shadow-none md:max-w-xs">
|
||||
Showing <strong>{filteredManuals.length}</strong> of{" "}
|
||||
<strong>{manuals.length}</strong> manuals
|
||||
</PublicInset>
|
||||
</div>
|
||||
|
||||
<PublicSurface>
|
||||
<CardContent className="p-0">
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
|
|
@ -525,14 +663,12 @@ export function ManualsPageClient({
|
|||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
<div className="rounded-[1.5rem] border border-border/60 bg-white/80 p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4 items-start sm:items-center">
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<Filter className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium">Filters:</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full sm:w-auto">
|
||||
<div className="relative">
|
||||
<Select
|
||||
|
|
@ -630,12 +766,10 @@ export function ManualsPageClient({
|
|||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle and Results Count */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4 border-t border-border/55 pt-1">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<span className="text-sm text-muted-foreground flex-shrink-0">
|
||||
View:
|
||||
|
|
@ -673,8 +807,8 @@ export function ManualsPageClient({
|
|||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground w-full sm:w-auto text-center sm:text-left">
|
||||
View the full library grouped by manufacturer or switch to list
|
||||
view for a faster scan.
|
||||
Showing <strong>{filteredManuals.length}</strong> of{" "}
|
||||
<strong>{manuals.length}</strong> manuals
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -702,15 +836,11 @@ export function ManualsPageClient({
|
|||
{filteredGroupedManuals.map((group) => {
|
||||
const organized = organizeCategories(group.categories)
|
||||
return (
|
||||
<PublicSurface key={group.manufacturer} className="space-y-7 p-5 md:p-7">
|
||||
<div className="border-b border-border/60 pb-4">
|
||||
<PublicSurface key={group.manufacturer} className="space-y-6">
|
||||
<div className="border-b border-border/60 pb-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{group.manufacturer}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{Object.values(group.categories).flat().length} manuals
|
||||
available from this manufacturer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Machine Type Categories First */}
|
||||
|
|
@ -748,7 +878,7 @@ export function ManualsPageClient({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categoryManuals.map((manual) => (
|
||||
<ManualCard
|
||||
key={manual.path}
|
||||
|
|
@ -791,7 +921,7 @@ export function ManualsPageClient({
|
|||
)
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categoryManuals.map((manual) => (
|
||||
<ManualCard
|
||||
key={manual.path}
|
||||
|
|
@ -808,13 +938,28 @@ export function ManualsPageClient({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Suggestions Section */}
|
||||
{filteredManuals.length > 0 && (
|
||||
<div className="space-y-6 mt-8">
|
||||
<div className="border-t border-border/60 pt-6">
|
||||
<h3 className="text-lg font-semibold text-muted-foreground mb-4">
|
||||
Related Products
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredManuals.slice(0, 3).map((manual) => (
|
||||
<ProductSuggestions key={manual.path} manual={manual} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PublicSurface>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredManuals.map((manual) => (
|
||||
<ManualCard
|
||||
key={manual.path}
|
||||
|
|
|
|||
|
|
@ -3,63 +3,32 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { EbayCacheState } from "@/lib/ebay-parts-match"
|
||||
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
|
||||
import {
|
||||
hasTrustedPartsListings,
|
||||
shouldShowEbayPartsPanel,
|
||||
} from "@/lib/ebay-parts-visibility"
|
||||
|
||||
interface PartsPanelProps {
|
||||
manualFilename: string
|
||||
className?: string
|
||||
onStateChange?: (state: { isLoading: boolean; isVisible: boolean }) => void
|
||||
}
|
||||
|
||||
export function PartsPanel({
|
||||
manualFilename,
|
||||
className = "",
|
||||
onStateChange,
|
||||
}: PartsPanelProps) {
|
||||
const [parts, setParts] = useState<PartForPage[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cache, setCache] = useState<EbayCacheState | null>(null)
|
||||
|
||||
const formatFreshness = (value: number | null) => {
|
||||
if (!value) {
|
||||
return "not refreshed yet"
|
||||
}
|
||||
|
||||
const minutes = Math.max(0, Math.floor(value / 60000))
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
const loadParts = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setParts([])
|
||||
setCache(null)
|
||||
|
||||
try {
|
||||
const result = await getTopPartsForManual(manualFilename, 5)
|
||||
setParts(result.parts)
|
||||
setError(result.error ?? null)
|
||||
setCache(result.cache ?? null)
|
||||
} catch (err) {
|
||||
console.error("Error loading parts:", err)
|
||||
setParts([])
|
||||
setCache(null)
|
||||
setError("Could not load parts")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
|
|
@ -72,25 +41,37 @@ export function PartsPanel({
|
|||
}
|
||||
}, [loadParts, manualFilename])
|
||||
|
||||
const hasListings = hasTrustedPartsListings(parts)
|
||||
const shouldShowPanel = shouldShowEbayPartsPanel({
|
||||
isLoading,
|
||||
parts,
|
||||
cache,
|
||||
error,
|
||||
})
|
||||
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
|
||||
const hasListings = parts.some((part) => part.ebayListings.length > 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!onStateChange) {
|
||||
return
|
||||
}
|
||||
|
||||
onStateChange({
|
||||
isLoading,
|
||||
isVisible: shouldShowPanel,
|
||||
})
|
||||
}, [isLoading, onStateChange, shouldShowPanel])
|
||||
const renderStatusCard = (title: string, message: string) => (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
|
||||
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
Parts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-3 py-4 text-center">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-700 dark:text-yellow-300 mb-2" />
|
||||
<p className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-yellow-900/70 dark:text-yellow-100/70">
|
||||
{message}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadParts()}
|
||||
className="mt-3 h-8 text-[11px] border-yellow-300/60 text-yellow-900 hover:bg-yellow-100 dark:border-yellow-700/60 dark:text-yellow-100 dark:hover:bg-yellow-900/40"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -102,14 +83,6 @@ export function PartsPanel({
|
|||
Parts
|
||||
</span>
|
||||
</div>
|
||||
{cache && (
|
||||
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
|
||||
{cache.lastSuccessfulAt
|
||||
? `Cache updated ${cacheFreshnessText}`
|
||||
: "Cache warming up"}
|
||||
{cache.isStale ? " • stale" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
|
|
@ -119,8 +92,53 @@ export function PartsPanel({
|
|||
)
|
||||
}
|
||||
|
||||
if (!shouldShowPanel) {
|
||||
return null
|
||||
if (error && !hasListings) {
|
||||
const loweredError = error.toLowerCase()
|
||||
const statusMessage = error.includes("eBay API not configured")
|
||||
? "Set EBAY_APP_ID in the app environment so live listings can load."
|
||||
: loweredError.includes("rate limit") || loweredError.includes("exceeded")
|
||||
? "eBay is temporarily rate-limited. Try again in a minute."
|
||||
: error
|
||||
|
||||
return renderStatusCard("eBay unavailable", statusMessage)
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
|
||||
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
Parts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
||||
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
|
||||
No parts data extracted for this manual yet
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasListings) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
|
||||
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
Parts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
||||
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
|
||||
No live eBay matches found for these parts yet
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -132,14 +150,6 @@ export function PartsPanel({
|
|||
Parts ({parts.length})
|
||||
</span>
|
||||
</div>
|
||||
{cache && (
|
||||
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
|
||||
{cache.lastSuccessfulAt
|
||||
? `Cache updated ${cacheFreshnessText}`
|
||||
: "Cache warming up"}
|
||||
{cache.isStale ? " • stale" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
|
||||
|
|
@ -149,10 +159,15 @@ export function PartsPanel({
|
|||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
Cached eBay listings are unavailable right now.
|
||||
Live eBay listings are unavailable right now.
|
||||
</p>
|
||||
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
|
||||
{error}
|
||||
{error.includes("eBay API not configured")
|
||||
? "Set EBAY_APP_ID in the app environment, then reload the panel."
|
||||
: error.toLowerCase().includes("rate limit") ||
|
||||
error.toLowerCase().includes("exceeded")
|
||||
? "eBay is temporarily rate-limited. Reload after a short wait."
|
||||
: error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@ type PublicPageHeaderProps = {
|
|||
children?: ReactNode
|
||||
}
|
||||
|
||||
type PublicProseProps = {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function PublicSection({
|
||||
id,
|
||||
tone = "default",
|
||||
|
|
@ -107,7 +102,7 @@ export function PublicSurface({
|
|||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"rounded-[var(--public-surface-radius)] border border-border/65 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.96))] p-5 shadow-[0_20px_52px_rgba(15,23,42,0.075)] md:p-7",
|
||||
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] p-5 shadow-[var(--public-surface-shadow)] md:p-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -125,7 +120,7 @@ export function PublicInset({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[var(--public-inset-radius)] border border-border/55 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(255,250,244,0.92))] p-4 shadow-[0_12px_30px_rgba(15,23,42,0.055)]",
|
||||
"rounded-[var(--public-inset-radius)] border border-border/60 bg-white/95 p-4 shadow-[0_10px_28px_rgba(15,23,42,0.06)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -149,29 +144,14 @@ export function PublicSectionHeader({
|
|||
className,
|
||||
}: PublicSectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("space-y-2.5", className)}>
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground md:text-[1.375rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicProse({ className, children }: PublicProseProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-slate max-w-none prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-foreground prose-p:text-muted-foreground prose-p:leading-7 prose-li:text-muted-foreground prose-li:leading-7 prose-strong:text-foreground prose-a:text-foreground prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-img:rounded-[1.5rem] prose-img:border prose-img:border-border/55 prose-img:shadow-[0_18px_45px_rgba(15,23,42,0.08)] prose-hr:border-border/50 prose-blockquote:border-primary/25 prose-blockquote:text-foreground md:prose-lg",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function RequestMachineSection() {
|
|||
<section id="request-machine" className="bg-background py-16 md:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
|
||||
<PublicSurface className="p-6 md:p-8 lg:sticky lg:top-28">
|
||||
<PublicSurface className="bg-white p-6 md:p-8 lg:sticky lg:top-28">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
<Package className="h-4 w-4" />
|
||||
Free Placement
|
||||
|
|
@ -61,7 +61,7 @@ export function RequestMachineSection() {
|
|||
</div>
|
||||
</PublicSurface>
|
||||
|
||||
<PublicSurface className="p-5 md:p-7">
|
||||
<PublicSurface className="bg-white p-5 md:p-7">
|
||||
<RequestMachineForm
|
||||
onSubmit={(data) =>
|
||||
console.log("Machine request form submitted:", data)
|
||||
|
|
|
|||
|
|
@ -9,47 +9,6 @@ import {
|
|||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
|
||||
const reviewThemes = [
|
||||
{
|
||||
title: "Always cold and stocked",
|
||||
body: "Customers consistently mention that the machines stay full, drinks stay cold, and the day-to-day experience feels dependable instead of neglected.",
|
||||
},
|
||||
{
|
||||
title: "Fast, friendly service",
|
||||
body: "When something needs attention, businesses talk about quick follow-through, easy communication, and issues getting handled without a long delay.",
|
||||
},
|
||||
{
|
||||
title: "Wide product variety",
|
||||
body: "Reviews regularly call out strong drink selection, snack variety, and the ability to request items that fit the people using the location.",
|
||||
},
|
||||
{
|
||||
title: "Fair pricing and reliability",
|
||||
body: "People mention competitive pricing, clean machines, and a setup that feels professional instead of frustrating or outdated.",
|
||||
},
|
||||
]
|
||||
|
||||
const featuredQuotes = [
|
||||
{
|
||||
quote:
|
||||
"He is arguably one of the best vendors in the industry. There probably isn't too many people I would trust more than him.",
|
||||
author: "Martin Harrison",
|
||||
},
|
||||
{
|
||||
quote: "He always has my favorite energy drink at a great price!",
|
||||
author: "TOPX Kingsford",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"This vending machine is my favorite at my job! It always works and has the best stuff!!",
|
||||
author: "DJ Montoya",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"Great to work with, looking forward to having him in more locations.",
|
||||
author: "Jennifer Spencer",
|
||||
},
|
||||
]
|
||||
|
||||
export function ReviewsPage() {
|
||||
useEffect(() => {
|
||||
const existingScript = document.querySelector(
|
||||
|
|
@ -85,65 +44,8 @@ export function ReviewsPage() {
|
|||
align="center"
|
||||
eyebrow="Customer Reviews"
|
||||
title="What Utah businesses say about working with Rocky Mountain Vending."
|
||||
description="See the real themes customers mention most often, browse featured comments, and then dig into the live Google review feed."
|
||||
>
|
||||
<PublicInset className="mx-auto inline-flex w-fit rounded-full px-4 py-2 text-sm text-muted-foreground shadow-none">
|
||||
Average rating: 4.9 out of 5, based on 50+ customer reviews.
|
||||
</PublicInset>
|
||||
</PublicPageHeader>
|
||||
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<PublicSurface>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Why Businesses Trust Rocky
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
|
||||
The same strengths keep showing up in the reviews.
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
||||
We'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">
|
||||
“{item.quote}”
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-semibold text-primary">
|
||||
{item.author}
|
||||
</p>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
description="Browse the live Google review feed and see what Utah businesses say about placement, restocking, repairs, and service."
|
||||
/>
|
||||
|
||||
<section className="mt-12">
|
||||
<PublicSurface className="overflow-hidden p-5 md:p-7">
|
||||
|
|
@ -167,9 +69,9 @@ export function ReviewsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[1.5rem] bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.92))] p-2 sm:p-3">
|
||||
<div className="mt-6">
|
||||
<iframe
|
||||
className="lc_reviews_widget min-h-[620px] w-full rounded-[1.35rem] border border-border/60 bg-background md:min-h-[780px]"
|
||||
className="lc_reviews_widget min-h-[900px] w-full rounded-[1.5rem] border border-border/60 bg-background"
|
||||
src="https://reputationhub.site/reputation/widgets/review_widget/YAoWLgNSid8oG44j9BjG"
|
||||
frameBorder="0"
|
||||
scrolling="no"
|
||||
|
|
@ -179,7 +81,7 @@ export function ReviewsPage() {
|
|||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<PublicSurface>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">
|
||||
What businesses usually want to verify before they choose a vendor
|
||||
|
|
@ -218,65 +120,42 @@ export function ReviewsPage() {
|
|||
<PublicSurface className="flex flex-col justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Why It Matters
|
||||
Next Step
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
|
||||
Reviews are usually the last confidence check before someone reaches out.
|
||||
Want to see whether your location qualifies?
|
||||
</h2>
|
||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
Most businesses are trying to verify the same things: follow-through,
|
||||
communication, and whether the machines stay stocked and working
|
||||
after install. If that sounds like your checklist too, we can help
|
||||
you sort through next steps quickly.
|
||||
Tell us about your traffic, breakroom, or customer area and
|
||||
we'll help you decide between free placement, machine sales,
|
||||
or service help.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4">
|
||||
<PublicInset className="p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Common Questions
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<Link
|
||||
href="/#request-machine"
|
||||
className="rounded-[1.5rem] border border-border/60 bg-white p-5 text-left transition hover:border-primary/30 hover:text-primary"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Free Placement
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
See whether your business qualifies for vending machine
|
||||
placement and ongoing service.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-3 text-sm leading-relaxed text-muted-foreground">
|
||||
<li>Does this location qualify for free placement?</li>
|
||||
<li>Can Rocky handle repairs and restocking without extra staff work on our side?</li>
|
||||
<li>Should we ask about placement, machine sales, or direct service help?</li>
|
||||
</ul>
|
||||
</PublicInset>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<Link
|
||||
href="/#request-machine"
|
||||
className="rounded-[1.5rem] border border-border/55 bg-background/70 p-5 text-left transition hover:border-primary/30 hover:text-primary"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Free Placement
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
See whether your business qualifies for vending machine
|
||||
placement and ongoing service.
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
className="rounded-[1.5rem] border border-border/55 bg-background/70 p-5 text-left transition hover:border-primary/30 hover:text-primary"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Service or Sales
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
Reach out about repairs, moving, manuals, parts, or machine
|
||||
sales.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-[1.5rem] border border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.7))] p-5">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Looking for a direct answer?
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
Tell us about your location, traffic, and what kind of help you
|
||||
need. We'll point you toward the right option instead of
|
||||
making you guess between service pages.
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
className="rounded-[1.5rem] border border-border/60 bg-white p-5 text-left transition hover:border-primary/30 hover:text-primary"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Service or Sales
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
Reach out about repairs, moving, manuals, parts, or machine
|
||||
sales.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { ReactNode } from "react"
|
|||
import { CheckCircle2 } from "lucide-react"
|
||||
import { DropdownPageShell } from "@/components/dropdown-page-shell"
|
||||
import { PublicInset } from "@/components/public-surface"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
|
||||
interface WhoWeServePageProps {
|
||||
title: string
|
||||
|
|
@ -57,58 +55,13 @@ export function WhoWeServePage({
|
|||
description ||
|
||||
"See how Rocky Mountain Vending adapts machine placement, product mix, and ongoing service to the way this kind of location actually runs."
|
||||
}
|
||||
headerContent={
|
||||
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Button asChild size="lg" className="min-h-11 rounded-full px-6">
|
||||
<Link href="/contact-us#contact-form">Talk to Our Team</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="min-h-11 rounded-full px-6"
|
||||
>
|
||||
<Link href="/#request-machine">See If You Qualify</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
contentIntro={
|
||||
<>
|
||||
<PublicInset className="h-full border-primary/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,245,0.94))] p-5 md:p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
How We Tailor Service
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance text-foreground">
|
||||
We shape the setup around the pace of the location.
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-relaxed text-muted-foreground">
|
||||
<li>Machine type and product mix matched to how people actually use the space.</li>
|
||||
<li>Placement recommendations based on traffic flow, break patterns, and visibility.</li>
|
||||
<li>Service cadence adjusted so stocking and support stay consistent without adding staff work.</li>
|
||||
</ul>
|
||||
</PublicInset>
|
||||
<PublicInset className="h-full border-border/50 bg-white/80 p-5 md:p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Good Fit Signals
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance text-foreground">
|
||||
These are usually the reasons businesses reach out first.
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-relaxed text-muted-foreground">
|
||||
<li>Your team or visitors need easier access to drinks, snacks, or convenience items on site.</li>
|
||||
<li>You want a cleaner vending setup without daily oversight falling back on your staff.</li>
|
||||
<li>You need local follow-through when a machine needs restocking, repair, or payment support.</li>
|
||||
</ul>
|
||||
</PublicInset>
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<div className="space-y-6 text-foreground">{content}</div>
|
||||
<div className="text-foreground">{content}</div>
|
||||
}
|
||||
contentClassName="prose-headings:mb-4 prose-headings:mt-10 prose-p:max-w-[68ch] prose-p:text-[1.02rem] prose-p:leading-8 prose-li:max-w-[68ch] prose-ul:space-y-2"
|
||||
contentClassName="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-foreground prose-a:underline prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-strong:text-foreground"
|
||||
sections={
|
||||
<section>
|
||||
<div className="mx-auto mb-8 max-w-3xl text-center">
|
||||
<div className="mx-auto mb-6 max-w-3xl text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Why Rocky
|
||||
</p>
|
||||
|
|
@ -154,12 +107,6 @@ export function WhoWeServePage({
|
|||
variant: "outline",
|
||||
},
|
||||
],
|
||||
note: (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
We can help you sort out whether this should start as a placement request,
|
||||
a machine-sales conversation, or direct service support.
|
||||
</p>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { v } from "convex/values"
|
||||
|
||||
function trimOptional(value?: string | null) {
|
||||
const normalized = String(value || "").trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function buildDisplayName(args: {
|
||||
displayName?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
}) {
|
||||
if (trimOptional(args.displayName)) {
|
||||
return trimOptional(args.displayName)
|
||||
}
|
||||
|
||||
const firstName = trimOptional(args.firstName)
|
||||
const lastName = trimOptional(args.lastName)
|
||||
const fallback = [firstName, lastName].filter(Boolean).join(" ").trim()
|
||||
return fallback || undefined
|
||||
}
|
||||
|
||||
export const getByNormalizedPhone = query({
|
||||
args: {
|
||||
normalizedPhone: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("contactProfiles")
|
||||
.withIndex("by_normalizedPhone", (q) =>
|
||||
q.eq("normalizedPhone", args.normalizedPhone)
|
||||
)
|
||||
.unique()
|
||||
},
|
||||
})
|
||||
|
||||
export const upsertByPhone = mutation({
|
||||
args: {
|
||||
normalizedPhone: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
firstName: v.optional(v.string()),
|
||||
lastName: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
company: v.optional(v.string()),
|
||||
lastIntent: v.optional(v.string()),
|
||||
lastLeadOutcome: v.optional(
|
||||
v.union(
|
||||
v.literal("none"),
|
||||
v.literal("contact"),
|
||||
v.literal("requestMachine")
|
||||
)
|
||||
),
|
||||
lastSummaryText: v.optional(v.string()),
|
||||
lastCallAt: v.optional(v.number()),
|
||||
lastReminderAt: v.optional(v.number()),
|
||||
reminderNotes: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("contactProfiles")
|
||||
.withIndex("by_normalizedPhone", (q) =>
|
||||
q.eq("normalizedPhone", args.normalizedPhone)
|
||||
)
|
||||
.unique()
|
||||
|
||||
const now = Date.now()
|
||||
const patch = {
|
||||
normalizedPhone: args.normalizedPhone,
|
||||
displayName: buildDisplayName(args),
|
||||
firstName: trimOptional(args.firstName),
|
||||
lastName: trimOptional(args.lastName),
|
||||
email: trimOptional(args.email),
|
||||
company: trimOptional(args.company),
|
||||
lastIntent: trimOptional(args.lastIntent),
|
||||
lastLeadOutcome: args.lastLeadOutcome,
|
||||
lastSummaryText: trimOptional(args.lastSummaryText),
|
||||
lastCallAt: args.lastCallAt,
|
||||
lastReminderAt: args.lastReminderAt,
|
||||
reminderNotes: trimOptional(args.reminderNotes),
|
||||
source: trimOptional(args.source),
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
displayName: patch.displayName || existing.displayName,
|
||||
firstName: patch.firstName || existing.firstName,
|
||||
lastName: patch.lastName || existing.lastName,
|
||||
email: patch.email || existing.email,
|
||||
company: patch.company || existing.company,
|
||||
lastIntent: patch.lastIntent || existing.lastIntent,
|
||||
lastLeadOutcome: patch.lastLeadOutcome || existing.lastLeadOutcome,
|
||||
lastSummaryText: patch.lastSummaryText || existing.lastSummaryText,
|
||||
lastCallAt: patch.lastCallAt || existing.lastCallAt,
|
||||
lastReminderAt: patch.lastReminderAt || existing.lastReminderAt,
|
||||
reminderNotes: patch.reminderNotes || existing.reminderNotes,
|
||||
source: patch.source || existing.source,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return await ctx.db.get(existing._id)
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("contactProfiles", {
|
||||
...patch,
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return await ctx.db.get(id)
|
||||
},
|
||||
})
|
||||
1976
convex/crm.ts
1976
convex/crm.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,497 +0,0 @@
|
|||
// @ts-nocheck
|
||||
|
||||
export function normalizeEmail(value?: string) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
export function normalizePhone(value?: string) {
|
||||
const digits = String(value || "").replace(/\D/g, "")
|
||||
if (!digits) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (digits.length === 10) {
|
||||
return `+1${digits}`
|
||||
}
|
||||
|
||||
if (digits.length === 11 && digits.startsWith("1")) {
|
||||
return `+${digits}`
|
||||
}
|
||||
|
||||
return `+${digits}`
|
||||
}
|
||||
|
||||
function trimOptional(value?: string) {
|
||||
const trimmed = String(value || "").trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
function isPlaceholderFirstName(value?: string) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return normalized === "unknown" || normalized === "phone"
|
||||
}
|
||||
|
||||
function isPlaceholderLastName(value?: string) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return (
|
||||
normalized === "contact" ||
|
||||
normalized === "lead" ||
|
||||
normalized === "caller"
|
||||
)
|
||||
}
|
||||
|
||||
function looksLikePhoneLabel(value?: string) {
|
||||
const normalized = trimOptional(value)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
|
||||
const digits = normalized.replace(/\D/g, "")
|
||||
return digits.length >= 7 && digits.length <= 15
|
||||
}
|
||||
|
||||
export function sanitizeContactNameParts(args: {
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
fullName?: string
|
||||
}) {
|
||||
let firstName = trimOptional(args.firstName)
|
||||
let lastName = trimOptional(args.lastName)
|
||||
|
||||
if (!firstName && !lastName) {
|
||||
const fullName = trimOptional(args.fullName)
|
||||
if (fullName && !looksLikePhoneLabel(fullName)) {
|
||||
const parts = fullName.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 1) {
|
||||
firstName = parts[0]
|
||||
} else if (parts.length > 1) {
|
||||
firstName = parts.shift()
|
||||
lastName = parts.join(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlaceholderFirstName(firstName)) {
|
||||
firstName = undefined
|
||||
}
|
||||
|
||||
if (isPlaceholderLastName(lastName)) {
|
||||
lastName = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
}
|
||||
}
|
||||
|
||||
export function dedupeStrings(values?: string[]) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(values || [])
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function findContactByIdentity(ctx, args) {
|
||||
if (args.ghlContactId) {
|
||||
const byGhl = await ctx.db
|
||||
.query("contacts")
|
||||
.withIndex("by_ghlContactId", (q) => q.eq("ghlContactId", args.ghlContactId))
|
||||
.unique()
|
||||
if (byGhl) {
|
||||
return byGhl
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeEmail(args.email)
|
||||
if (normalizedEmail) {
|
||||
const byEmail = await ctx.db
|
||||
.query("contacts")
|
||||
.withIndex("by_normalizedEmail", (q) =>
|
||||
q.eq("normalizedEmail", normalizedEmail)
|
||||
)
|
||||
.unique()
|
||||
if (byEmail) {
|
||||
return byEmail
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPhone = normalizePhone(args.phone)
|
||||
if (normalizedPhone) {
|
||||
const byPhone = await ctx.db
|
||||
.query("contacts")
|
||||
.withIndex("by_normalizedPhone", (q) =>
|
||||
q.eq("normalizedPhone", normalizedPhone)
|
||||
)
|
||||
.unique()
|
||||
if (byPhone) {
|
||||
return byPhone
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function upsertContactRecord(ctx, input) {
|
||||
const now = input.updatedAt ?? Date.now()
|
||||
const normalizedEmail = normalizeEmail(input.email)
|
||||
const normalizedPhone = normalizePhone(input.phone)
|
||||
const existing = await findContactByIdentity(ctx, {
|
||||
ghlContactId: input.ghlContactId,
|
||||
email: normalizedEmail,
|
||||
phone: normalizedPhone,
|
||||
})
|
||||
|
||||
const existingName = sanitizeContactNameParts({
|
||||
firstName: existing?.firstName,
|
||||
lastName: existing?.lastName,
|
||||
})
|
||||
const incomingName = sanitizeContactNameParts({
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
fullName: input.fullName,
|
||||
})
|
||||
|
||||
const patch = {
|
||||
firstName: incomingName.firstName ?? existingName.firstName ?? "",
|
||||
lastName: incomingName.lastName ?? existingName.lastName ?? "",
|
||||
email: input.email || existing?.email,
|
||||
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
|
||||
phone: input.phone || existing?.phone,
|
||||
normalizedPhone: normalizedPhone || existing?.normalizedPhone,
|
||||
company: input.company ?? existing?.company,
|
||||
tags: dedupeStrings([...(existing?.tags || []), ...(input.tags || [])]),
|
||||
status: input.status || existing?.status || "lead",
|
||||
source: input.source || existing?.source,
|
||||
notes: input.notes ?? existing?.notes,
|
||||
ghlContactId: input.ghlContactId || existing?.ghlContactId,
|
||||
livekitIdentity: input.livekitIdentity || existing?.livekitIdentity,
|
||||
lastActivityAt:
|
||||
input.lastActivityAt ?? existing?.lastActivityAt ?? input.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch)
|
||||
return await ctx.db.get(existing._id)
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("contacts", {
|
||||
...patch,
|
||||
createdAt: input.createdAt ?? now,
|
||||
})
|
||||
|
||||
return await ctx.db.get(id)
|
||||
}
|
||||
|
||||
export async function upsertConversationRecord(ctx, input) {
|
||||
const now = input.updatedAt ?? Date.now()
|
||||
let existing = null
|
||||
|
||||
if (input.ghlConversationId) {
|
||||
existing = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_ghlConversationId", (q) =>
|
||||
q.eq("ghlConversationId", input.ghlConversationId)
|
||||
)
|
||||
.unique()
|
||||
}
|
||||
|
||||
if (!existing && input.livekitRoomName) {
|
||||
existing = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_livekitRoomName", (q) =>
|
||||
q.eq("livekitRoomName", input.livekitRoomName)
|
||||
)
|
||||
.unique()
|
||||
}
|
||||
|
||||
if (!existing && input.voiceSessionId) {
|
||||
existing = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_voiceSessionId", (q) =>
|
||||
q.eq("voiceSessionId", input.voiceSessionId)
|
||||
)
|
||||
.unique()
|
||||
}
|
||||
|
||||
if (!existing && input.contactId) {
|
||||
const candidates = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_contactId", (q) => q.eq("contactId", input.contactId))
|
||||
.collect()
|
||||
|
||||
const targetMoment =
|
||||
input.lastMessageAt ?? input.startedAt ?? input.updatedAt ?? now
|
||||
|
||||
existing =
|
||||
candidates
|
||||
.filter((candidate) => {
|
||||
if (input.channel && candidate.channel !== input.channel) {
|
||||
return false
|
||||
}
|
||||
const candidateMoment =
|
||||
candidate.lastMessageAt ??
|
||||
candidate.startedAt ??
|
||||
candidate.updatedAt ??
|
||||
0
|
||||
return Math.abs(candidateMoment - targetMoment) <= 5 * 60 * 1000
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aMoment = a.lastMessageAt ?? a.startedAt ?? a.updatedAt ?? 0
|
||||
const bMoment = b.lastMessageAt ?? b.startedAt ?? b.updatedAt ?? 0
|
||||
return Math.abs(aMoment - targetMoment) - Math.abs(bMoment - targetMoment)
|
||||
})[0] || null
|
||||
}
|
||||
|
||||
const patch = {
|
||||
contactId: input.contactId ?? existing?.contactId,
|
||||
title: input.title || existing?.title,
|
||||
channel: input.channel || existing?.channel || "unknown",
|
||||
source: input.source || existing?.source,
|
||||
status: input.status || existing?.status || "open",
|
||||
direction: input.direction || existing?.direction || "mixed",
|
||||
startedAt: input.startedAt ?? existing?.startedAt ?? now,
|
||||
endedAt: input.endedAt ?? existing?.endedAt,
|
||||
lastMessageAt: input.lastMessageAt ?? existing?.lastMessageAt,
|
||||
lastMessagePreview: input.lastMessagePreview ?? existing?.lastMessagePreview,
|
||||
unreadCount: input.unreadCount ?? existing?.unreadCount ?? 0,
|
||||
summaryText: input.summaryText ?? existing?.summaryText,
|
||||
ghlConversationId: input.ghlConversationId || existing?.ghlConversationId,
|
||||
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
|
||||
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch)
|
||||
return await ctx.db.get(existing._id)
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("conversations", {
|
||||
...patch,
|
||||
createdAt: input.createdAt ?? now,
|
||||
})
|
||||
return await ctx.db.get(id)
|
||||
}
|
||||
|
||||
export async function ensureConversationParticipant(ctx, input) {
|
||||
const participants = await ctx.db
|
||||
.query("conversationParticipants")
|
||||
.withIndex("by_conversationId", (q) =>
|
||||
q.eq("conversationId", input.conversationId)
|
||||
)
|
||||
.collect()
|
||||
|
||||
const normalizedEmail = normalizeEmail(input.email)
|
||||
const normalizedPhone = normalizePhone(input.phone)
|
||||
const existing = participants.find((participant) => {
|
||||
if (input.contactId && participant.contactId === input.contactId) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
input.externalContactId &&
|
||||
participant.externalContactId === input.externalContactId
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (normalizedEmail && participant.normalizedEmail === normalizedEmail) {
|
||||
return true
|
||||
}
|
||||
if (normalizedPhone && participant.normalizedPhone === normalizedPhone) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const patch = {
|
||||
contactId: input.contactId ?? existing?.contactId,
|
||||
role: input.role || existing?.role || "unknown",
|
||||
displayName: input.displayName || existing?.displayName,
|
||||
phone: input.phone || existing?.phone,
|
||||
normalizedPhone: normalizedPhone || existing?.normalizedPhone,
|
||||
email: input.email || existing?.email,
|
||||
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
|
||||
externalContactId: input.externalContactId || existing?.externalContactId,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch)
|
||||
return await ctx.db.get(existing._id)
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("conversationParticipants", {
|
||||
conversationId: input.conversationId,
|
||||
...patch,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
return await ctx.db.get(id)
|
||||
}
|
||||
|
||||
export async function upsertMessageRecord(ctx, input) {
|
||||
let existing = null
|
||||
|
||||
if (input.ghlMessageId) {
|
||||
existing = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_ghlMessageId", (q) =>
|
||||
q.eq("ghlMessageId", input.ghlMessageId)
|
||||
)
|
||||
.unique()
|
||||
}
|
||||
|
||||
if (!existing && input.voiceTranscriptTurnId) {
|
||||
existing = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_voiceTranscriptTurnId", (q) =>
|
||||
q.eq("voiceTranscriptTurnId", input.voiceTranscriptTurnId)
|
||||
)
|
||||
.unique()
|
||||
}
|
||||
|
||||
const now = input.updatedAt ?? Date.now()
|
||||
const patch = {
|
||||
conversationId: input.conversationId,
|
||||
contactId: input.contactId,
|
||||
direction: input.direction || existing?.direction || "system",
|
||||
channel: input.channel || existing?.channel || "unknown",
|
||||
source: input.source || existing?.source,
|
||||
messageType: input.messageType || existing?.messageType,
|
||||
body: String(input.body || existing?.body || "").trim(),
|
||||
status: input.status || existing?.status,
|
||||
sentAt: input.sentAt ?? existing?.sentAt ?? now,
|
||||
ghlMessageId: input.ghlMessageId || existing?.ghlMessageId,
|
||||
voiceTranscriptTurnId:
|
||||
input.voiceTranscriptTurnId ?? existing?.voiceTranscriptTurnId,
|
||||
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
|
||||
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
|
||||
metadata: input.metadata || existing?.metadata,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
let message
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch)
|
||||
message = await ctx.db.get(existing._id)
|
||||
} else {
|
||||
const id = await ctx.db.insert("messages", {
|
||||
...patch,
|
||||
createdAt: input.createdAt ?? now,
|
||||
})
|
||||
message = await ctx.db.get(id)
|
||||
}
|
||||
|
||||
await ctx.db.patch(input.conversationId, {
|
||||
lastMessageAt: patch.sentAt,
|
||||
lastMessagePreview: patch.body.slice(0, 240),
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
export async function upsertCallArtifactRecord(ctx, input) {
|
||||
let existing = null
|
||||
|
||||
if (input.recordingId) {
|
||||
existing = await ctx.db
|
||||
.query("callArtifacts")
|
||||
.withIndex("by_recordingId", (q) => q.eq("recordingId", input.recordingId))
|
||||
.unique()
|
||||
}
|
||||
|
||||
if (!existing && input.voiceSessionId) {
|
||||
existing = await ctx.db
|
||||
.query("callArtifacts")
|
||||
.withIndex("by_voiceSessionId", (q) =>
|
||||
q.eq("voiceSessionId", input.voiceSessionId)
|
||||
)
|
||||
.unique()
|
||||
}
|
||||
|
||||
if (!existing && input.ghlMessageId) {
|
||||
existing = await ctx.db
|
||||
.query("callArtifacts")
|
||||
.withIndex("by_ghlMessageId", (q) =>
|
||||
q.eq("ghlMessageId", input.ghlMessageId)
|
||||
)
|
||||
.unique()
|
||||
}
|
||||
|
||||
const now = input.updatedAt ?? Date.now()
|
||||
const patch = {
|
||||
conversationId: input.conversationId,
|
||||
contactId: input.contactId ?? existing?.contactId,
|
||||
source: input.source || existing?.source,
|
||||
recordingId: input.recordingId || existing?.recordingId,
|
||||
recordingUrl: input.recordingUrl || existing?.recordingUrl,
|
||||
recordingStatus: input.recordingStatus || existing?.recordingStatus,
|
||||
transcriptionText: input.transcriptionText ?? existing?.transcriptionText,
|
||||
durationMs: input.durationMs ?? existing?.durationMs,
|
||||
startedAt: input.startedAt ?? existing?.startedAt,
|
||||
endedAt: input.endedAt ?? existing?.endedAt,
|
||||
ghlMessageId: input.ghlMessageId || existing?.ghlMessageId,
|
||||
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
|
||||
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
|
||||
metadata: input.metadata || existing?.metadata,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch)
|
||||
return await ctx.db.get(existing._id)
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("callArtifacts", {
|
||||
...patch,
|
||||
createdAt: input.createdAt ?? now,
|
||||
})
|
||||
return await ctx.db.get(id)
|
||||
}
|
||||
|
||||
export async function upsertExternalSyncState(ctx, input) {
|
||||
const existing = await ctx.db
|
||||
.query("externalSyncState")
|
||||
.withIndex("by_provider_entityType_entityId", (q) =>
|
||||
q
|
||||
.eq("provider", input.provider)
|
||||
.eq("entityType", input.entityType)
|
||||
.eq("entityId", input.entityId)
|
||||
)
|
||||
.unique()
|
||||
|
||||
const patch = {
|
||||
cursor: input.cursor ?? existing?.cursor,
|
||||
checksum: input.checksum ?? existing?.checksum,
|
||||
status: input.status || existing?.status || "pending",
|
||||
lastAttemptAt: input.lastAttemptAt ?? existing?.lastAttemptAt ?? Date.now(),
|
||||
lastSyncedAt: input.lastSyncedAt ?? existing?.lastSyncedAt,
|
||||
error: input.error ?? existing?.error,
|
||||
metadata: input.metadata ?? existing?.metadata,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch)
|
||||
return await ctx.db.get(existing._id)
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("externalSyncState", {
|
||||
provider: input.provider,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
...patch,
|
||||
})
|
||||
return await ctx.db.get(id)
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { cronJobs } from "convex/server"
|
||||
import { api } from "./_generated/api"
|
||||
|
||||
const crons = cronJobs()
|
||||
|
||||
crons.interval(
|
||||
"ebay-manual-parts-refresh",
|
||||
{ hours: 24 },
|
||||
api.ebay.refreshCache,
|
||||
{ reason: "cron" }
|
||||
)
|
||||
|
||||
crons.interval(
|
||||
"ghl-crm-mirror-sync",
|
||||
{ hours: 1 },
|
||||
api.crm.runGhlMirror,
|
||||
{ reason: "cron" }
|
||||
)
|
||||
|
||||
export default crons
|
||||
648
convex/ebay.ts
648
convex/ebay.ts
|
|
@ -1,648 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { action, internalMutation, query } from "./_generated/server"
|
||||
import { api, internal } from "./_generated/api"
|
||||
import { v } from "convex/values"
|
||||
|
||||
const POLL_KEY = "manual-parts"
|
||||
const LISTING_EXPIRES_MS = 14 * 24 * 60 * 60 * 1000
|
||||
const STALE_AFTER_MS = 36 * 60 * 60 * 1000
|
||||
const BASE_REFRESH_MS = 24 * 60 * 60 * 1000
|
||||
const MAX_BACKOFF_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const MAX_QUERIES_PER_RUN = 4
|
||||
const MAX_RESULTS_PER_QUERY = 8
|
||||
const MAX_UNIQUE_RESULTS = 48
|
||||
|
||||
const SYNTHETIC_ITEM_PREFIX = "123456789"
|
||||
const PLACEHOLDER_IMAGE_HOSTS = [
|
||||
"images.unsplash.com",
|
||||
"via.placeholder.com",
|
||||
"placehold.co",
|
||||
] as const
|
||||
|
||||
const POLL_QUERY_POOL = [
|
||||
{
|
||||
label: "dixie narco part number",
|
||||
keywords: "dixie narco vending part number",
|
||||
categoryId: "11700",
|
||||
},
|
||||
{
|
||||
label: "crane national vendors part",
|
||||
keywords: "crane national vendors vending part",
|
||||
categoryId: "11700",
|
||||
},
|
||||
{
|
||||
label: "seaga vending control board",
|
||||
keywords: "seaga vending control board",
|
||||
categoryId: "11700",
|
||||
},
|
||||
{
|
||||
label: "coinco coin mech",
|
||||
keywords: "coinco vending coin mech",
|
||||
categoryId: "11700",
|
||||
},
|
||||
{
|
||||
label: "mei bill validator",
|
||||
keywords: "mei vending bill validator",
|
||||
categoryId: "11700",
|
||||
},
|
||||
{
|
||||
label: "wittern delivery motor",
|
||||
keywords: "wittern vending delivery motor",
|
||||
categoryId: "11700",
|
||||
},
|
||||
{
|
||||
label: "vending refrigeration deck",
|
||||
keywords: "vending machine refrigeration deck",
|
||||
categoryId: "11700",
|
||||
},
|
||||
{
|
||||
label: "vending keypad",
|
||||
keywords: "vending machine keypad",
|
||||
categoryId: "11700",
|
||||
},
|
||||
] as const
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
function parsePositiveNumber(value: string): number | null {
|
||||
const match = value.match(/([0-9]+(?:\.[0-9]+)?)/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(match[1])
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getPollQueriesForRun(now: number) {
|
||||
const total = POLL_QUERY_POOL.length
|
||||
if (total === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const startIndex = Math.floor(now / BASE_REFRESH_MS) % total
|
||||
const count = Math.min(MAX_QUERIES_PER_RUN, total)
|
||||
const queries: (typeof POLL_QUERY_POOL)[number][] = []
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
queries.push(POLL_QUERY_POOL[(startIndex + index) % total])
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
function buildAffiliateLink(viewItemUrl: string): string {
|
||||
const campaignId = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
|
||||
if (!campaignId) {
|
||||
return viewItemUrl
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(viewItemUrl)
|
||||
url.searchParams.set("mkcid", "1")
|
||||
url.searchParams.set("mkrid", "711-53200-19255-0")
|
||||
url.searchParams.set("siteid", "0")
|
||||
url.searchParams.set("campid", campaignId)
|
||||
url.searchParams.set("toolid", "10001")
|
||||
url.searchParams.set("mkevt", "1")
|
||||
return url.toString()
|
||||
} catch {
|
||||
return viewItemUrl
|
||||
}
|
||||
}
|
||||
|
||||
function firstValue<T>(value: T | T[] | undefined): T | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
function parseUrl(value: string): URL | null {
|
||||
try {
|
||||
return new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedListingCandidate(listing: ReturnType<typeof normalizeEbayItem>) {
|
||||
const itemId = listing.itemId?.trim() || ""
|
||||
if (!/^[0-9]{9,15}$/.test(itemId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (itemId.startsWith(SYNTHETIC_ITEM_PREFIX)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsedUrl = parseUrl(listing.viewItemUrl || "")
|
||||
if (!parsedUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const host = parsedUrl.hostname.toLowerCase()
|
||||
if (!host.includes("ebay.")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!parsedUrl.pathname.includes("/itm/")) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsedPrice = parsePositiveNumber(listing.price || "")
|
||||
if (!parsedPrice) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (listing.imageUrl) {
|
||||
const parsedImage = parseUrl(listing.imageUrl)
|
||||
const imageHost = parsedImage?.hostname.toLowerCase() || ""
|
||||
if (PLACEHOLDER_IMAGE_HOSTS.some((placeholder) => imageHost.includes(placeholder))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isRateLimitError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("10001") ||
|
||||
normalized.includes("rate limit") ||
|
||||
normalized.includes("too many requests") ||
|
||||
normalized.includes("exceeded the number of times") ||
|
||||
normalized.includes("quota")
|
||||
)
|
||||
}
|
||||
|
||||
function getBackoffMs(consecutiveFailures: number, rateLimited: boolean) {
|
||||
if (rateLimited) {
|
||||
return BASE_REFRESH_MS
|
||||
}
|
||||
|
||||
const base = BASE_REFRESH_MS / 2
|
||||
const multiplier = Math.max(1, consecutiveFailures)
|
||||
return Math.min(base * multiplier, MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
async function readEbayErrorMessage(response: Response) {
|
||||
const text = await response.text().catch(() => "")
|
||||
if (!text.trim()) {
|
||||
return `eBay API error: ${response.status}`
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as any
|
||||
const messages = parsed?.errorMessage?.[0]?.error?.[0]
|
||||
const message = Array.isArray(messages?.message)
|
||||
? messages.message[0]
|
||||
: messages?.message
|
||||
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
const errorId = Array.isArray(messages?.errorId)
|
||||
? messages.errorId[0]
|
||||
: messages?.errorId
|
||||
return errorId
|
||||
? `eBay API error ${errorId}: ${message}`
|
||||
: `eBay API error: ${message}`
|
||||
}
|
||||
} catch {
|
||||
// Fall through to returning raw text.
|
||||
}
|
||||
|
||||
return text.trim() || `eBay API error: ${response.status}`
|
||||
}
|
||||
|
||||
function readEbayJsonError(data: any): string | null {
|
||||
const errorMessage = data?.errorMessage?.[0]?.error?.[0]
|
||||
const message = Array.isArray(errorMessage?.message)
|
||||
? errorMessage.message[0]
|
||||
: errorMessage?.message
|
||||
|
||||
if (typeof message !== "string" || !message.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const errorId = Array.isArray(errorMessage?.errorId)
|
||||
? errorMessage.errorId[0]
|
||||
: errorMessage?.errorId
|
||||
|
||||
return errorId
|
||||
? `eBay API error ${errorId}: ${message}`
|
||||
: `eBay API error: ${message}`
|
||||
}
|
||||
|
||||
function normalizeEbayItem(item: any, fetchedAt: number) {
|
||||
const currentPrice = firstValue(item?.sellingStatus?.currentPrice)
|
||||
const shippingCost = firstValue(item?.shippingInfo?.shippingServiceCost)
|
||||
const condition = firstValue(item?.condition)
|
||||
const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || ""
|
||||
const title = item?.title || "Unknown Item"
|
||||
|
||||
return {
|
||||
itemId: String(item?.itemId || ""),
|
||||
title,
|
||||
normalizedTitle: normalizeText(title),
|
||||
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
|
||||
currency: currentPrice?.currencyId || "USD",
|
||||
imageUrl: item?.galleryURL || undefined,
|
||||
viewItemUrl,
|
||||
condition: condition?.conditionDisplayName || undefined,
|
||||
shippingCost: shippingCost?.value
|
||||
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
|
||||
: undefined,
|
||||
affiliateLink: buildAffiliateLink(viewItemUrl),
|
||||
sourceQueries: [] as string[],
|
||||
fetchedAt,
|
||||
firstSeenAt: fetchedAt,
|
||||
lastSeenAt: fetchedAt,
|
||||
expiresAt: fetchedAt + LISTING_EXPIRES_MS,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function searchEbayListings(query: (typeof POLL_QUERY_POOL)[number]) {
|
||||
const appId = process.env.EBAY_APP_ID?.trim()
|
||||
if (!appId) {
|
||||
throw new Error("eBay App ID is not configured")
|
||||
}
|
||||
|
||||
const url = new URL("https://svcs.ebay.com/services/search/FindingService/v1")
|
||||
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
|
||||
url.searchParams.set("SERVICE-VERSION", "1.0.0")
|
||||
url.searchParams.set("SECURITY-APPNAME", appId)
|
||||
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
|
||||
url.searchParams.set("REST-PAYLOAD", "true")
|
||||
url.searchParams.set("keywords", query.keywords)
|
||||
url.searchParams.set("sortOrder", "BestMatch")
|
||||
url.searchParams.set("paginationInput.entriesPerPage", String(MAX_RESULTS_PER_QUERY))
|
||||
if (query.categoryId) {
|
||||
url.searchParams.set("categoryId", query.categoryId)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readEbayErrorMessage(response))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const jsonError = readEbayJsonError(data)
|
||||
if (jsonError) {
|
||||
throw new Error(jsonError)
|
||||
}
|
||||
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
|
||||
const searchResult = findItemsAdvancedResponse?.searchResult?.[0]
|
||||
const items = Array.isArray(searchResult?.item)
|
||||
? searchResult.item
|
||||
: searchResult?.item
|
||||
? [searchResult.item]
|
||||
: []
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export const getCacheOverview = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const now = Date.now()
|
||||
const state =
|
||||
(await ctx.db
|
||||
.query("ebayPollState")
|
||||
.withIndex("by_key", (q) => q.eq("key", POLL_KEY))
|
||||
.unique()) || null
|
||||
|
||||
const listings = await ctx.db.query("ebayListings").collect()
|
||||
const activeListings = listings.filter(
|
||||
(listing) => listing.active && listing.expiresAt >= now
|
||||
)
|
||||
|
||||
const freshnessMs = state?.lastSuccessfulAt
|
||||
? Math.max(0, now - state.lastSuccessfulAt)
|
||||
: null
|
||||
|
||||
return {
|
||||
key: POLL_KEY,
|
||||
status: state?.status || "idle",
|
||||
lastSuccessfulAt: state?.lastSuccessfulAt || null,
|
||||
lastAttemptAt: state?.lastAttemptAt || null,
|
||||
nextEligibleAt: state?.nextEligibleAt || null,
|
||||
lastError: state?.lastError || null,
|
||||
consecutiveFailures: state?.consecutiveFailures || 0,
|
||||
queryCount: state?.queryCount || 0,
|
||||
itemCount: state?.itemCount || 0,
|
||||
sourceQueries: state?.sourceQueries || [],
|
||||
freshnessMs,
|
||||
isStale: freshnessMs !== null ? freshnessMs >= STALE_AFTER_MS : true,
|
||||
listingCount: listings.length,
|
||||
activeListingCount: activeListings.length,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const listCachedListings = query({
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
const listings = await ctx.db.query("ebayListings").collect()
|
||||
const normalized = listings.map((listing) => ({
|
||||
...listing,
|
||||
active: listing.active && listing.expiresAt >= now,
|
||||
}))
|
||||
|
||||
normalized.sort((a, b) => {
|
||||
if (a.active !== b.active) {
|
||||
return Number(b.active) - Number(a.active)
|
||||
}
|
||||
|
||||
const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0
|
||||
const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0
|
||||
return bFreshness - aFreshness
|
||||
})
|
||||
|
||||
return typeof args.limit === "number" && args.limit > 0
|
||||
? normalized.slice(0, args.limit)
|
||||
: normalized
|
||||
},
|
||||
})
|
||||
|
||||
export const refreshCache = action({
|
||||
args: {
|
||||
reason: v.optional(v.string()),
|
||||
force: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
const state =
|
||||
(await ctx.runQuery(api.ebay.getCacheOverview, {})) || ({
|
||||
status: "idle",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: null,
|
||||
nextEligibleAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
} as const)
|
||||
|
||||
if (
|
||||
!args.force &&
|
||||
typeof state.nextEligibleAt === "number" &&
|
||||
state.nextEligibleAt > now
|
||||
) {
|
||||
return {
|
||||
status: "skipped",
|
||||
message: "Refresh is deferred until the next eligible window.",
|
||||
nextEligibleAt: state.nextEligibleAt,
|
||||
}
|
||||
}
|
||||
|
||||
const appId = process.env.EBAY_APP_ID?.trim()
|
||||
if (!appId) {
|
||||
const nextEligibleAt = now + BASE_REFRESH_MS
|
||||
await ctx.runMutation(internal.ebay.upsertPollResult, {
|
||||
key: POLL_KEY,
|
||||
status: "missing_config",
|
||||
lastAttemptAt: now,
|
||||
lastSuccessfulAt: state.lastSuccessfulAt || null,
|
||||
nextEligibleAt,
|
||||
lastError: "EBAY_APP_ID is not configured.",
|
||||
consecutiveFailures: state.consecutiveFailures + 1,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
listings: [],
|
||||
reason: args.reason || "missing_config",
|
||||
})
|
||||
|
||||
return {
|
||||
status: "missing_config",
|
||||
message: "EBAY_APP_ID is not configured.",
|
||||
nextEligibleAt,
|
||||
}
|
||||
}
|
||||
|
||||
const sourceQueries: string[] = []
|
||||
const collectedListings = new Map<string, ReturnType<typeof normalizeEbayItem>>()
|
||||
let queryCount = 0
|
||||
let rateLimited = false
|
||||
let lastError: string | null = null
|
||||
|
||||
const pollQueries = getPollQueriesForRun(now)
|
||||
|
||||
for (const query of pollQueries) {
|
||||
if (collectedListings.size >= MAX_UNIQUE_RESULTS) {
|
||||
break
|
||||
}
|
||||
|
||||
queryCount += 1
|
||||
sourceQueries.push(query.label)
|
||||
|
||||
try {
|
||||
const items = await searchEbayListings(query)
|
||||
for (const item of items) {
|
||||
const listing = normalizeEbayItem(item, now)
|
||||
if (!listing.itemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isTrustedListingCandidate(listing)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = collectedListings.get(listing.itemId)
|
||||
if (existing) {
|
||||
existing.sourceQueries = Array.from(
|
||||
new Set([...existing.sourceQueries, query.label])
|
||||
)
|
||||
existing.title = listing.title || existing.title
|
||||
existing.normalizedTitle = normalizeText(existing.title)
|
||||
existing.price = listing.price || existing.price
|
||||
existing.currency = listing.currency || existing.currency
|
||||
existing.imageUrl = listing.imageUrl || existing.imageUrl
|
||||
existing.viewItemUrl = listing.viewItemUrl || existing.viewItemUrl
|
||||
existing.condition = listing.condition || existing.condition
|
||||
existing.shippingCost = listing.shippingCost || existing.shippingCost
|
||||
existing.affiliateLink = listing.affiliateLink || existing.affiliateLink
|
||||
existing.lastSeenAt = now
|
||||
existing.fetchedAt = now
|
||||
existing.expiresAt = now + LISTING_EXPIRES_MS
|
||||
existing.active = true
|
||||
continue
|
||||
}
|
||||
|
||||
listing.sourceQueries = [query.label]
|
||||
collectedListings.set(listing.itemId, listing)
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : "Failed to refresh eBay listings"
|
||||
if (isRateLimitError(lastError)) {
|
||||
rateLimited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listings = Array.from(collectedListings.values())
|
||||
const nextEligibleAt = now + getBackoffMs(
|
||||
state.consecutiveFailures + 1,
|
||||
rateLimited
|
||||
)
|
||||
|
||||
await ctx.runMutation(internal.ebay.upsertPollResult, {
|
||||
key: POLL_KEY,
|
||||
status: rateLimited
|
||||
? "rate_limited"
|
||||
: lastError
|
||||
? "error"
|
||||
: "success",
|
||||
lastAttemptAt: now,
|
||||
lastSuccessfulAt: rateLimited || lastError ? state.lastSuccessfulAt || null : now,
|
||||
nextEligibleAt: rateLimited || lastError ? nextEligibleAt : now + BASE_REFRESH_MS,
|
||||
lastError: lastError || null,
|
||||
consecutiveFailures:
|
||||
rateLimited || lastError ? state.consecutiveFailures + 1 : 0,
|
||||
queryCount,
|
||||
itemCount: listings.length,
|
||||
sourceQueries,
|
||||
listings,
|
||||
reason: args.reason || "cron",
|
||||
})
|
||||
|
||||
return {
|
||||
status: rateLimited ? "rate_limited" : lastError ? "error" : "success",
|
||||
message: lastError || undefined,
|
||||
queryCount,
|
||||
itemCount: listings.length,
|
||||
nextEligibleAt: rateLimited || lastError ? nextEligibleAt : now + BASE_REFRESH_MS,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const upsertPollResult = internalMutation({
|
||||
args: {
|
||||
key: v.string(),
|
||||
status: v.union(
|
||||
v.literal("idle"),
|
||||
v.literal("success"),
|
||||
v.literal("rate_limited"),
|
||||
v.literal("error"),
|
||||
v.literal("missing_config"),
|
||||
v.literal("skipped")
|
||||
),
|
||||
lastAttemptAt: v.number(),
|
||||
lastSuccessfulAt: v.union(v.number(), v.null()),
|
||||
nextEligibleAt: v.union(v.number(), v.null()),
|
||||
lastError: v.union(v.string(), v.null()),
|
||||
consecutiveFailures: v.number(),
|
||||
queryCount: v.number(),
|
||||
itemCount: v.number(),
|
||||
sourceQueries: v.array(v.string()),
|
||||
listings: v.array(
|
||||
v.object({
|
||||
itemId: v.string(),
|
||||
title: v.string(),
|
||||
normalizedTitle: v.string(),
|
||||
price: v.string(),
|
||||
currency: v.string(),
|
||||
imageUrl: v.optional(v.string()),
|
||||
viewItemUrl: v.string(),
|
||||
condition: v.optional(v.string()),
|
||||
shippingCost: v.optional(v.string()),
|
||||
affiliateLink: v.string(),
|
||||
sourceQueries: v.array(v.string()),
|
||||
fetchedAt: v.number(),
|
||||
firstSeenAt: v.number(),
|
||||
lastSeenAt: v.number(),
|
||||
expiresAt: v.number(),
|
||||
active: v.boolean(),
|
||||
})
|
||||
),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
for (const listing of args.listings) {
|
||||
const existing = await ctx.db
|
||||
.query("ebayListings")
|
||||
.withIndex("by_itemId", (q) => q.eq("itemId", listing.itemId))
|
||||
.unique()
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
title: listing.title,
|
||||
normalizedTitle: listing.normalizedTitle,
|
||||
price: listing.price,
|
||||
currency: listing.currency,
|
||||
imageUrl: listing.imageUrl,
|
||||
viewItemUrl: listing.viewItemUrl,
|
||||
condition: listing.condition,
|
||||
shippingCost: listing.shippingCost,
|
||||
affiliateLink: listing.affiliateLink,
|
||||
sourceQueries: Array.from(
|
||||
new Set([...(existing.sourceQueries || []), ...(listing.sourceQueries || [])])
|
||||
),
|
||||
fetchedAt: listing.fetchedAt,
|
||||
firstSeenAt: existing.firstSeenAt || listing.firstSeenAt,
|
||||
lastSeenAt: listing.lastSeenAt,
|
||||
expiresAt: listing.expiresAt,
|
||||
active: listing.active,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
await ctx.db.insert("ebayListings", listing)
|
||||
}
|
||||
|
||||
const existingState = await ctx.db
|
||||
.query("ebayPollState")
|
||||
.withIndex("by_key", (q) => q.eq("key", args.key))
|
||||
.unique()
|
||||
|
||||
const stateRecord: Record<string, any> = {
|
||||
key: args.key,
|
||||
status: args.status,
|
||||
lastAttemptAt: args.lastAttemptAt,
|
||||
consecutiveFailures: args.consecutiveFailures,
|
||||
queryCount: args.queryCount,
|
||||
itemCount: args.itemCount,
|
||||
sourceQueries: args.sourceQueries,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
if (args.lastSuccessfulAt !== null) {
|
||||
stateRecord.lastSuccessfulAt = args.lastSuccessfulAt
|
||||
}
|
||||
|
||||
if (args.nextEligibleAt !== null) {
|
||||
stateRecord.nextEligibleAt = args.nextEligibleAt
|
||||
}
|
||||
|
||||
if (args.lastError !== null) {
|
||||
stateRecord.lastError = args.lastError
|
||||
}
|
||||
|
||||
if (existingState) {
|
||||
await ctx.db.patch(existingState._id, stateRecord)
|
||||
return await ctx.db.get(existingState._id)
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("ebayPollState", stateRecord)
|
||||
return await ctx.db.get(id)
|
||||
},
|
||||
})
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
// @ts-nocheck
|
||||
|
||||
type GhlMirrorConfig = {
|
||||
token: string
|
||||
locationId: string
|
||||
baseUrl: string
|
||||
version: string
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value?: string) {
|
||||
return String(value || "https://services.leadconnectorhq.com").replace(
|
||||
/\/+$/,
|
||||
""
|
||||
)
|
||||
}
|
||||
|
||||
export function readGhlMirrorConfig() {
|
||||
const token = String(
|
||||
process.env.GHL_PRIVATE_INTEGRATION_TOKEN || process.env.GHL_API_TOKEN || ""
|
||||
).trim()
|
||||
const locationId = String(process.env.GHL_LOCATION_ID || "").trim()
|
||||
const baseUrl = normalizeBaseUrl(process.env.GHL_API_BASE_URL)
|
||||
const version = String(process.env.GHL_API_VERSION || "2021-07-28").trim()
|
||||
|
||||
if (!token || !locationId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
locationId,
|
||||
baseUrl,
|
||||
version,
|
||||
} satisfies GhlMirrorConfig
|
||||
}
|
||||
|
||||
export async function fetchGhlMirrorJson(
|
||||
config: GhlMirrorConfig,
|
||||
pathname: string,
|
||||
init?: RequestInit
|
||||
) {
|
||||
const response = await fetch(`${config.baseUrl}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Version: config.version,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
let body: any = null
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GHL request failed (${response.status}) for ${pathname}: ${body?.message || text || "Unknown error"}`
|
||||
)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
export async function fetchGhlContactsPage(
|
||||
config: GhlMirrorConfig,
|
||||
args?: {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
}
|
||||
) {
|
||||
const searchParams = new URLSearchParams({
|
||||
locationId: config.locationId,
|
||||
limit: String(Math.min(100, Math.max(1, args?.limit || 100))),
|
||||
})
|
||||
|
||||
if (args?.cursor) {
|
||||
searchParams.set("startAfterId", args.cursor)
|
||||
}
|
||||
|
||||
const payload = await fetchGhlMirrorJson(
|
||||
config,
|
||||
`/contacts/?${searchParams.toString()}`
|
||||
)
|
||||
|
||||
const contacts = Array.isArray(payload?.contacts)
|
||||
? payload.contacts
|
||||
: Array.isArray(payload?.data?.contacts)
|
||||
? payload.data.contacts
|
||||
: []
|
||||
|
||||
const nextCursor =
|
||||
contacts.length > 0 ? String(contacts[contacts.length - 1]?.id || "") : ""
|
||||
|
||||
return {
|
||||
items: contacts,
|
||||
nextCursor: nextCursor || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGhlMessagesPage(
|
||||
config: GhlMirrorConfig,
|
||||
args?: {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
channel?: "Call" | "SMS"
|
||||
}
|
||||
) {
|
||||
const url = new URL(`${config.baseUrl}/conversations/messages/export`)
|
||||
url.searchParams.set("locationId", config.locationId)
|
||||
url.searchParams.set("limit", String(Math.min(100, Math.max(1, args?.limit || 100))))
|
||||
url.searchParams.set("channel", args?.channel || "SMS")
|
||||
|
||||
if (args?.cursor) {
|
||||
url.searchParams.set("cursor", args.cursor)
|
||||
}
|
||||
|
||||
const payload = await fetchGhlMirrorJson(config, url.pathname + url.search)
|
||||
|
||||
return {
|
||||
items: Array.isArray(payload?.messages) ? payload.messages : [],
|
||||
nextCursor:
|
||||
typeof payload?.nextCursor === "string" && payload.nextCursor
|
||||
? payload.nextCursor
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGhlConversationsPage(
|
||||
config: GhlMirrorConfig,
|
||||
args?: {
|
||||
limit?: number
|
||||
}
|
||||
) {
|
||||
const url = new URL(`${config.baseUrl}/conversations/search`)
|
||||
url.searchParams.set("locationId", config.locationId)
|
||||
url.searchParams.set("limit", String(Math.min(100, Math.max(1, args?.limit || 100))))
|
||||
|
||||
const payload = await fetchGhlMirrorJson(config, url.pathname + url.search)
|
||||
|
||||
return {
|
||||
items: Array.isArray(payload?.conversations) ? payload.conversations : [],
|
||||
total:
|
||||
typeof payload?.total === "number"
|
||||
? payload.total
|
||||
: Array.isArray(payload?.conversations)
|
||||
? payload.conversations.length
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGhlConversationMessages(
|
||||
config: GhlMirrorConfig,
|
||||
args: {
|
||||
conversationId: string
|
||||
}
|
||||
) {
|
||||
const payload = await fetchGhlMirrorJson(
|
||||
config,
|
||||
`/conversations/${encodeURIComponent(args.conversationId)}/messages`
|
||||
)
|
||||
|
||||
return {
|
||||
items: Array.isArray(payload?.messages?.messages)
|
||||
? payload.messages.messages
|
||||
: Array.isArray(payload?.messages)
|
||||
? payload.messages
|
||||
: Array.isArray(payload?.data?.messages?.messages)
|
||||
? payload.data.messages.messages
|
||||
: Array.isArray(payload?.data?.messages)
|
||||
? payload.data.messages
|
||||
: Array.isArray(payload)
|
||||
? payload
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendGhlConversationMessage(
|
||||
config: GhlMirrorConfig,
|
||||
args: {
|
||||
conversationId?: string
|
||||
contactId?: string
|
||||
message: string
|
||||
type?: string
|
||||
}
|
||||
) {
|
||||
return await fetchGhlMirrorJson(config, "/conversations/messages", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: args.type || "SMS",
|
||||
message: args.message,
|
||||
conversationId: args.conversationId,
|
||||
contactId: args.contactId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchGhlCallLogsPage(
|
||||
config: GhlMirrorConfig,
|
||||
args?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
) {
|
||||
const url = new URL(`${config.baseUrl}/voice-ai/dashboard/call-logs`)
|
||||
url.searchParams.set("locationId", config.locationId)
|
||||
url.searchParams.set("page", String(Math.max(1, args?.page || 1)))
|
||||
url.searchParams.set(
|
||||
"pageSize",
|
||||
String(Math.min(50, Math.max(1, args?.pageSize || 50)))
|
||||
)
|
||||
|
||||
const payload = await fetchGhlMirrorJson(config, url.pathname + url.search)
|
||||
|
||||
return {
|
||||
items: Array.isArray(payload?.callLogs) ? payload.callLogs : [],
|
||||
page: Number(payload?.page || args?.page || 1),
|
||||
total: Number(payload?.total || 0),
|
||||
pageSize: Number(payload?.pageSize || args?.pageSize || 50),
|
||||
}
|
||||
}
|
||||
124
convex/leads.ts
124
convex/leads.ts
|
|
@ -1,12 +1,6 @@
|
|||
// @ts-nocheck
|
||||
import { action, mutation } from "./_generated/server"
|
||||
import { v } from "convex/values"
|
||||
import {
|
||||
ensureConversationParticipant,
|
||||
upsertContactRecord,
|
||||
upsertConversationRecord,
|
||||
upsertMessageRecord,
|
||||
} from "./crmModel"
|
||||
|
||||
const leadSyncStatus = v.union(
|
||||
v.literal("pending"),
|
||||
|
|
@ -125,60 +119,8 @@ export const createLead = mutation({
|
|||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
const contact = await upsertContactRecord(ctx, {
|
||||
firstName: args.firstName,
|
||||
lastName: args.lastName,
|
||||
email: args.email,
|
||||
phone: args.phone,
|
||||
company: args.company,
|
||||
source: args.source,
|
||||
status: args.status === "delivered" ? "active" : "lead",
|
||||
lastActivityAt: now,
|
||||
})
|
||||
|
||||
const conversation = await upsertConversationRecord(ctx, {
|
||||
contactId: contact?._id,
|
||||
title:
|
||||
args.type === "requestMachine"
|
||||
? "Machine request"
|
||||
: "Website contact",
|
||||
channel: "chat",
|
||||
source: args.source || "website",
|
||||
status: args.status === "failed" ? "archived" : "open",
|
||||
direction: "inbound",
|
||||
startedAt: now,
|
||||
lastMessageAt: now,
|
||||
lastMessagePreview: args.message || args.intent,
|
||||
summaryText: args.intent,
|
||||
})
|
||||
|
||||
await ensureConversationParticipant(ctx, {
|
||||
conversationId: conversation._id,
|
||||
contactId: contact?._id,
|
||||
role: "contact",
|
||||
displayName: `${args.firstName} ${args.lastName}`.trim(),
|
||||
phone: args.phone,
|
||||
email: args.email,
|
||||
})
|
||||
|
||||
if (args.message || args.intent) {
|
||||
await upsertMessageRecord(ctx, {
|
||||
conversationId: conversation._id,
|
||||
contactId: contact?._id,
|
||||
direction: "inbound",
|
||||
channel: "chat",
|
||||
source: args.source || "website",
|
||||
messageType: args.type,
|
||||
body: args.message || args.intent || "",
|
||||
status: args.status,
|
||||
sentAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return await ctx.db.insert("leadSubmissions", {
|
||||
...args,
|
||||
contactId: contact?._id,
|
||||
conversationId: conversation?._id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deliveredAt: args.status === "delivered" ? now : undefined,
|
||||
|
|
@ -234,54 +176,6 @@ export const ingestLead = mutation({
|
|||
const fallbackName = splitName(args.name)
|
||||
const type = mapServiceToType(args.service)
|
||||
const now = Date.now()
|
||||
const contact = await upsertContactRecord(ctx, {
|
||||
firstName: args.firstName || fallbackName.firstName,
|
||||
lastName: args.lastName || fallbackName.lastName,
|
||||
email: args.email,
|
||||
phone: args.phone,
|
||||
company: args.company,
|
||||
source: args.source,
|
||||
status: "lead",
|
||||
lastActivityAt: now,
|
||||
})
|
||||
const conversation = await upsertConversationRecord(ctx, {
|
||||
contactId: contact?._id,
|
||||
title: type === "requestMachine" ? "Machine request" : "Website contact",
|
||||
channel: "chat",
|
||||
source: args.source || "website",
|
||||
status: "open",
|
||||
direction: "inbound",
|
||||
startedAt: now,
|
||||
lastMessageAt: now,
|
||||
lastMessagePreview: args.message || args.intent,
|
||||
summaryText: args.intent || args.service,
|
||||
})
|
||||
|
||||
await ensureConversationParticipant(ctx, {
|
||||
conversationId: conversation._id,
|
||||
contactId: contact?._id,
|
||||
role: "contact",
|
||||
displayName: `${args.firstName || fallbackName.firstName} ${args.lastName || fallbackName.lastName}`.trim(),
|
||||
phone: args.phone,
|
||||
email: args.email,
|
||||
})
|
||||
|
||||
await upsertMessageRecord(ctx, {
|
||||
conversationId: conversation._id,
|
||||
contactId: contact?._id,
|
||||
direction: "inbound",
|
||||
channel: "chat",
|
||||
source: args.source || "website",
|
||||
messageType: type,
|
||||
body: args.message,
|
||||
status: "pending",
|
||||
sentAt: now,
|
||||
metadata: JSON.stringify({
|
||||
intent: args.intent,
|
||||
service: args.service,
|
||||
}),
|
||||
})
|
||||
|
||||
const leadId = await ctx.db.insert("leadSubmissions", {
|
||||
type,
|
||||
status: "pending",
|
||||
|
|
@ -304,8 +198,6 @@ export const ingestLead = mutation({
|
|||
consentVersion: args.consentVersion,
|
||||
consentCapturedAt: args.consentCapturedAt,
|
||||
consentSourcePage: args.consentSourcePage,
|
||||
contactId: contact?._id,
|
||||
conversationId: conversation?._id,
|
||||
usesendStatus: "pending",
|
||||
ghlStatus: "pending",
|
||||
createdAt: now,
|
||||
|
|
@ -349,22 +241,6 @@ export const updateLeadSyncStatus = mutation({
|
|||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (lead.contactId) {
|
||||
await ctx.db.patch(lead.contactId, {
|
||||
status: status === "delivered" ? "active" : "lead",
|
||||
lastActivityAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (lead.conversationId) {
|
||||
await ctx.db.patch(lead.conversationId, {
|
||||
status: status === "failed" ? "archived" : "open",
|
||||
lastMessageAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return await ctx.db.get(args.leadId)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
// @ts-nocheck
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { v } from "convex/values"
|
||||
import {
|
||||
canonicalizeTenantDomain,
|
||||
manualVisibleForTenant,
|
||||
tenantDomainVariants,
|
||||
} from "../lib/manuals-tenant"
|
||||
|
||||
const manualInput = v.object({
|
||||
filename: v.string(),
|
||||
|
|
@ -28,19 +23,10 @@ const manualInput = v.object({
|
|||
})
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
domain: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const tenantDomain = canonicalizeTenantDomain(args.domain)
|
||||
if (!tenantDomain) {
|
||||
return []
|
||||
}
|
||||
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const manuals = await ctx.db.query("manuals").collect()
|
||||
return manuals
|
||||
.filter((manual) => manualVisibleForTenant(manual, tenantDomain))
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
return manuals.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -120,64 +106,3 @@ export const upsertMany = mutation({
|
|||
return results
|
||||
},
|
||||
})
|
||||
|
||||
export const backfillTenantVisibility = mutation({
|
||||
args: {
|
||||
domain: v.string(),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const tenantDomain = canonicalizeTenantDomain(args.domain)
|
||||
if (!tenantDomain) {
|
||||
throw new Error("A valid tenant domain is required.")
|
||||
}
|
||||
|
||||
const aliases = tenantDomainVariants(tenantDomain)
|
||||
const dryRun = Boolean(args.dryRun)
|
||||
const now = Date.now()
|
||||
const manuals = await ctx.db.query("manuals").collect()
|
||||
|
||||
let patched = 0
|
||||
let alreadyCovered = 0
|
||||
|
||||
for (const manual of manuals) {
|
||||
const visibilitySet = new Set(
|
||||
(manual.siteVisibility || [])
|
||||
.map((entry) => canonicalizeTenantDomain(entry))
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain)
|
||||
const hasDomain =
|
||||
aliases.some((alias) => visibilitySet.has(alias)) ||
|
||||
(sourceDomain ? aliases.includes(sourceDomain) : false)
|
||||
|
||||
if (hasDomain) {
|
||||
alreadyCovered += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nextVisibility = Array.from(
|
||||
new Set([...visibilitySet, ...aliases])
|
||||
).sort()
|
||||
|
||||
if (!dryRun) {
|
||||
await ctx.db.patch(manual._id, {
|
||||
sourceDomain: sourceDomain || tenantDomain,
|
||||
siteVisibility: nextVisibility,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
patched += 1
|
||||
}
|
||||
|
||||
return {
|
||||
domain: tenantDomain,
|
||||
total: manuals.length,
|
||||
patched,
|
||||
alreadyCovered,
|
||||
dryRun,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
228
convex/schema.ts
228
convex/schema.ts
|
|
@ -90,50 +90,6 @@ export default defineSchema({
|
|||
.index("by_category", ["category"])
|
||||
.index("by_path", ["path"]),
|
||||
|
||||
ebayListings: defineTable({
|
||||
itemId: v.string(),
|
||||
title: v.string(),
|
||||
normalizedTitle: v.string(),
|
||||
price: v.string(),
|
||||
currency: v.string(),
|
||||
imageUrl: v.optional(v.string()),
|
||||
viewItemUrl: v.string(),
|
||||
condition: v.optional(v.string()),
|
||||
shippingCost: v.optional(v.string()),
|
||||
affiliateLink: v.string(),
|
||||
sourceQueries: v.array(v.string()),
|
||||
fetchedAt: v.number(),
|
||||
firstSeenAt: v.number(),
|
||||
lastSeenAt: v.number(),
|
||||
expiresAt: v.number(),
|
||||
active: v.boolean(),
|
||||
})
|
||||
.index("by_itemId", ["itemId"])
|
||||
.index("by_active", ["active"])
|
||||
.index("by_expiresAt", ["expiresAt"])
|
||||
.index("by_lastSeenAt", ["lastSeenAt"]),
|
||||
|
||||
ebayPollState: defineTable({
|
||||
key: v.string(),
|
||||
status: v.union(
|
||||
v.literal("idle"),
|
||||
v.literal("success"),
|
||||
v.literal("rate_limited"),
|
||||
v.literal("error"),
|
||||
v.literal("missing_config"),
|
||||
v.literal("skipped")
|
||||
),
|
||||
lastSuccessfulAt: v.optional(v.number()),
|
||||
lastAttemptAt: v.optional(v.number()),
|
||||
nextEligibleAt: v.optional(v.number()),
|
||||
lastError: v.optional(v.string()),
|
||||
consecutiveFailures: v.number(),
|
||||
queryCount: v.number(),
|
||||
itemCount: v.number(),
|
||||
sourceQueries: v.array(v.string()),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_key", ["key"]),
|
||||
|
||||
manualCategories: defineTable({
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
|
|
@ -189,8 +145,6 @@ export default defineSchema({
|
|||
v.literal("skipped")
|
||||
)
|
||||
),
|
||||
contactId: v.optional(v.id("contacts")),
|
||||
conversationId: v.optional(v.id("conversations")),
|
||||
error: v.optional(v.string()),
|
||||
deliveredAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
|
|
@ -245,186 +199,6 @@ export default defineSchema({
|
|||
.index("by_kind", ["kind"])
|
||||
.index("by_status", ["status"]),
|
||||
|
||||
contacts: defineTable({
|
||||
firstName: v.string(),
|
||||
lastName: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
normalizedEmail: v.optional(v.string()),
|
||||
phone: v.optional(v.string()),
|
||||
normalizedPhone: v.optional(v.string()),
|
||||
company: v.optional(v.string()),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
status: v.optional(
|
||||
v.union(
|
||||
v.literal("active"),
|
||||
v.literal("lead"),
|
||||
v.literal("customer"),
|
||||
v.literal("inactive")
|
||||
)
|
||||
),
|
||||
source: v.optional(v.string()),
|
||||
notes: v.optional(v.string()),
|
||||
ghlContactId: v.optional(v.string()),
|
||||
livekitIdentity: v.optional(v.string()),
|
||||
lastActivityAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_normalizedEmail", ["normalizedEmail"])
|
||||
.index("by_normalizedPhone", ["normalizedPhone"])
|
||||
.index("by_ghlContactId", ["ghlContactId"])
|
||||
.index("by_lastActivityAt", ["lastActivityAt"])
|
||||
.index("by_updatedAt", ["updatedAt"]),
|
||||
|
||||
conversations: defineTable({
|
||||
contactId: v.optional(v.id("contacts")),
|
||||
title: v.optional(v.string()),
|
||||
channel: v.union(
|
||||
v.literal("call"),
|
||||
v.literal("sms"),
|
||||
v.literal("chat"),
|
||||
v.literal("unknown")
|
||||
),
|
||||
source: v.optional(v.string()),
|
||||
status: v.optional(
|
||||
v.union(v.literal("open"), v.literal("closed"), v.literal("archived"))
|
||||
),
|
||||
direction: v.optional(
|
||||
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("mixed"))
|
||||
),
|
||||
startedAt: v.number(),
|
||||
endedAt: v.optional(v.number()),
|
||||
lastMessageAt: v.optional(v.number()),
|
||||
lastMessagePreview: v.optional(v.string()),
|
||||
unreadCount: v.optional(v.number()),
|
||||
summaryText: v.optional(v.string()),
|
||||
ghlConversationId: v.optional(v.string()),
|
||||
livekitRoomName: v.optional(v.string()),
|
||||
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_contactId", ["contactId"])
|
||||
.index("by_channel", ["channel"])
|
||||
.index("by_status", ["status"])
|
||||
.index("by_ghlConversationId", ["ghlConversationId"])
|
||||
.index("by_livekitRoomName", ["livekitRoomName"])
|
||||
.index("by_voiceSessionId", ["voiceSessionId"])
|
||||
.index("by_lastMessageAt", ["lastMessageAt"]),
|
||||
|
||||
conversationParticipants: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
contactId: v.optional(v.id("contacts")),
|
||||
role: v.optional(
|
||||
v.union(
|
||||
v.literal("contact"),
|
||||
v.literal("agent"),
|
||||
v.literal("system"),
|
||||
v.literal("unknown")
|
||||
)
|
||||
),
|
||||
displayName: v.optional(v.string()),
|
||||
phone: v.optional(v.string()),
|
||||
normalizedPhone: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
normalizedEmail: v.optional(v.string()),
|
||||
externalContactId: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_conversationId", ["conversationId"])
|
||||
.index("by_contactId", ["contactId"])
|
||||
.index("by_externalContactId", ["externalContactId"]),
|
||||
|
||||
messages: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
contactId: v.optional(v.id("contacts")),
|
||||
direction: v.optional(
|
||||
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("system"))
|
||||
),
|
||||
channel: v.union(
|
||||
v.literal("call"),
|
||||
v.literal("sms"),
|
||||
v.literal("chat"),
|
||||
v.literal("unknown")
|
||||
),
|
||||
source: v.optional(v.string()),
|
||||
messageType: v.optional(v.string()),
|
||||
body: v.string(),
|
||||
status: v.optional(v.string()),
|
||||
sentAt: v.number(),
|
||||
ghlMessageId: v.optional(v.string()),
|
||||
voiceTranscriptTurnId: v.optional(v.id("voiceTranscriptTurns")),
|
||||
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||
livekitRoomName: v.optional(v.string()),
|
||||
metadata: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_conversationId", ["conversationId"])
|
||||
.index("by_contactId", ["contactId"])
|
||||
.index("by_ghlMessageId", ["ghlMessageId"])
|
||||
.index("by_voiceTranscriptTurnId", ["voiceTranscriptTurnId"])
|
||||
.index("by_sentAt", ["sentAt"]),
|
||||
|
||||
callArtifacts: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
contactId: v.optional(v.id("contacts")),
|
||||
source: v.optional(v.string()),
|
||||
recordingId: v.optional(v.string()),
|
||||
recordingUrl: v.optional(v.string()),
|
||||
recordingStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("starting"),
|
||||
v.literal("recording"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
)
|
||||
),
|
||||
transcriptionText: v.optional(v.string()),
|
||||
durationMs: v.optional(v.number()),
|
||||
startedAt: v.optional(v.number()),
|
||||
endedAt: v.optional(v.number()),
|
||||
ghlMessageId: v.optional(v.string()),
|
||||
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||
livekitRoomName: v.optional(v.string()),
|
||||
metadata: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_conversationId", ["conversationId"])
|
||||
.index("by_contactId", ["contactId"])
|
||||
.index("by_recordingId", ["recordingId"])
|
||||
.index("by_voiceSessionId", ["voiceSessionId"])
|
||||
.index("by_ghlMessageId", ["ghlMessageId"]),
|
||||
|
||||
externalSyncState: defineTable({
|
||||
provider: v.string(),
|
||||
entityType: v.string(),
|
||||
entityId: v.string(),
|
||||
cursor: v.optional(v.string()),
|
||||
checksum: v.optional(v.string()),
|
||||
status: v.optional(
|
||||
v.union(
|
||||
v.literal("running"),
|
||||
v.literal("pending"),
|
||||
v.literal("synced"),
|
||||
v.literal("failed"),
|
||||
v.literal("missing_config"),
|
||||
v.literal("reconciled"),
|
||||
v.literal("mismatch")
|
||||
)
|
||||
),
|
||||
lastAttemptAt: v.optional(v.number()),
|
||||
lastSyncedAt: v.optional(v.number()),
|
||||
error: v.optional(v.string()),
|
||||
metadata: v.optional(v.string()),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_provider_entityType", ["provider", "entityType"])
|
||||
.index("by_provider_entityType_entityId", ["provider", "entityType", "entityId"]),
|
||||
|
||||
voiceSessions: defineTable({
|
||||
roomName: v.string(),
|
||||
participantIdentity: v.string(),
|
||||
|
|
@ -474,8 +248,6 @@ export default defineSchema({
|
|||
recordingUrl: v.optional(v.string()),
|
||||
recordingError: v.optional(v.string()),
|
||||
metadata: v.optional(v.string()),
|
||||
contactId: v.optional(v.id("contacts")),
|
||||
conversationId: v.optional(v.id("conversations")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,68 +1,6 @@
|
|||
// @ts-nocheck
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { v } from "convex/values"
|
||||
import {
|
||||
ensureConversationParticipant,
|
||||
upsertCallArtifactRecord,
|
||||
upsertContactRecord,
|
||||
upsertConversationRecord,
|
||||
upsertMessageRecord,
|
||||
} from "./crmModel"
|
||||
|
||||
async function syncPhoneConversation(ctx, session, overrides = {}) {
|
||||
const contact = await upsertContactRecord(ctx, {
|
||||
firstName: "Phone",
|
||||
lastName: "Caller",
|
||||
phone: session.participantIdentity,
|
||||
livekitIdentity: session.participantIdentity,
|
||||
source: "phone-agent",
|
||||
status: "lead",
|
||||
lastActivityAt:
|
||||
overrides.lastActivityAt ?? session.updatedAt ?? session.startedAt ?? Date.now(),
|
||||
})
|
||||
|
||||
const conversation = await upsertConversationRecord(ctx, {
|
||||
contactId: contact?._id,
|
||||
title: `Phone call ${session.roomName}`,
|
||||
channel: "call",
|
||||
source: "phone-agent",
|
||||
status:
|
||||
session.callStatus === "completed"
|
||||
? "closed"
|
||||
: session.callStatus === "failed"
|
||||
? "archived"
|
||||
: "open",
|
||||
direction: "inbound",
|
||||
startedAt: session.startedAt,
|
||||
endedAt: session.endedAt,
|
||||
lastMessageAt: overrides.lastActivityAt ?? session.updatedAt ?? session.startedAt,
|
||||
lastMessagePreview:
|
||||
overrides.lastMessagePreview ?? session.summaryText ?? session.handoffReason,
|
||||
summaryText: session.summaryText,
|
||||
livekitRoomName: session.roomName,
|
||||
voiceSessionId: session._id,
|
||||
})
|
||||
|
||||
await ensureConversationParticipant(ctx, {
|
||||
conversationId: conversation._id,
|
||||
contactId: contact?._id,
|
||||
role: "contact",
|
||||
displayName: contact ? `${contact.firstName} ${contact.lastName}`.trim() : "Phone caller",
|
||||
phone: contact?.phone || session.participantIdentity,
|
||||
email: contact?.email,
|
||||
})
|
||||
|
||||
await ctx.db.patch(session._id, {
|
||||
contactId: contact?._id,
|
||||
conversationId: conversation?._id,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
return {
|
||||
contact,
|
||||
conversation,
|
||||
}
|
||||
}
|
||||
|
||||
export const getByRoom = query({
|
||||
args: {
|
||||
|
|
@ -145,7 +83,7 @@ export const createSession = mutation({
|
|||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = args.startedAt ?? Date.now()
|
||||
const id = await ctx.db.insert("voiceSessions", {
|
||||
return await ctx.db.insert("voiceSessions", {
|
||||
...args,
|
||||
startedAt: now,
|
||||
callStatus: args.callStatus,
|
||||
|
|
@ -156,13 +94,6 @@ export const createSession = mutation({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const session = await ctx.db.get(id)
|
||||
if (session) {
|
||||
await syncPhoneConversation(ctx, session)
|
||||
}
|
||||
|
||||
return id
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -210,11 +141,7 @@ export const upsertPhoneCallSession = mutation({
|
|||
notificationStatus: existing.notificationStatus || "pending",
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
const updated = await ctx.db.get(existing._id)
|
||||
if (updated) {
|
||||
await syncPhoneConversation(ctx, updated)
|
||||
}
|
||||
return updated
|
||||
return await ctx.db.get(existing._id)
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("voiceSessions", {
|
||||
|
|
@ -237,11 +164,7 @@ export const upsertPhoneCallSession = mutation({
|
|||
updatedAt: now,
|
||||
})
|
||||
|
||||
const session = await ctx.db.get(id)
|
||||
if (session) {
|
||||
await syncPhoneConversation(ctx, session)
|
||||
}
|
||||
return session
|
||||
return await ctx.db.get(id)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -280,33 +203,6 @@ export const addTranscriptTurn = mutation({
|
|||
: session.agentAnsweredAt,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
const { contact, conversation } = await syncPhoneConversation(ctx, {
|
||||
...session,
|
||||
updatedAt: createdAt,
|
||||
}, {
|
||||
lastActivityAt: createdAt,
|
||||
lastMessagePreview: args.text,
|
||||
})
|
||||
|
||||
await upsertMessageRecord(ctx, {
|
||||
conversationId: conversation._id,
|
||||
contactId: args.role === "user" ? contact?._id : undefined,
|
||||
direction:
|
||||
args.role === "user"
|
||||
? "inbound"
|
||||
: args.role === "assistant"
|
||||
? "outbound"
|
||||
: "system",
|
||||
channel: "call",
|
||||
source: args.source || "phone-agent",
|
||||
messageType: args.kind || "transcript",
|
||||
body: args.text,
|
||||
sentAt: createdAt,
|
||||
voiceTranscriptTurnId: turnId,
|
||||
voiceSessionId: args.sessionId,
|
||||
livekitRoomName: args.roomName,
|
||||
})
|
||||
}
|
||||
|
||||
return turnId
|
||||
|
|
@ -335,21 +231,8 @@ export const linkPhoneCallLead = mutation({
|
|||
handoffReason: args.handoffReason,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
const session = await ctx.db.get(args.sessionId)
|
||||
if (session) {
|
||||
const { conversation } = await syncPhoneConversation(ctx, session)
|
||||
if (args.linkedLeadId || args.leadOutcome || args.handoffReason) {
|
||||
await ctx.db.patch(conversation._id, {
|
||||
summaryText:
|
||||
session.summaryText ||
|
||||
args.handoffReason ||
|
||||
conversation.summaryText,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return session
|
||||
return await ctx.db.get(args.sessionId)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -377,21 +260,7 @@ export const updateRecording = mutation({
|
|||
recordingError: args.recordingError,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
const session = await ctx.db.get(args.sessionId)
|
||||
if (session) {
|
||||
const { contact, conversation } = await syncPhoneConversation(ctx, session)
|
||||
await upsertCallArtifactRecord(ctx, {
|
||||
conversationId: conversation._id,
|
||||
contactId: contact?._id,
|
||||
source: "phone-agent",
|
||||
recordingId: args.recordingId,
|
||||
recordingUrl: args.recordingUrl,
|
||||
recordingStatus: args.recordingStatus,
|
||||
voiceSessionId: session._id,
|
||||
livekitRoomName: session.roomName,
|
||||
})
|
||||
}
|
||||
return session
|
||||
return await ctx.db.get(args.sessionId)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -441,31 +310,7 @@ export const completeSession = mutation({
|
|||
notificationError: args.notificationError,
|
||||
updatedAt: endedAt,
|
||||
})
|
||||
const session = await ctx.db.get(args.sessionId)
|
||||
if (session) {
|
||||
const { contact, conversation } = await syncPhoneConversation(ctx, session, {
|
||||
lastActivityAt: endedAt,
|
||||
lastMessagePreview: args.summaryText || session.summaryText,
|
||||
})
|
||||
await upsertCallArtifactRecord(ctx, {
|
||||
conversationId: conversation._id,
|
||||
contactId: contact?._id,
|
||||
source: "phone-agent",
|
||||
recordingId: args.recordingId,
|
||||
recordingUrl: args.recordingUrl,
|
||||
recordingStatus: args.recordingStatus,
|
||||
transcriptionText: args.summaryText,
|
||||
durationMs:
|
||||
typeof session.startedAt === "number"
|
||||
? Math.max(0, endedAt - session.startedAt)
|
||||
: undefined,
|
||||
startedAt: session.startedAt,
|
||||
endedAt,
|
||||
voiceSessionId: session._id,
|
||||
livekitRoomName: session.roomName,
|
||||
})
|
||||
}
|
||||
return session
|
||||
return await ctx.db.get(args.sessionId)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
# Manuals Tenant Recovery Runbook
|
||||
|
||||
## 1) Verify runtime env on active app
|
||||
|
||||
Confirm these variables on the live Coolify app/container:
|
||||
|
||||
- `NEXT_PUBLIC_CONVEX_URL` (full `https://...` URL)
|
||||
- `NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app`
|
||||
- Optional override: `MANUALS_TENANT_DOMAIN=rmv.abundancepartners.app`
|
||||
|
||||
## 2) Verify Convex tenant data gate
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm deploy:staging:convex-gate
|
||||
```
|
||||
|
||||
This fails if Convex returns fewer than one manual for the active domain.
|
||||
|
||||
## 3) Backfill existing manuals rows for tenant visibility
|
||||
|
||||
Dry run first:
|
||||
|
||||
```bash
|
||||
pnpm manuals:backfill:tenant -- --domain rmv.abundancepartners.app --dry-run
|
||||
```
|
||||
|
||||
Apply:
|
||||
|
||||
```bash
|
||||
pnpm manuals:backfill:tenant -- --domain rmv.abundancepartners.app
|
||||
```
|
||||
|
||||
## 4) Re-run smoke checks
|
||||
|
||||
```bash
|
||||
pnpm deploy:staging:smoke -- --base-url https://rmv.abundancepartners.app --skip-browser
|
||||
```
|
||||
|
||||
Manuals checks will fail if:
|
||||
|
||||
- `/manuals` renders with `initialManuals: []`
|
||||
- tenant domain marker mismatches the host
|
||||
- degraded manuals state is shown
|
||||
|
||||
## 5) Recover eBay parts cache (when status is `rate_limited`/empty)
|
||||
|
||||
Force a cache refresh from the live app (requires `ADMIN_API_TOKEN`):
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
-H "x-admin-token: $ADMIN_API_TOKEN" \
|
||||
https://rmv.abundancepartners.app/api/admin/ebay/refresh
|
||||
```
|
||||
|
||||
Then verify:
|
||||
|
||||
1. `GET /api/ebay/search?...` reports cache `status=success` with non-zero `listingCount`.
|
||||
2. `POST /api/ebay/manual-parts` for a known parts manual returns at least one listing.
|
||||
3. Manual viewer shows no stale/error eBay panel when matches are unavailable.
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
# eBay Cache Diagnosis
|
||||
|
||||
Use this when the manuals/parts experience looks empty or stale and you want to know whether the problem is env, Convex, cache data, or the browser UI.
|
||||
|
||||
## What It Checks
|
||||
|
||||
- Public pages: `/`, `/contact-us`, `/products`, `/manuals`
|
||||
- eBay cache routes:
|
||||
- `GET /api/ebay/search?keywords=vending machine part`
|
||||
- `POST /api/ebay/manual-parts`
|
||||
- Notification validation:
|
||||
- `GET /api/ebay/notifications?challenge_code=...`
|
||||
- Admin refresh:
|
||||
- `POST /api/admin/ebay/refresh` when an admin token is provided
|
||||
- Browser smoke:
|
||||
- Loads `/manuals`
|
||||
- Opens the AP parts manual viewer
|
||||
- Confirms the viewer or fallback state is visible
|
||||
|
||||
## How To Run
|
||||
|
||||
Local:
|
||||
|
||||
```bash
|
||||
pnpm diagnose:ebay
|
||||
```
|
||||
|
||||
Staging:
|
||||
|
||||
```bash
|
||||
pnpm diagnose:ebay --base-url https://rmv.abundancepartners.app --admin-token "$ADMIN_API_TOKEN"
|
||||
```
|
||||
|
||||
You can skip browser checks if Playwright browsers are unavailable:
|
||||
|
||||
```bash
|
||||
SMOKE_SKIP_BROWSER=1 pnpm diagnose:ebay
|
||||
```
|
||||
|
||||
## How To Read The Output
|
||||
|
||||
- `NEXT_PUBLIC_CONVEX_URL missing`
|
||||
- The cache routes should return `status: disabled` and no listings.
|
||||
- `cache.message` mentions bundled/fallback cache
|
||||
- This is not revenue-ready. The app is not using Convex cached inventory.
|
||||
- `cache.status=success` with `listingCount=0`
|
||||
- Treat this as backend cache failure or empty cache; not revenue-ready.
|
||||
- `synthetic placeholder listings` failure
|
||||
- Listings are fake data and should not be shown in affiliate cards.
|
||||
- `trusted listings missing affiliate tracking` failure
|
||||
- Listings may be real but links are not monetized yet.
|
||||
- Notification challenge returns `200`
|
||||
- The eBay validation endpoint is wired correctly.
|
||||
- Admin refresh returns `2xx`
|
||||
- The cache seeding path is available and the admin token is accepted.
|
||||
|
|
@ -3,9 +3,6 @@ import { fetchQuery } from "convex/nextjs"
|
|||
import { makeFunctionReference } from "convex/server"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
||||
import { normalizeManualAssetValue } from "@/lib/manuals-asset-paths"
|
||||
import { deriveThumbnailPathFromManualPath } from "@/lib/manuals-thumbnail-fallback"
|
||||
import type { Product } from "@/lib/products/types"
|
||||
import type { Manual } from "@/lib/manuals-types"
|
||||
|
||||
|
|
@ -83,15 +80,14 @@ function getServerConvexClient(useAdminAuth: boolean) {
|
|||
async function queryManualsWithAuthFallback<TData>(
|
||||
label: string,
|
||||
queryRef: ReturnType<typeof makeFunctionReference<"query">>,
|
||||
fallback: TData,
|
||||
args: Record<string, unknown> = {}
|
||||
fallback: TData
|
||||
): Promise<TData> {
|
||||
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
||||
const adminClient = getServerConvexClient(true)
|
||||
|
||||
if (adminClient) {
|
||||
try {
|
||||
return (await adminClient.query(queryRef, args)) as TData
|
||||
return (await adminClient.query(queryRef, {})) as TData
|
||||
} catch (error) {
|
||||
console.error(`[convex-service] ${label} admin query failed`, error)
|
||||
if (!adminKey) {
|
||||
|
|
@ -107,7 +103,7 @@ async function queryManualsWithAuthFallback<TData>(
|
|||
|
||||
return await safeFetchQuery(
|
||||
`${label}.public`,
|
||||
publicClient.query(queryRef, args),
|
||||
publicClient.query(queryRef, {}),
|
||||
fallback
|
||||
)
|
||||
}
|
||||
|
|
@ -171,13 +167,6 @@ function mapConvexProduct(product: ConvexProductDoc): Product {
|
|||
}
|
||||
|
||||
function mapConvexManual(manual: ConvexManualDoc): Manual {
|
||||
const normalizedThumbnailUrl = normalizeManualAssetValue(
|
||||
manual.thumbnailUrl,
|
||||
"thumbnail"
|
||||
)
|
||||
const thumbnailUrl =
|
||||
normalizedThumbnailUrl || deriveThumbnailPathFromManualPath(manual.path)
|
||||
|
||||
return {
|
||||
filename: manual.filename,
|
||||
path: manual.path,
|
||||
|
|
@ -189,7 +178,7 @@ function mapConvexManual(manual: ConvexManualDoc): Manual {
|
|||
: undefined,
|
||||
searchTerms: manual.searchTerms,
|
||||
commonNames: manual.commonNames,
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
thumbnailUrl: manual.thumbnailUrl,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,26 +227,15 @@ export async function getConvexProduct(id: string): Promise<Product | null> {
|
|||
return match ? mapConvexProduct(match) : null
|
||||
}
|
||||
|
||||
export async function listConvexManuals(domain?: string): Promise<Manual[]> {
|
||||
export async function listConvexManuals(): Promise<Manual[]> {
|
||||
if (!hasConvexUrl()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const tenantDomain = resolveManualsTenantDomain({
|
||||
requestHost: domain,
|
||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
||||
})
|
||||
|
||||
if (!tenantDomain) {
|
||||
return []
|
||||
}
|
||||
|
||||
const manuals = await queryManualsWithAuthFallback(
|
||||
"manuals.list",
|
||||
LIST_MANUALS,
|
||||
[] as ConvexManualDoc[],
|
||||
{ domain: tenantDomain }
|
||||
[] as ConvexManualDoc[]
|
||||
)
|
||||
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,516 +0,0 @@
|
|||
export type CachedEbayListing = {
|
||||
itemId: string
|
||||
title: string
|
||||
price: string
|
||||
currency: string
|
||||
imageUrl?: string
|
||||
viewItemUrl: string
|
||||
condition?: string
|
||||
shippingCost?: string
|
||||
affiliateLink: string
|
||||
normalizedTitle?: string
|
||||
sourceQueries?: string[]
|
||||
fetchedAt?: number
|
||||
firstSeenAt?: number
|
||||
lastSeenAt?: number
|
||||
expiresAt?: number
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export type ManualPartInput = {
|
||||
partNumber: string
|
||||
description: string
|
||||
manufacturer?: string
|
||||
category?: string
|
||||
manualFilename?: string
|
||||
}
|
||||
|
||||
export type EbayCacheState = {
|
||||
key: string
|
||||
status:
|
||||
| "idle"
|
||||
| "success"
|
||||
| "rate_limited"
|
||||
| "error"
|
||||
| "missing_config"
|
||||
| "skipped"
|
||||
| "disabled"
|
||||
lastSuccessfulAt: number | null
|
||||
lastAttemptAt: number | null
|
||||
nextEligibleAt: number | null
|
||||
lastError: string | null
|
||||
consecutiveFailures: number
|
||||
queryCount: number
|
||||
itemCount: number
|
||||
sourceQueries: string[]
|
||||
freshnessMs: number | null
|
||||
isStale: boolean
|
||||
listingCount?: number
|
||||
activeListingCount?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
const SYNTHETIC_ITEM_PREFIX = "123456789"
|
||||
const PLACEHOLDER_IMAGE_HOSTS = [
|
||||
"images.unsplash.com",
|
||||
"via.placeholder.com",
|
||||
"placehold.co",
|
||||
]
|
||||
|
||||
const GENERIC_PART_TERMS = new Set([
|
||||
"and",
|
||||
"the",
|
||||
"for",
|
||||
"with",
|
||||
"from",
|
||||
"page",
|
||||
"part",
|
||||
"parts",
|
||||
"number",
|
||||
"numbers",
|
||||
"read",
|
||||
"across",
|
||||
"refer",
|
||||
"reference",
|
||||
"shown",
|
||||
"figure",
|
||||
"fig",
|
||||
"rev",
|
||||
"revision",
|
||||
"item",
|
||||
"items",
|
||||
"assembly",
|
||||
"assy",
|
||||
"machine",
|
||||
"vending",
|
||||
])
|
||||
|
||||
const COMMON_QUERY_STOPWORDS = new Set([
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"for",
|
||||
"in",
|
||||
"of",
|
||||
"the",
|
||||
"to",
|
||||
"with",
|
||||
"vending",
|
||||
"machine",
|
||||
"machines",
|
||||
"part",
|
||||
"parts",
|
||||
])
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
function parsePositivePrice(value: string): number | null {
|
||||
const match = value.match(/([0-9]+(?:\.[0-9]+)?)/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(match[1])
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseUrl(value: string): URL | null {
|
||||
try {
|
||||
return new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isSyntheticEbayListing(
|
||||
listing: Pick<CachedEbayListing, "itemId" | "viewItemUrl" | "imageUrl">
|
||||
): boolean {
|
||||
const itemId = listing.itemId?.trim() || ""
|
||||
const viewItemUrl = listing.viewItemUrl?.trim() || ""
|
||||
const imageUrl = listing.imageUrl?.trim() || ""
|
||||
|
||||
if (!itemId || itemId.startsWith(SYNTHETIC_ITEM_PREFIX)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (viewItemUrl.includes(SYNTHETIC_ITEM_PREFIX)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
const parsedImageUrl = parseUrl(imageUrl)
|
||||
const imageHost = parsedImageUrl?.hostname.toLowerCase() || ""
|
||||
if (PLACEHOLDER_IMAGE_HOSTS.some((host) => imageHost.includes(host))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isTrustedEbayListing(listing: CachedEbayListing): boolean {
|
||||
const itemId = listing.itemId?.trim() || ""
|
||||
if (!/^[0-9]{9,15}$/.test(itemId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isSyntheticEbayListing(listing)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsedViewUrl = parseUrl(listing.viewItemUrl || "")
|
||||
if (!parsedViewUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const viewHost = parsedViewUrl.hostname.toLowerCase()
|
||||
if (!viewHost.includes("ebay.")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!parsedViewUrl.pathname.includes("/itm/")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!parsePositivePrice(listing.price || "")) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function filterTrustedEbayListings(
|
||||
listings: CachedEbayListing[]
|
||||
): CachedEbayListing[] {
|
||||
return listings.filter((listing) => isTrustedEbayListing(listing))
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
normalizeText(value)
|
||||
.split(" ")
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 1 && !COMMON_QUERY_STOPWORDS.has(token))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function listingSearchText(listing: Pick<CachedEbayListing, "title" | "normalizedTitle">): string {
|
||||
return normalizeText(listing.normalizedTitle || listing.title)
|
||||
}
|
||||
|
||||
function isListingFresh(listing: CachedEbayListing): boolean {
|
||||
if (listing.active === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof listing.expiresAt === "number") {
|
||||
return listing.expiresAt >= Date.now()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function sourceQueryBonus(listing: CachedEbayListing, queryTerms: string[]): number {
|
||||
if (!listing.sourceQueries || listing.sourceQueries.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const sourceText = listing.sourceQueries.map((query) => normalizeText(query)).join(" ")
|
||||
let bonus = 0
|
||||
for (const term of queryTerms) {
|
||||
if (sourceText.includes(term)) {
|
||||
bonus += 3
|
||||
}
|
||||
}
|
||||
|
||||
return bonus
|
||||
}
|
||||
|
||||
function computeTokenOverlapScore(queryTerms: string[], haystackText: string): number {
|
||||
let score = 0
|
||||
for (const term of queryTerms) {
|
||||
if (haystackText.includes(term)) {
|
||||
score += 8
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function scoreListingForPart(part: ManualPartInput, listing: CachedEbayListing): number {
|
||||
const partNumber = normalizeText(part.partNumber)
|
||||
const description = normalizeText(part.description)
|
||||
const manufacturer = normalizeText(part.manufacturer || "")
|
||||
const category = normalizeText(part.category || "")
|
||||
const titleText = listingSearchText(listing)
|
||||
const listingTokens = tokenize(listing.title)
|
||||
const descriptionTokens = tokenize(part.description)
|
||||
const manufacturerTokens = tokenize(part.manufacturer || "")
|
||||
const categoryTokens = tokenize(part.category || "")
|
||||
|
||||
let score = isListingFresh(listing) ? 10 : -6
|
||||
|
||||
if (!partNumber) {
|
||||
return -100
|
||||
}
|
||||
|
||||
if (titleText.includes(partNumber)) {
|
||||
score += 110
|
||||
}
|
||||
|
||||
const compactPartNumber = partNumber.replace(/\s+/g, "")
|
||||
const compactTitle = titleText.replace(/\s+/g, "")
|
||||
if (compactPartNumber && compactTitle.includes(compactPartNumber)) {
|
||||
score += 90
|
||||
}
|
||||
|
||||
const exactTokenMatch = listingTokens.includes(partNumber)
|
||||
if (exactTokenMatch) {
|
||||
score += 80
|
||||
}
|
||||
|
||||
const digitsOnlyPart = partNumber.replace(/[^0-9]/g, "")
|
||||
if (digitsOnlyPart.length >= 4 && compactTitle.includes(digitsOnlyPart)) {
|
||||
score += 40
|
||||
}
|
||||
|
||||
if (description) {
|
||||
const overlap = descriptionTokens.filter((token) => titleText.includes(token)).length
|
||||
score += Math.min(overlap * 7, 28)
|
||||
}
|
||||
|
||||
if (manufacturer) {
|
||||
score += Math.min(
|
||||
manufacturerTokens.filter((token) => titleText.includes(token)).length * 8,
|
||||
24
|
||||
)
|
||||
}
|
||||
|
||||
if (category) {
|
||||
score += Math.min(
|
||||
categoryTokens.filter((token) => titleText.includes(token)).length * 5,
|
||||
10
|
||||
)
|
||||
}
|
||||
|
||||
score += computeTokenOverlapScore(tokenize(part.partNumber), titleText)
|
||||
score += sourceQueryBonus(listing, [
|
||||
partNumber,
|
||||
...descriptionTokens,
|
||||
...manufacturerTokens,
|
||||
...categoryTokens,
|
||||
])
|
||||
|
||||
if (GENERIC_PART_TERMS.has(partNumber)) {
|
||||
score -= 50
|
||||
}
|
||||
|
||||
if (titleText.includes("vending") || titleText.includes("machine")) {
|
||||
score += 6
|
||||
}
|
||||
|
||||
if (listing.condition && /new|used|refurbished/i.test(listing.condition)) {
|
||||
score += 2
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function scoreListingForQuery(query: string, listing: CachedEbayListing): number {
|
||||
const queryText = normalizeText(query)
|
||||
const titleText = listingSearchText(listing)
|
||||
const queryTerms = tokenize(query)
|
||||
|
||||
let score = isListingFresh(listing) ? 10 : -6
|
||||
|
||||
if (!queryText) {
|
||||
return -100
|
||||
}
|
||||
|
||||
if (titleText.includes(queryText)) {
|
||||
score += 70
|
||||
}
|
||||
|
||||
score += computeTokenOverlapScore(queryTerms, titleText)
|
||||
score += sourceQueryBonus(listing, queryTerms)
|
||||
|
||||
if (queryTerms.some((term) => titleText.includes(term))) {
|
||||
score += 20
|
||||
}
|
||||
|
||||
if (titleText.includes("vending")) {
|
||||
score += 8
|
||||
}
|
||||
|
||||
if (GENERIC_PART_TERMS.has(queryText)) {
|
||||
score -= 30
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
export function rankListingsForPart(
|
||||
part: ManualPartInput,
|
||||
listings: CachedEbayListing[],
|
||||
limit: number
|
||||
): CachedEbayListing[] {
|
||||
return listings
|
||||
.map((listing) => ({
|
||||
listing,
|
||||
score: scoreListingForPart(part, listing),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score
|
||||
}
|
||||
|
||||
const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0
|
||||
const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0
|
||||
return bFreshness - aFreshness
|
||||
})
|
||||
.slice(0, limit)
|
||||
.filter((entry) => entry.score > 0)
|
||||
.map((entry) => entry.listing)
|
||||
}
|
||||
|
||||
export function rankListingsForQuery(
|
||||
query: string,
|
||||
listings: CachedEbayListing[],
|
||||
limit: number
|
||||
): CachedEbayListing[] {
|
||||
return listings
|
||||
.map((listing) => ({
|
||||
listing,
|
||||
score: scoreListingForQuery(query, listing),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score
|
||||
}
|
||||
|
||||
const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0
|
||||
const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0
|
||||
return bFreshness - aFreshness
|
||||
})
|
||||
.slice(0, limit)
|
||||
.filter((entry) => entry.score > 0)
|
||||
.map((entry) => entry.listing)
|
||||
}
|
||||
|
||||
export function isEbayRateLimitError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("10001") ||
|
||||
normalized.includes("rate limit") ||
|
||||
normalized.includes("exceeded the number of times") ||
|
||||
normalized.includes("too many requests") ||
|
||||
normalized.includes("quota")
|
||||
)
|
||||
}
|
||||
|
||||
export function buildAffiliateLink(
|
||||
viewItemUrl: string,
|
||||
campaignId?: string | null
|
||||
): string {
|
||||
const trimmedCampaignId = campaignId?.trim() || ""
|
||||
if (!trimmedCampaignId) {
|
||||
return viewItemUrl
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(viewItemUrl)
|
||||
url.searchParams.set("mkcid", "1")
|
||||
url.searchParams.set("mkrid", "711-53200-19255-0")
|
||||
url.searchParams.set("siteid", "0")
|
||||
url.searchParams.set("campid", trimmedCampaignId)
|
||||
url.searchParams.set("toolid", "10001")
|
||||
url.searchParams.set("mkevt", "1")
|
||||
return url.toString()
|
||||
} catch {
|
||||
return viewItemUrl
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEbayItem(
|
||||
item: any,
|
||||
options?: {
|
||||
campaignId?: string
|
||||
sourceQuery?: string
|
||||
fetchedAt?: number
|
||||
existing?: CachedEbayListing
|
||||
expiresAt?: number
|
||||
}
|
||||
): CachedEbayListing {
|
||||
const currentPrice = item?.sellingStatus?.currentPrice
|
||||
const shippingCost = item?.shippingInfo?.shippingServiceCost
|
||||
const condition = item?.condition
|
||||
const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || ""
|
||||
const title = item?.title || "Unknown Item"
|
||||
const fetchedAt = options?.fetchedAt ?? Date.now()
|
||||
const existing = options?.existing
|
||||
const sourceQueries = Array.from(
|
||||
new Set([
|
||||
...(existing?.sourceQueries || []),
|
||||
...(options?.sourceQuery ? [options.sourceQuery] : []),
|
||||
])
|
||||
)
|
||||
|
||||
return {
|
||||
itemId: String(item?.itemId || existing?.itemId || ""),
|
||||
title,
|
||||
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
|
||||
currency: currentPrice?.currencyId || "USD",
|
||||
imageUrl: item?.galleryURL || existing?.imageUrl || undefined,
|
||||
viewItemUrl,
|
||||
condition: condition?.conditionDisplayName || existing?.condition || undefined,
|
||||
shippingCost: shippingCost?.value
|
||||
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
|
||||
: existing?.shippingCost,
|
||||
affiliateLink: buildAffiliateLink(viewItemUrl, options?.campaignId),
|
||||
normalizedTitle: normalizeText(title),
|
||||
sourceQueries,
|
||||
firstSeenAt: existing?.firstSeenAt ?? fetchedAt,
|
||||
lastSeenAt: fetchedAt,
|
||||
fetchedAt,
|
||||
expiresAt: options?.expiresAt ?? existing?.expiresAt ?? fetchedAt,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function sortListingsByFreshness(listings: CachedEbayListing[]): CachedEbayListing[] {
|
||||
return [...listings].sort((a, b) => {
|
||||
const aActive = a.active === false ? 0 : 1
|
||||
const bActive = b.active === false ? 0 : 1
|
||||
if (aActive !== bActive) {
|
||||
return bActive - aActive
|
||||
}
|
||||
|
||||
const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0
|
||||
const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0
|
||||
return bFreshness - aFreshness
|
||||
})
|
||||
}
|
||||
|
||||
export function estimateListingFreshness(now: number, lastSuccessfulAt?: number) {
|
||||
if (!lastSuccessfulAt) {
|
||||
return {
|
||||
isFresh: false,
|
||||
isStale: true,
|
||||
freshnessMs: null as number | null,
|
||||
}
|
||||
}
|
||||
|
||||
const freshnessMs = Math.max(0, now - lastSuccessfulAt)
|
||||
return {
|
||||
isFresh: freshnessMs < 24 * 60 * 60 * 1000,
|
||||
isStale: freshnessMs >= 24 * 60 * 60 * 1000,
|
||||
freshnessMs,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import {
|
||||
hasTrustedPartsListings,
|
||||
shouldShowEbayPartsPanel,
|
||||
} from "@/lib/ebay-parts-visibility"
|
||||
import type { EbayCacheState } from "@/lib/ebay-parts-match"
|
||||
|
||||
function createCacheState(
|
||||
overrides: Partial<EbayCacheState> = {}
|
||||
): EbayCacheState {
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "idle",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: null,
|
||||
nextEligibleAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test("hasTrustedPartsListings returns true when at least one part has listings", () => {
|
||||
assert.equal(
|
||||
hasTrustedPartsListings([
|
||||
{ ebayListings: [] },
|
||||
{ ebayListings: [{ itemId: "123" }] },
|
||||
]),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test("shouldShowEbayPartsPanel hides panel for rate-limited cache with no listings", () => {
|
||||
const result = shouldShowEbayPartsPanel({
|
||||
isLoading: false,
|
||||
parts: [{ ebayListings: [] }],
|
||||
cache: createCacheState({
|
||||
status: "rate_limited",
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
freshnessMs: null,
|
||||
lastAttemptAt: null,
|
||||
lastSuccessfulAt: null,
|
||||
nextEligibleAt: null,
|
||||
lastError: "rate limited",
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
|
||||
assert.equal(result, false)
|
||||
})
|
||||
|
||||
test("shouldShowEbayPartsPanel shows panel while loading", () => {
|
||||
const result = shouldShowEbayPartsPanel({
|
||||
isLoading: true,
|
||||
parts: [],
|
||||
cache: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
assert.equal(result, true)
|
||||
})
|
||||
|
||||
test("shouldShowEbayPartsPanel shows panel when trusted listings exist", () => {
|
||||
const result = shouldShowEbayPartsPanel({
|
||||
isLoading: false,
|
||||
parts: [{ ebayListings: [{ itemId: "abc" }] }],
|
||||
cache: createCacheState({
|
||||
status: "success",
|
||||
isStale: false,
|
||||
listingCount: 12,
|
||||
activeListingCount: 12,
|
||||
freshnessMs: 5000,
|
||||
lastAttemptAt: Date.now(),
|
||||
lastSuccessfulAt: Date.now(),
|
||||
nextEligibleAt: Date.now() + 1000,
|
||||
lastError: null,
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
|
||||
assert.equal(result, true)
|
||||
})
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import type { EbayCacheState } from "@/lib/ebay-parts-match"
|
||||
|
||||
type PartLike = {
|
||||
ebayListings?: Array<unknown>
|
||||
}
|
||||
|
||||
const HIDDEN_CACHE_STATUSES = new Set([
|
||||
"rate_limited",
|
||||
"missing_config",
|
||||
"disabled",
|
||||
"error",
|
||||
])
|
||||
|
||||
export function hasTrustedPartsListings(parts: PartLike[]) {
|
||||
return parts.some((part) => (part.ebayListings || []).length > 0)
|
||||
}
|
||||
|
||||
export function shouldShowEbayPartsPanel(args: {
|
||||
isLoading: boolean
|
||||
parts: PartLike[]
|
||||
cache: EbayCacheState | null
|
||||
error: string | null
|
||||
}) {
|
||||
if (args.isLoading) {
|
||||
return true
|
||||
}
|
||||
|
||||
const hasListings = hasTrustedPartsListings(args.parts)
|
||||
if (hasListings) {
|
||||
return true
|
||||
}
|
||||
|
||||
const status = args.cache?.status || "idle"
|
||||
if (HIDDEN_CACHE_STATUSES.has(status)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (args.error) {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,429 +0,0 @@
|
|||
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
const GOOGLE_CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"
|
||||
const DEFAULT_TIME_ZONE = "America/Denver"
|
||||
const DEFAULT_SLOT_MINUTES = 15
|
||||
const DEFAULT_START_HOUR = 8
|
||||
const DEFAULT_END_HOUR = 17
|
||||
const OFFERABLE_WEEKDAYS = new Set([3, 4, 5])
|
||||
|
||||
type LocalDateTime = {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
hour: number
|
||||
minute: number
|
||||
second: number
|
||||
weekday: number
|
||||
}
|
||||
|
||||
type BusyInterval = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function getTimeZone() {
|
||||
return process.env.GOOGLE_CALENDAR_TIMEZONE || DEFAULT_TIME_ZONE
|
||||
}
|
||||
|
||||
function getSlotMinutes() {
|
||||
const value = Number.parseInt(
|
||||
process.env.GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES || "",
|
||||
10
|
||||
)
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_SLOT_MINUTES
|
||||
}
|
||||
|
||||
function getCallbackHours() {
|
||||
const startHour = Number.parseInt(
|
||||
process.env.GOOGLE_CALENDAR_CALLBACK_START_HOUR || "",
|
||||
10
|
||||
)
|
||||
const endHour = Number.parseInt(
|
||||
process.env.GOOGLE_CALENDAR_CALLBACK_END_HOUR || "",
|
||||
10
|
||||
)
|
||||
|
||||
return {
|
||||
startHour:
|
||||
Number.isFinite(startHour) && startHour >= 0 && startHour <= 23
|
||||
? startHour
|
||||
: DEFAULT_START_HOUR,
|
||||
endHour:
|
||||
Number.isFinite(endHour) && endHour >= 1 && endHour <= 24
|
||||
? endHour
|
||||
: DEFAULT_END_HOUR,
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredConfig() {
|
||||
const clientId = String(process.env.GOOGLE_CALENDAR_CLIENT_ID || "").trim()
|
||||
const clientSecret = String(
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_SECRET || ""
|
||||
).trim()
|
||||
const refreshToken = String(
|
||||
process.env.GOOGLE_CALENDAR_REFRESH_TOKEN || ""
|
||||
).trim()
|
||||
const calendarId = String(process.env.GOOGLE_CALENDAR_ID || "").trim()
|
||||
|
||||
const missing = [
|
||||
!clientId ? "GOOGLE_CALENDAR_CLIENT_ID" : null,
|
||||
!clientSecret ? "GOOGLE_CALENDAR_CLIENT_SECRET" : null,
|
||||
!refreshToken ? "GOOGLE_CALENDAR_REFRESH_TOKEN" : null,
|
||||
!calendarId ? "GOOGLE_CALENDAR_ID" : null,
|
||||
].filter(Boolean)
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`${missing.join(", ")} is not configured.`)
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
calendarId,
|
||||
}
|
||||
}
|
||||
|
||||
export function isGoogleCalendarConfigured() {
|
||||
try {
|
||||
getRequiredConfig()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalDateTime(date: Date, timeZone = getTimeZone()): LocalDateTime {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
weekday: "short",
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(date)
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
||||
const weekdayMap: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
}
|
||||
|
||||
return {
|
||||
year: Number.parseInt(values.year || "0", 10),
|
||||
month: Number.parseInt(values.month || "0", 10),
|
||||
day: Number.parseInt(values.day || "0", 10),
|
||||
hour: Number.parseInt(values.hour || "0", 10),
|
||||
minute: Number.parseInt(values.minute || "0", 10),
|
||||
second: Number.parseInt(values.second || "0", 10),
|
||||
weekday: weekdayMap[values.weekday || "Sun"] ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeZoneOffsetMs(date: Date, timeZone = getTimeZone()) {
|
||||
const parts = getLocalDateTime(date, timeZone)
|
||||
const asUtc = Date.UTC(
|
||||
parts.year,
|
||||
parts.month - 1,
|
||||
parts.day,
|
||||
parts.hour,
|
||||
parts.minute,
|
||||
parts.second
|
||||
)
|
||||
|
||||
return asUtc - date.getTime()
|
||||
}
|
||||
|
||||
function zonedDateTimeToUtc(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute: number,
|
||||
second = 0,
|
||||
timeZone = getTimeZone()
|
||||
) {
|
||||
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
|
||||
const offset = getTimeZoneOffsetMs(utcGuess, timeZone)
|
||||
return new Date(utcGuess.getTime() - offset)
|
||||
}
|
||||
|
||||
function addDaysLocal(date: LocalDateTime, days: number) {
|
||||
const utcMidnight = Date.UTC(date.year, date.month - 1, date.day)
|
||||
const next = new Date(utcMidnight + days * 24 * 60 * 60 * 1000)
|
||||
return {
|
||||
year: next.getUTCFullYear(),
|
||||
month: next.getUTCMonth() + 1,
|
||||
day: next.getUTCDate(),
|
||||
}
|
||||
}
|
||||
|
||||
function roundUpToSlot(date: Date, slotMinutes = getSlotMinutes()) {
|
||||
const rounded = new Date(date.getTime())
|
||||
rounded.setUTCSeconds(0, 0)
|
||||
const intervalMs = slotMinutes * 60 * 1000
|
||||
const remainder = rounded.getTime() % intervalMs
|
||||
if (remainder !== 0) {
|
||||
rounded.setTime(rounded.getTime() + (intervalMs - remainder))
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
function formatSlotLabel(startAt: Date, endAt: Date, timeZone = getTimeZone()) {
|
||||
const startFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
const endFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
|
||||
return `${startFormatter.format(startAt)} - ${endFormatter.format(endAt)}`
|
||||
}
|
||||
|
||||
async function getGoogleAccessToken() {
|
||||
const config = getRequiredConfig()
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
refresh_token: config.refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
})
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
const data = (await response.json().catch(() => ({}))) as {
|
||||
access_token?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
if (!response.ok || !data.access_token) {
|
||||
throw new Error(
|
||||
data.error_description ||
|
||||
data.error ||
|
||||
"Failed to authenticate with Google Calendar."
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
calendarId: config.calendarId,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBusyIntervals(startAt: Date, endAt: Date) {
|
||||
const { accessToken, calendarId } = await getGoogleAccessToken()
|
||||
const response = await fetch(`${GOOGLE_CALENDAR_API_BASE}/freeBusy`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
timeMin: startAt.toISOString(),
|
||||
timeMax: endAt.toISOString(),
|
||||
timeZone: getTimeZone(),
|
||||
items: [{ id: calendarId }],
|
||||
}),
|
||||
})
|
||||
|
||||
const data = (await response.json().catch(() => ({}))) as {
|
||||
calendars?: Record<
|
||||
string,
|
||||
{
|
||||
busy?: Array<{ start: string; end: string }>
|
||||
}
|
||||
>
|
||||
error?: {
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
data.error?.message || "Failed to fetch Google Calendar availability."
|
||||
)
|
||||
}
|
||||
|
||||
return (data.calendars?.[calendarId]?.busy || [])
|
||||
.map((entry) => ({
|
||||
start: new Date(entry.start).getTime(),
|
||||
end: new Date(entry.end).getTime(),
|
||||
}))
|
||||
.filter((entry) => Number.isFinite(entry.start) && Number.isFinite(entry.end))
|
||||
}
|
||||
|
||||
function overlapsBusyWindow(
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
busyIntervals: BusyInterval[]
|
||||
) {
|
||||
const start = startAt.getTime()
|
||||
const end = endAt.getTime()
|
||||
return busyIntervals.some((busy) => start < busy.end && end > busy.start)
|
||||
}
|
||||
|
||||
export async function listFutureCallbackSlots(limit = 3) {
|
||||
const timeZone = getTimeZone()
|
||||
const slotMinutes = getSlotMinutes()
|
||||
const { startHour, endHour } = getCallbackHours()
|
||||
const now = new Date()
|
||||
const nowLocal = getLocalDateTime(now, timeZone)
|
||||
const tomorrow = addDaysLocal(nowLocal, 1)
|
||||
const searchStart = zonedDateTimeToUtc(
|
||||
tomorrow.year,
|
||||
tomorrow.month,
|
||||
tomorrow.day,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
timeZone
|
||||
)
|
||||
const searchEnd = new Date(searchStart.getTime() + 21 * 24 * 60 * 60 * 1000)
|
||||
const busyIntervals = await fetchBusyIntervals(searchStart, searchEnd)
|
||||
|
||||
const slots: Array<{
|
||||
startAt: string
|
||||
endAt: string
|
||||
displayLabel: string
|
||||
dayLabel: string
|
||||
}> = []
|
||||
|
||||
for (let offset = 1; offset <= 21 && slots.length < limit; offset += 1) {
|
||||
const day = addDaysLocal(nowLocal, offset)
|
||||
const dayMarker = zonedDateTimeToUtc(
|
||||
day.year,
|
||||
day.month,
|
||||
day.day,
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
timeZone
|
||||
)
|
||||
const weekday = getLocalDateTime(dayMarker, timeZone).weekday
|
||||
|
||||
if (!OFFERABLE_WEEKDAYS.has(weekday)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (
|
||||
let minuteOffset = 0;
|
||||
minuteOffset < (endHour - startHour) * 60 && slots.length < limit;
|
||||
minuteOffset += slotMinutes
|
||||
) {
|
||||
const hour = startHour + Math.floor(minuteOffset / 60)
|
||||
const minute = minuteOffset % 60
|
||||
const slotStart = zonedDateTimeToUtc(
|
||||
day.year,
|
||||
day.month,
|
||||
day.day,
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
timeZone
|
||||
)
|
||||
const slotEnd = new Date(slotStart.getTime() + slotMinutes * 60 * 1000)
|
||||
|
||||
if (slotStart.getTime() <= now.getTime()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (overlapsBusyWindow(slotStart, slotEnd, busyIntervals)) {
|
||||
continue
|
||||
}
|
||||
|
||||
slots.push({
|
||||
startAt: slotStart.toISOString(),
|
||||
endAt: slotEnd.toISOString(),
|
||||
displayLabel: formatSlotLabel(slotStart, slotEnd, timeZone),
|
||||
dayLabel: formatSlotLabel(slotStart, slotEnd, timeZone).split(" - ")[0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
export async function createFollowupReminderEvent(args: {
|
||||
title: string
|
||||
description: string
|
||||
startAt: Date
|
||||
endAt: Date
|
||||
}) {
|
||||
const { accessToken, calendarId } = await getGoogleAccessToken()
|
||||
const timeZone = getTimeZone()
|
||||
const response = await fetch(
|
||||
`${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
summary: args.title,
|
||||
description: args.description,
|
||||
start: {
|
||||
dateTime: args.startAt.toISOString(),
|
||||
timeZone,
|
||||
},
|
||||
end: {
|
||||
dateTime: args.endAt.toISOString(),
|
||||
timeZone,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const data = (await response.json().catch(() => ({}))) as {
|
||||
id?: string
|
||||
htmlLink?: string
|
||||
error?: {
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok || !data.id) {
|
||||
throw new Error(
|
||||
data.error?.message || "Failed to create the Google Calendar reminder."
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
eventId: data.id,
|
||||
htmlLink: data.htmlLink || "",
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSameDayReminderWindow() {
|
||||
const slotMinutes = getSlotMinutes()
|
||||
const startAt = roundUpToSlot(new Date(), slotMinutes)
|
||||
const endAt = new Date(startAt.getTime() + slotMinutes * 60 * 1000)
|
||||
|
||||
return {
|
||||
startAt,
|
||||
endAt,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import {
|
||||
isPrivateOrLocalHost,
|
||||
normalizeManualAssetValue,
|
||||
} from "@/lib/manuals-asset-paths"
|
||||
|
||||
test("normalizeManualAssetValue keeps relative paths relative", () => {
|
||||
assert.equal(
|
||||
normalizeManualAssetValue("Royal-Vendors/foo-manual.pdf", "manual"),
|
||||
"Royal-Vendors/foo-manual.pdf"
|
||||
)
|
||||
assert.equal(
|
||||
normalizeManualAssetValue("Royal-Vendors/foo-thumb.jpg", "thumbnail"),
|
||||
"Royal-Vendors/foo-thumb.jpg"
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizeManualAssetValue normalizes site-proxy absolute URLs to relative paths", () => {
|
||||
assert.equal(
|
||||
normalizeManualAssetValue(
|
||||
"https://cdn.example.com/manuals/vendor/foo-manual.pdf",
|
||||
"manual"
|
||||
),
|
||||
"vendor/foo-manual.pdf"
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
normalizeManualAssetValue(
|
||||
"https://files.example.com/vendor/foo-manual.pdf",
|
||||
"manual"
|
||||
),
|
||||
"https://files.example.com/vendor/foo-manual.pdf"
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizeManualAssetValue rewrites localhost and private hosts to relative paths", () => {
|
||||
assert.equal(
|
||||
normalizeManualAssetValue(
|
||||
"http://localhost:3000/api/thumbnails/Royal-Vendors/foo-thumb.jpg",
|
||||
"thumbnail"
|
||||
),
|
||||
"Royal-Vendors/foo-thumb.jpg"
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
normalizeManualAssetValue(
|
||||
"http://127.0.0.1:3000/api/manuals/Royal-Vendors/foo-manual.pdf",
|
||||
"manual"
|
||||
),
|
||||
"Royal-Vendors/foo-manual.pdf"
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
normalizeManualAssetValue(
|
||||
"http://10.1.2.3:3000/api/thumbnails/Royal-Vendors/foo-thumb.jpg",
|
||||
"thumbnail"
|
||||
),
|
||||
"Royal-Vendors/foo-thumb.jpg"
|
||||
)
|
||||
})
|
||||
|
||||
test("isPrivateOrLocalHost identifies local/private hosts", () => {
|
||||
assert.equal(isPrivateOrLocalHost("localhost"), true)
|
||||
assert.equal(isPrivateOrLocalHost("127.0.0.1"), true)
|
||||
assert.equal(isPrivateOrLocalHost("10.0.0.9"), true)
|
||||
assert.equal(isPrivateOrLocalHost("192.168.1.20"), true)
|
||||
assert.equal(isPrivateOrLocalHost("172.18.5.4"), true)
|
||||
assert.equal(isPrivateOrLocalHost("cdn.example.com"), false)
|
||||
})
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
export type ManualAssetKind = "manual" | "thumbnail"
|
||||
|
||||
function safeDecodeSegment(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function decodePath(pathname: string) {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment) => safeDecodeSegment(segment))
|
||||
.join("/")
|
||||
}
|
||||
|
||||
function stripLeadingSlash(value: string) {
|
||||
return value.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
function stripAssetPrefix(value: string, kind: ManualAssetKind) {
|
||||
if (kind === "manual") {
|
||||
return value
|
||||
.replace(/^api\/manuals\//i, "")
|
||||
.replace(/^manuals\//i, "")
|
||||
.replace(/^\/api\/manuals\//i, "")
|
||||
.replace(/^\/manuals\//i, "")
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/^api\/thumbnails\//i, "")
|
||||
.replace(/^thumbnails\//i, "")
|
||||
.replace(/^\/api\/thumbnails\//i, "")
|
||||
.replace(/^\/thumbnails\//i, "")
|
||||
}
|
||||
|
||||
function looksLikePrivateHost(hostname: string) {
|
||||
const host = hostname.trim().toLowerCase()
|
||||
if (!host) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const match172 = host.match(/^172\.(\d{1,3})\./)
|
||||
if (match172) {
|
||||
const octet = Number.parseInt(match172[1] || "", 10)
|
||||
if (Number.isFinite(octet) && octet >= 16 && octet <= 31) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeRelativePath(value: string, kind: ManualAssetKind) {
|
||||
const decoded = decodePath(stripLeadingSlash(value.trim()))
|
||||
const withoutPrefix = stripAssetPrefix(decoded, kind).trim()
|
||||
if (!withoutPrefix) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (withoutPrefix.includes("..")) {
|
||||
return null
|
||||
}
|
||||
|
||||
return withoutPrefix
|
||||
}
|
||||
|
||||
function extractRelativePathFromAbsolute(url: URL, kind: ManualAssetKind) {
|
||||
const decodedPath = decodePath(url.pathname)
|
||||
return normalizeRelativePath(decodedPath, kind)
|
||||
}
|
||||
|
||||
export function isPrivateOrLocalHost(hostname: string) {
|
||||
return looksLikePrivateHost(hostname)
|
||||
}
|
||||
|
||||
export function normalizeManualAssetValue(
|
||||
value: string | null | undefined,
|
||||
kind: ManualAssetKind
|
||||
): string | null {
|
||||
const raw = String(value || "").trim()
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(raw)) {
|
||||
return normalizeRelativePath(raw, kind)
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
const extractedRelativePath = extractRelativePathFromAbsolute(parsed, kind)
|
||||
if (looksLikePrivateHost(parsed.hostname)) {
|
||||
return extractedRelativePath
|
||||
}
|
||||
|
||||
// Normalize site-proxy URLs to relative paths to keep persisted values tenant-safe.
|
||||
if (extractedRelativePath) {
|
||||
const lowerPath = parsed.pathname.toLowerCase()
|
||||
if (
|
||||
lowerPath.includes("/api/manuals/") ||
|
||||
lowerPath.includes("/api/thumbnails/") ||
|
||||
lowerPath.includes("/manuals/") ||
|
||||
lowerPath.includes("/thumbnails/")
|
||||
) {
|
||||
return extractedRelativePath
|
||||
}
|
||||
}
|
||||
|
||||
return raw
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbsoluteHttpUrl(value: string) {
|
||||
return /^https?:\/\//i.test(value.trim())
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import type { Manual } from "@/lib/manuals-types"
|
||||
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
|
||||
|
||||
function buildManual(overrides: Partial<Manual> = {}): Manual {
|
||||
return {
|
||||
filename: "test-manual.pdf",
|
||||
path: "Test/test-manual.pdf",
|
||||
manufacturer: "Test",
|
||||
category: "Test",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test("sanitizeManualThumbnailsForRuntime keeps relative thumbnails in production", () => {
|
||||
const manual = buildManual({ thumbnailUrl: "Test/test-manual.jpg" })
|
||||
const result = sanitizeManualThumbnailsForRuntime([manual], {
|
||||
isLocalDevelopment: false,
|
||||
thumbnailsRoot: "/tmp/manuals-thumbnails",
|
||||
})
|
||||
|
||||
assert.equal(result[0]?.thumbnailUrl, "Test/test-manual.jpg")
|
||||
})
|
||||
|
||||
test("sanitizeManualThumbnailsForRuntime strips missing relative thumbnails in local development", () => {
|
||||
const manual = buildManual({ thumbnailUrl: "Test/missing-thumb.jpg" })
|
||||
const result = sanitizeManualThumbnailsForRuntime([manual], {
|
||||
isLocalDevelopment: true,
|
||||
thumbnailsRoot: "/tmp/manuals-thumbnails",
|
||||
fileExists: () => false,
|
||||
})
|
||||
|
||||
assert.equal(result[0]?.thumbnailUrl, undefined)
|
||||
})
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { join } from "node:path"
|
||||
import type { Manual } from "@/lib/manuals-types"
|
||||
|
||||
type ThumbnailSanitizeOptions = {
|
||||
isLocalDevelopment: boolean
|
||||
thumbnailsRoot: string
|
||||
fileExists?: (path: string) => boolean
|
||||
}
|
||||
|
||||
export function sanitizeManualThumbnailsForRuntime(
|
||||
manuals: Manual[],
|
||||
options: ThumbnailSanitizeOptions
|
||||
): Manual[] {
|
||||
if (!options.isLocalDevelopment) {
|
||||
return manuals
|
||||
}
|
||||
|
||||
const fileExists = options.fileExists ?? (() => false)
|
||||
|
||||
return manuals.map((manual) => {
|
||||
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
|
||||
return manual
|
||||
}
|
||||
|
||||
const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/")
|
||||
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "")
|
||||
: manual.thumbnailUrl
|
||||
|
||||
return fileExists(join(options.thumbnailsRoot, relativeThumbnailPath))
|
||||
? manual
|
||||
: { ...manual, thumbnailUrl: undefined }
|
||||
})
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import {
|
||||
canonicalizeTenantDomain,
|
||||
manualVisibleForTenant,
|
||||
resolveManualsTenantDomain,
|
||||
tenantDomainVariants,
|
||||
} from "@/lib/manuals-tenant"
|
||||
|
||||
test("canonicalizeTenantDomain strips protocol, port, path, and casing", () => {
|
||||
assert.equal(
|
||||
canonicalizeTenantDomain("HTTPS://RMV.AbundancePartners.App:443/manuals"),
|
||||
"rmv.abundancepartners.app"
|
||||
)
|
||||
assert.equal(canonicalizeTenantDomain(""), "")
|
||||
assert.equal(canonicalizeTenantDomain(undefined), "")
|
||||
})
|
||||
|
||||
test("tenantDomainVariants includes root and www aliases", () => {
|
||||
assert.deepEqual(tenantDomainVariants("rmv.abundancepartners.app"), [
|
||||
"rmv.abundancepartners.app",
|
||||
"www.rmv.abundancepartners.app",
|
||||
])
|
||||
assert.deepEqual(tenantDomainVariants("www.rockymountainvending.com"), [
|
||||
"www.rockymountainvending.com",
|
||||
"rockymountainvending.com",
|
||||
])
|
||||
})
|
||||
|
||||
test("manualVisibleForTenant matches sourceDomain and siteVisibility aliases", () => {
|
||||
assert.equal(
|
||||
manualVisibleForTenant(
|
||||
{
|
||||
sourceDomain: "rmv.abundancepartners.app",
|
||||
},
|
||||
"https://rmv.abundancepartners.app/manuals"
|
||||
),
|
||||
true
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
manualVisibleForTenant(
|
||||
{
|
||||
siteVisibility: ["www.rockymountainvending.com"],
|
||||
},
|
||||
"rockymountainvending.com"
|
||||
),
|
||||
true
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
manualVisibleForTenant(
|
||||
{
|
||||
sourceDomain: "quickfreshvending.com",
|
||||
siteVisibility: ["quickfreshvending.com"],
|
||||
},
|
||||
"rmv.abundancepartners.app"
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test("resolveManualsTenantDomain prioritizes request host then env overrides", () => {
|
||||
assert.equal(
|
||||
resolveManualsTenantDomain({
|
||||
requestHost: "rmv.abundancepartners.app",
|
||||
envTenantDomain: "fallback.example",
|
||||
envSiteDomain: "another.example",
|
||||
}),
|
||||
"rmv.abundancepartners.app"
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
resolveManualsTenantDomain({
|
||||
requestHost: "",
|
||||
envTenantDomain: "tenant.example",
|
||||
envSiteDomain: "site.example",
|
||||
}),
|
||||
"tenant.example"
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
resolveManualsTenantDomain({
|
||||
requestHost: "",
|
||||
envTenantDomain: "",
|
||||
envSiteDomain: "site.example",
|
||||
}),
|
||||
"site.example"
|
||||
)
|
||||
})
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
export type TenantScopedManual = {
|
||||
sourceDomain?: string
|
||||
siteVisibility?: string[]
|
||||
}
|
||||
|
||||
function stripProtocolAndPath(input: string) {
|
||||
const firstHost = input.split(",")[0] || ""
|
||||
|
||||
return firstHost
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/\.$/, "")
|
||||
}
|
||||
|
||||
export function canonicalizeTenantDomain(
|
||||
input: string | null | undefined
|
||||
): string {
|
||||
if (!input) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return stripProtocolAndPath(input)
|
||||
}
|
||||
|
||||
export function tenantDomainVariants(domain: string): string[] {
|
||||
const canonical = canonicalizeTenantDomain(domain)
|
||||
if (!canonical) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (canonical.startsWith("www.")) {
|
||||
return [canonical, canonical.replace(/^www\./, "")]
|
||||
}
|
||||
|
||||
return [canonical, `www.${canonical}`]
|
||||
}
|
||||
|
||||
export function manualVisibleForTenant(
|
||||
manual: TenantScopedManual,
|
||||
domain: string
|
||||
): boolean {
|
||||
const variants = new Set(tenantDomainVariants(domain))
|
||||
if (variants.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain)
|
||||
if (sourceDomain && variants.has(sourceDomain)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const visibility = Array.isArray(manual.siteVisibility)
|
||||
? manual.siteVisibility
|
||||
.map((entry) => canonicalizeTenantDomain(entry))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
if (visibility.some((entry) => variants.has(entry))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function resolveManualsTenantDomain(params: {
|
||||
requestHost?: string | null
|
||||
envSiteDomain?: string | null
|
||||
envTenantDomain?: string | null
|
||||
}) {
|
||||
return (
|
||||
canonicalizeTenantDomain(params.requestHost) ||
|
||||
canonicalizeTenantDomain(params.envTenantDomain) ||
|
||||
canonicalizeTenantDomain(params.envSiteDomain)
|
||||
)
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { deriveThumbnailPathFromManualPath } from "@/lib/manuals-thumbnail-fallback"
|
||||
|
||||
test("deriveThumbnailPathFromManualPath derives jpg path from relative manual path", () => {
|
||||
assert.equal(
|
||||
deriveThumbnailPathFromManualPath("Royal-Vendors/vender-3.pdf"),
|
||||
"Royal-Vendors/vender-3.jpg"
|
||||
)
|
||||
})
|
||||
|
||||
test("deriveThumbnailPathFromManualPath returns undefined for absolute URLs", () => {
|
||||
assert.equal(
|
||||
deriveThumbnailPathFromManualPath("https://example.com/manuals/file.pdf"),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
test("deriveThumbnailPathFromManualPath returns undefined for non-pdf paths", () => {
|
||||
assert.equal(
|
||||
deriveThumbnailPathFromManualPath("Royal-Vendors/not-a-pdf.txt"),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
export function deriveThumbnailPathFromManualPath(
|
||||
manualPath: string | undefined | null
|
||||
): string | undefined {
|
||||
const trimmedPath = String(manualPath || "").trim()
|
||||
if (!trimmedPath || /^https?:\/\//i.test(trimmedPath)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!trimmedPath.toLowerCase().endsWith(".pdf")) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return trimmedPath.replace(/\.pdf$/i, ".jpg")
|
||||
}
|
||||
|
|
@ -2,24 +2,24 @@
|
|||
* Parts lookup utility for frontend
|
||||
*
|
||||
* Provides functions to fetch parts data by manual filename.
|
||||
* Static JSON remains the primary data source, while cached eBay matches
|
||||
* are fetched from the server so normal browsing never reaches eBay.
|
||||
* Static JSON remains the primary data source, while live eBay fallback
|
||||
* goes through the server route so credentials never reach the browser.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CachedEbayListing,
|
||||
EbayCacheState,
|
||||
ManualPartInput,
|
||||
} from "@/lib/ebay-parts-match"
|
||||
import {
|
||||
filterTrustedEbayListings,
|
||||
isSyntheticEbayListing,
|
||||
} from "@/lib/ebay-parts-match"
|
||||
|
||||
export interface PartForPage {
|
||||
partNumber: string
|
||||
description: string
|
||||
ebayListings: CachedEbayListing[]
|
||||
ebayListings: Array<{
|
||||
itemId: string
|
||||
title: string
|
||||
price: string
|
||||
currency: string
|
||||
imageUrl?: string
|
||||
viewItemUrl: string
|
||||
condition?: string
|
||||
shippingCost?: string
|
||||
affiliateLink: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface ManualPartsLookup {
|
||||
|
|
@ -32,40 +32,30 @@ interface ManualPagesParts {
|
|||
}
|
||||
}
|
||||
|
||||
interface CachedPartsResponse {
|
||||
manualFilename: string
|
||||
parts: Array<
|
||||
ManualPartInput & {
|
||||
ebayListings: CachedEbayListing[]
|
||||
}
|
||||
>
|
||||
cache: EbayCacheState
|
||||
cacheSource?: "convex" | "fallback"
|
||||
interface EbaySearchResult {
|
||||
itemId: string
|
||||
title: string
|
||||
price: string
|
||||
currency: string
|
||||
imageUrl?: string
|
||||
viewItemUrl: string
|
||||
condition?: string
|
||||
shippingCost?: string
|
||||
affiliateLink: string
|
||||
}
|
||||
|
||||
interface EbaySearchResponse {
|
||||
results: EbaySearchResult[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface CachedEbaySearchResponse {
|
||||
results: CachedEbayListing[]
|
||||
cache: EbayCacheState
|
||||
cacheSource?: "convex" | "fallback"
|
||||
error?: string
|
||||
}
|
||||
|
||||
const cachedManualMatchResponses = new Map<
|
||||
// Cache for eBay search results
|
||||
const ebaySearchCache = new Map<
|
||||
string,
|
||||
{ response: CachedPartsResponse; timestamp: number }
|
||||
{ results: EbaySearchResult[]; timestamp: number }
|
||||
>()
|
||||
const inFlightManualMatchRequests = new Map<string, Promise<CachedPartsResponse>>()
|
||||
const MANUAL_MATCH_CACHE_TTL = 5 * 60 * 1000
|
||||
const cachedEbaySearchResponses = new Map<
|
||||
string,
|
||||
{ response: CachedEbaySearchResponse; timestamp: number }
|
||||
>()
|
||||
const inFlightEbaySearches = new Map<
|
||||
string,
|
||||
Promise<CachedEbaySearchResponse>
|
||||
>()
|
||||
const EBAY_SEARCH_CACHE_TTL = 5 * 60 * 1000
|
||||
const inFlightEbaySearches = new Map<string, Promise<EbaySearchResponse>>()
|
||||
const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
const GENERIC_PART_TERMS = new Set([
|
||||
"and",
|
||||
|
|
@ -139,196 +129,121 @@ async function loadPartsData(): Promise<{
|
|||
}
|
||||
}
|
||||
|
||||
function makeFallbackCacheState(errorMessage?: string): EbayCacheState {
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "disabled",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: null,
|
||||
nextEligibleAt: null,
|
||||
lastError: errorMessage || "eBay cache unavailable.",
|
||||
consecutiveFailures: 0,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message: errorMessage || "eBay cache unavailable.",
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchManualPartsMatches(
|
||||
manualFilename: string,
|
||||
parts: ManualPartInput[],
|
||||
limit: number
|
||||
): Promise<CachedPartsResponse> {
|
||||
const cacheKey = [
|
||||
manualFilename.trim().toLowerCase(),
|
||||
String(limit),
|
||||
parts
|
||||
.map((part) =>
|
||||
[
|
||||
part.partNumber.trim().toLowerCase(),
|
||||
part.description.trim().toLowerCase(),
|
||||
part.manufacturer?.trim().toLowerCase() || "",
|
||||
part.category?.trim().toLowerCase() || "",
|
||||
].join(":")
|
||||
)
|
||||
.join("|"),
|
||||
].join("::")
|
||||
|
||||
const cached = cachedManualMatchResponses.get(cacheKey)
|
||||
if (cached && Date.now() - cached.timestamp < MANUAL_MATCH_CACHE_TTL) {
|
||||
return cached.response
|
||||
}
|
||||
|
||||
const inFlight = inFlightManualMatchRequests.get(cacheKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/ebay/manual-parts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
manualFilename,
|
||||
parts,
|
||||
limit,
|
||||
}),
|
||||
})
|
||||
|
||||
const body = await response.json().catch(() => null)
|
||||
if (!response.ok || !body || typeof body !== "object") {
|
||||
const message =
|
||||
body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `Failed to load cached parts (${response.status})`
|
||||
return {
|
||||
manualFilename,
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
ebayListings: [],
|
||||
})),
|
||||
cache: makeFallbackCacheState(message),
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
|
||||
const partsResponse = body as CachedPartsResponse
|
||||
return {
|
||||
manualFilename: partsResponse.manualFilename || manualFilename,
|
||||
parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [],
|
||||
cache: partsResponse.cache || makeFallbackCacheState(),
|
||||
error:
|
||||
typeof (partsResponse as CachedPartsResponse).error === "string"
|
||||
? (partsResponse as CachedPartsResponse).error
|
||||
: undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load cached parts"
|
||||
return {
|
||||
manualFilename,
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
ebayListings: [],
|
||||
})),
|
||||
cache: makeFallbackCacheState(message),
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
inFlightManualMatchRequests.set(cacheKey, request)
|
||||
try {
|
||||
const response = await request
|
||||
cachedManualMatchResponses.set(cacheKey, {
|
||||
response,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return response
|
||||
} finally {
|
||||
inFlightManualMatchRequests.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search eBay for parts with caching.
|
||||
* This calls the server route so the app never needs direct eBay credentials
|
||||
* in client code.
|
||||
*/
|
||||
async function searchEBayForParts(
|
||||
partNumber: string,
|
||||
description?: string,
|
||||
manufacturer?: string
|
||||
): Promise<CachedEbaySearchResponse> {
|
||||
): Promise<EbaySearchResponse> {
|
||||
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
|
||||
|
||||
const cached = cachedEbaySearchResponses.get(cacheKey)
|
||||
if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) {
|
||||
return cached.response
|
||||
// Check cache
|
||||
const cached = ebaySearchCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
|
||||
return { results: cached.results as EbaySearchResult[] }
|
||||
}
|
||||
|
||||
const inFlight = inFlightEbaySearches.get(cacheKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
const buildQuery = () => {
|
||||
let query = partNumber
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
keywords: [partNumber, description, manufacturer, "vending machine"]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
maxResults: "3",
|
||||
sortOrder: "BestMatch",
|
||||
})
|
||||
if (description && description.length > 0 && description.length < 50) {
|
||||
const descWords = description
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 3)
|
||||
.slice(0, 3)
|
||||
.join(" ")
|
||||
|
||||
const response = await fetch(`/api/ebay/search?${params.toString()}`)
|
||||
const body = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok || !body || typeof body !== "object") {
|
||||
const message =
|
||||
body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `Failed to load cached eBay listings (${response.status})`
|
||||
return {
|
||||
results: [],
|
||||
cache: makeFallbackCacheState(message),
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results: Array.isArray((body as any).results)
|
||||
? ((body as any).results as CachedEbayListing[])
|
||||
: [],
|
||||
cache: (body as any).cache || makeFallbackCacheState(),
|
||||
error:
|
||||
typeof (body as any).error === "string" ? (body as any).error : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load cached eBay listings"
|
||||
return {
|
||||
results: [],
|
||||
cache: makeFallbackCacheState(message),
|
||||
error: message,
|
||||
if (descWords) {
|
||||
query += ` ${descWords}`
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
inFlightEbaySearches.set(cacheKey, request)
|
||||
try {
|
||||
const response = await request
|
||||
cachedEbaySearchResponses.set(cacheKey, {
|
||||
response,
|
||||
if (manufacturer) {
|
||||
query += ` ${manufacturer}`
|
||||
}
|
||||
|
||||
return `${query} vending machine`
|
||||
}
|
||||
|
||||
const searchViaApi = async (
|
||||
categoryId?: string
|
||||
): Promise<EbaySearchResponse> => {
|
||||
const requestKey = `${cacheKey}:${categoryId || "general"}`
|
||||
|
||||
const inFlight = inFlightEbaySearches.get(requestKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
keywords: buildQuery(),
|
||||
maxResults: "3",
|
||||
sortOrder: "BestMatch",
|
||||
})
|
||||
|
||||
if (categoryId) {
|
||||
params.set("categoryId", categoryId)
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/ebay/search?${params.toString()}`)
|
||||
const body = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `eBay API error: ${response.status}`
|
||||
|
||||
return { results: [], error: message }
|
||||
}
|
||||
|
||||
const results = Array.isArray(body) ? body : []
|
||||
return { results }
|
||||
} catch (error) {
|
||||
return {
|
||||
results: [],
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to search eBay",
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
inFlightEbaySearches.set(requestKey, request)
|
||||
|
||||
try {
|
||||
return await request
|
||||
} finally {
|
||||
inFlightEbaySearches.delete(requestKey)
|
||||
}
|
||||
}
|
||||
|
||||
const categorySearch = await searchViaApi("11700")
|
||||
if (categorySearch.results.length > 0) {
|
||||
ebaySearchCache.set(cacheKey, {
|
||||
results: categorySearch.results,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return response
|
||||
} finally {
|
||||
inFlightEbaySearches.delete(cacheKey)
|
||||
return categorySearch
|
||||
}
|
||||
|
||||
const generalSearch = await searchViaApi()
|
||||
if (generalSearch.results.length > 0) {
|
||||
ebaySearchCache.set(cacheKey, {
|
||||
results: generalSearch.results,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return generalSearch
|
||||
}
|
||||
|
||||
return {
|
||||
results: [],
|
||||
error: categorySearch.error || generalSearch.error,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -336,6 +251,20 @@ function normalizePartText(value: string): string {
|
|||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isSyntheticEbayListing(
|
||||
listing: PartForPage["ebayListings"][number]
|
||||
): boolean {
|
||||
const itemId = listing.itemId?.trim() || ""
|
||||
const viewItemUrl = listing.viewItemUrl?.trim() || ""
|
||||
const imageUrl = listing.imageUrl?.trim() || ""
|
||||
|
||||
return (
|
||||
imageUrl.includes("images.unsplash.com") ||
|
||||
viewItemUrl.includes("123456789") ||
|
||||
itemId.startsWith("123456789")
|
||||
)
|
||||
}
|
||||
|
||||
function hasLiveEbayListings(listings: PartForPage["ebayListings"]): boolean {
|
||||
return listings.some((listing) => !isSyntheticEbayListing(listing))
|
||||
}
|
||||
|
|
@ -533,13 +462,6 @@ async function getPartsForManualWithStatus(manualFilename: string): Promise<{
|
|||
return { parts }
|
||||
}
|
||||
|
||||
function sanitizePartListings(parts: PartForPage[]): PartForPage[] {
|
||||
return parts.map((part) => ({
|
||||
...part,
|
||||
ebayListings: filterTrustedEbayListings(part.ebayListings || []),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all parts for a manual with enhanced eBay data
|
||||
*/
|
||||
|
|
@ -547,7 +469,7 @@ export async function getPartsForManual(
|
|||
manualFilename: string
|
||||
): Promise<PartForPage[]> {
|
||||
const result = await getPartsForManualWithStatus(manualFilename)
|
||||
return sanitizePartListings(result.parts)
|
||||
return result.parts
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -568,17 +490,8 @@ export async function getPartsForPage(
|
|||
return []
|
||||
}
|
||||
|
||||
const matched = await fetchManualPartsMatches(
|
||||
manualFilename,
|
||||
parts.map((part) => ({
|
||||
partNumber: part.partNumber,
|
||||
description: part.description,
|
||||
manualFilename,
|
||||
})),
|
||||
Math.max(parts.length, 1)
|
||||
)
|
||||
|
||||
return sanitizePartListings(matched.parts as PartForPage[])
|
||||
const enhanced = await enhancePartsData(parts)
|
||||
return enhanced.parts
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -590,7 +503,6 @@ export async function getTopPartsForManual(
|
|||
): Promise<{
|
||||
parts: PartForPage[]
|
||||
error?: string
|
||||
cache?: EbayCacheState
|
||||
}> {
|
||||
const { parts } = await getPartsForManualWithStatus(manualFilename)
|
||||
|
||||
|
|
@ -602,20 +514,23 @@ export async function getTopPartsForManual(
|
|||
parts,
|
||||
Math.max(limit * 2, limit)
|
||||
)
|
||||
const matched = await fetchManualPartsMatches(
|
||||
manualFilename,
|
||||
liveSearchCandidates.map((part) => ({
|
||||
partNumber: part.partNumber,
|
||||
description: part.description,
|
||||
manualFilename,
|
||||
})),
|
||||
limit
|
||||
)
|
||||
const { parts: enrichedParts, error } =
|
||||
await enhancePartsData(liveSearchCandidates)
|
||||
|
||||
const sorted = enrichedParts.sort((a, b) => {
|
||||
const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0
|
||||
const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0
|
||||
|
||||
if (aHasLiveListings !== bHasLiveListings) {
|
||||
return bHasLiveListings - aHasLiveListings
|
||||
}
|
||||
|
||||
return b.ebayListings.length - a.ebayListings.length
|
||||
})
|
||||
|
||||
return {
|
||||
parts: sanitizePartListings(matched.parts as PartForPage[]),
|
||||
error: matched.error,
|
||||
cache: matched.cache,
|
||||
parts: sorted.slice(0, limit),
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,13 +15,9 @@ export type AdminPhoneCallDetail = {
|
|||
id: string
|
||||
roomName: string
|
||||
participantIdentity: string
|
||||
callerPhone?: string
|
||||
pathname?: string
|
||||
pageUrl?: string
|
||||
source?: string
|
||||
contactProfileId?: string
|
||||
contactDisplayName?: string
|
||||
contactCompany?: string
|
||||
startedAt: number
|
||||
endedAt?: number
|
||||
durationMs: number | null
|
||||
|
|
@ -37,18 +33,6 @@ export type AdminPhoneCallDetail = {
|
|||
notificationStatus: "pending" | "sent" | "failed" | "disabled"
|
||||
notificationSentAt?: number
|
||||
notificationError?: string
|
||||
reminderStatus?: "none" | "scheduled" | "sameDay"
|
||||
reminderRequestedAt?: number
|
||||
reminderStartAt?: number
|
||||
reminderEndAt?: number
|
||||
reminderCalendarEventId?: string
|
||||
reminderCalendarHtmlLink?: string
|
||||
reminderNote?: string
|
||||
warmTransferStatus?: "none" | "attempted" | "connected" | "failed" | "fallback"
|
||||
warmTransferTarget?: string
|
||||
warmTransferAttemptedAt?: number
|
||||
warmTransferConnectedAt?: number
|
||||
warmTransferFailureReason?: string
|
||||
recordingStatus?:
|
||||
| "pending"
|
||||
| "starting"
|
||||
|
|
@ -71,21 +55,6 @@ export type AdminPhoneCallDetail = {
|
|||
message?: string
|
||||
createdAt: number
|
||||
}
|
||||
contactProfile: null | {
|
||||
_id: string
|
||||
normalizedPhone: string
|
||||
displayName?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
email?: string
|
||||
company?: string
|
||||
lastIntent?: string
|
||||
lastLeadOutcome?: "none" | "contact" | "requestMachine"
|
||||
lastSummaryText?: string
|
||||
lastCallAt?: number
|
||||
lastReminderAt?: number
|
||||
reminderNotes?: string
|
||||
}
|
||||
turns: AdminPhoneCallTurn[]
|
||||
}
|
||||
|
||||
|
|
@ -152,39 +121,20 @@ export function buildPhoneCallSummary(
|
|||
""
|
||||
|
||||
const callerNumber =
|
||||
detail.call.callerPhone ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
detail.call.participantIdentity
|
||||
const parts = [
|
||||
`Caller: ${detail.call.contactDisplayName || callerNumber || "Unknown caller"}.`,
|
||||
`Caller: ${callerNumber || "Unknown caller"}.`,
|
||||
answeredLabel,
|
||||
leadLabel,
|
||||
]
|
||||
|
||||
if (detail.call.contactCompany) {
|
||||
parts.push(`Company: ${detail.call.contactCompany}.`)
|
||||
}
|
||||
|
||||
if (detail.call.handoffRequested) {
|
||||
parts.push(
|
||||
`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.call.reminderStatus === "sameDay") {
|
||||
parts.push("A same-day follow-up reminder was created for Matt.")
|
||||
} else if (detail.call.reminderStatus === "scheduled") {
|
||||
parts.push(
|
||||
`A follow-up reminder was scheduled for ${formatPhoneCallTimestamp(detail.call.reminderStartAt)}.`
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.call.warmTransferStatus && detail.call.warmTransferStatus !== "none") {
|
||||
parts.push(
|
||||
`Warm transfer status: ${detail.call.warmTransferStatus}${detail.call.warmTransferFailureReason ? ` (${detail.call.warmTransferFailureReason})` : ""}.`
|
||||
)
|
||||
}
|
||||
|
||||
if (leadMessage) {
|
||||
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`)
|
||||
}
|
||||
|
|
@ -219,10 +169,8 @@ export async function sendPhoneCallSummaryEmail(args: {
|
|||
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`
|
||||
const summaryText = buildPhoneCallSummary(args.detail)
|
||||
const callerNumber =
|
||||
args.detail.call.callerPhone ||
|
||||
normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
|
||||
"Unknown caller"
|
||||
const callerLabel = args.detail.call.contactDisplayName || callerNumber
|
||||
const statusLabel = args.detail.call.callStatus.toUpperCase()
|
||||
|
||||
const transcriptHtml = args.detail.turns
|
||||
|
|
@ -241,24 +189,13 @@ export async function sendPhoneCallSummaryEmail(args: {
|
|||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.6;">
|
||||
<h1 style="font-size: 20px; margin-bottom: 16px;">Rocky Mountain Vending phone call summary</h1>
|
||||
<p><strong>Caller:</strong> ${callerLabel}</p>
|
||||
<p><strong>Caller number:</strong> ${callerNumber}</p>
|
||||
<p><strong>Company:</strong> ${args.detail.call.contactCompany || "Unknown"}</p>
|
||||
<p><strong>Caller:</strong> ${callerNumber}</p>
|
||||
<p><strong>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p>
|
||||
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
|
||||
<p><strong>Call status:</strong> ${statusLabel}</p>
|
||||
<p><strong>Jessica answered:</strong> ${args.detail.call.answered ? "Yes" : "No"}</p>
|
||||
<p><strong>Lead outcome:</strong> ${args.detail.call.leadOutcome}</p>
|
||||
<p><strong>Handoff requested:</strong> ${args.detail.call.handoffRequested ? "Yes" : "No"}</p>
|
||||
<p><strong>Reminder status:</strong> ${args.detail.call.reminderStatus || "none"}</p>
|
||||
<p><strong>Reminder time:</strong> ${formatPhoneCallTimestamp(args.detail.call.reminderStartAt)}</p>
|
||||
<p><strong>Reminder link:</strong> ${
|
||||
args.detail.call.reminderCalendarHtmlLink
|
||||
? `<a href="${args.detail.call.reminderCalendarHtmlLink}">${args.detail.call.reminderCalendarHtmlLink}</a>`
|
||||
: "No reminder link"
|
||||
}</p>
|
||||
<p><strong>Warm transfer:</strong> ${args.detail.call.warmTransferStatus || "none"}</p>
|
||||
<p><strong>Warm transfer details:</strong> ${args.detail.call.warmTransferFailureReason || "—"}</p>
|
||||
<p><strong>Recording status:</strong> ${args.detail.call.recordingStatus || "Unavailable"}</p>
|
||||
<p><strong>Recording URL:</strong> ${
|
||||
args.detail.call.recordingUrl
|
||||
|
|
@ -277,7 +214,7 @@ export async function sendPhoneCallSummaryEmail(args: {
|
|||
await sendTransactionalEmail({
|
||||
from: fromEmail,
|
||||
to: adminEmail,
|
||||
subject: `[RMV Phone] ${statusLabel} call from ${callerLabel}`,
|
||||
subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`,
|
||||
html,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
export function normalizePhoneE164(input?: string | null) {
|
||||
const digits = String(input || "").replace(/\D/g, "")
|
||||
if (!digits) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (digits.length === 10) {
|
||||
return `+1${digits}`
|
||||
}
|
||||
|
||||
if (digits.length === 11 && digits.startsWith("1")) {
|
||||
return `+${digits}`
|
||||
}
|
||||
|
||||
if (digits.length >= 11) {
|
||||
return `+${digits}`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function splitDisplayName(name?: string | null) {
|
||||
const trimmed = String(name || "").trim()
|
||||
if (!trimmed) {
|
||||
return {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
displayName: "",
|
||||
}
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/\s+/)
|
||||
const firstName = parts.shift() || ""
|
||||
const lastName = parts.join(" ")
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
displayName: [firstName, lastName].filter(Boolean).join(" "),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,4 @@
|
|||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { cookies } from "next/headers"
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs"
|
||||
import { NextResponse } from "next/server"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
|
||||
export const ADMIN_SESSION_COOKIE = "rmv_admin_session"
|
||||
const ADMIN_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7
|
||||
|
||||
function getProvidedToken(request: Request) {
|
||||
const authHeader = request.headers.get("authorization") || ""
|
||||
|
|
@ -38,123 +30,3 @@ export function requireAdminToken(request: Request) {
|
|||
export function isAdminUiEnabled() {
|
||||
return process.env.ADMIN_UI_ENABLED === "true"
|
||||
}
|
||||
|
||||
export function getConfiguredAdminEmail() {
|
||||
return String(process.env.ADMIN_EMAIL || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function getConfiguredAdminPassword() {
|
||||
return String(process.env.ADMIN_PASSWORD || "")
|
||||
}
|
||||
|
||||
function hashAdminSessionToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex")
|
||||
}
|
||||
|
||||
function readCookieFromHeader(cookieHeader: string, name: string) {
|
||||
const cookies = cookieHeader.split(";")
|
||||
for (const entry of cookies) {
|
||||
const [cookieName, ...rest] = entry.trim().split("=")
|
||||
if (cookieName === name) {
|
||||
return rest.join("=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
export function isAdminCredentialLoginConfigured() {
|
||||
return Boolean(
|
||||
isAdminUiEnabled() &&
|
||||
hasConvexUrl() &&
|
||||
getConfiguredAdminEmail() &&
|
||||
getConfiguredAdminPassword()
|
||||
)
|
||||
}
|
||||
|
||||
export function isAdminCredentialMatch(email: string, password: string) {
|
||||
return (
|
||||
email.trim().toLowerCase() === getConfiguredAdminEmail() &&
|
||||
password === getConfiguredAdminPassword()
|
||||
)
|
||||
}
|
||||
|
||||
export async function createAdminSession(email: string) {
|
||||
if (!hasConvexUrl()) {
|
||||
throw new Error("Convex is not configured for admin sessions.")
|
||||
}
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
const rawToken = randomBytes(32).toString("hex")
|
||||
const tokenHash = hashAdminSessionToken(rawToken)
|
||||
const expiresAt = Date.now() + ADMIN_SESSION_TTL_MS
|
||||
|
||||
await fetchMutation(api.admin.ensureAdminUser, {
|
||||
email: normalizedEmail,
|
||||
name: normalizedEmail.split("@")[0],
|
||||
})
|
||||
|
||||
await fetchMutation(api.admin.createSession, {
|
||||
email: normalizedEmail,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
})
|
||||
|
||||
return {
|
||||
token: rawToken,
|
||||
expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function destroyAdminSession(rawToken?: string | null) {
|
||||
if (!rawToken || !hasConvexUrl()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchMutation(api.admin.destroySession, {
|
||||
tokenHash: hashAdminSessionToken(rawToken),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to destroy admin session:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAdminSession(rawToken?: string | null) {
|
||||
if (!rawToken || !hasConvexUrl()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchQuery(api.admin.validateSession, {
|
||||
tokenHash: hashAdminSessionToken(rawToken),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to validate admin session:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdminSession(request: Request) {
|
||||
const rawToken = readCookieFromHeader(
|
||||
request.headers.get("cookie") || "",
|
||||
ADMIN_SESSION_COOKIE
|
||||
)
|
||||
const session = await validateAdminSession(rawToken || null)
|
||||
if (!session?.user) {
|
||||
return null
|
||||
}
|
||||
return session.user
|
||||
}
|
||||
|
||||
export async function getAdminUserFromCookies() {
|
||||
if (!isAdminUiEnabled()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value
|
||||
const session = await validateAdminSession(rawToken)
|
||||
return session?.user || null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,16 +143,12 @@ function getConfiguredTenantDomains() {
|
|||
}
|
||||
|
||||
function defaultDeps(): LeadSubmissionDeps {
|
||||
const ghlSyncEnabled = String(process.env.ENABLE_GHL_SYNC || "")
|
||||
.trim()
|
||||
.toLowerCase() === "true"
|
||||
|
||||
return {
|
||||
storageConfigured: isConvexConfigured(),
|
||||
emailConfigured: isEmailConfigured(),
|
||||
ghlConfigured:
|
||||
ghlSyncEnabled &&
|
||||
Boolean(process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID),
|
||||
ghlConfigured: Boolean(
|
||||
process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID
|
||||
),
|
||||
ingest: ingestLead,
|
||||
updateLeadStatus: updateLeadSyncStatus,
|
||||
sendEmail: (to, subject, html, replyTo) =>
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
type GhlSyncEnv = {
|
||||
token: string
|
||||
locationId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value?: string) {
|
||||
return (value || "https://services.leadconnectorhq.com").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export function getGhlSyncEnv(): GhlSyncEnv {
|
||||
const token = String(
|
||||
process.env.GHL_PRIVATE_INTEGRATION_TOKEN || process.env.GHL_API_TOKEN || ""
|
||||
).trim()
|
||||
const locationId = String(process.env.GHL_LOCATION_ID || "").trim()
|
||||
const baseUrl = normalizeBaseUrl(process.env.GHL_API_BASE_URL)
|
||||
|
||||
if (!token || !locationId) {
|
||||
throw new Error("GHL token or location ID is not configured.")
|
||||
}
|
||||
|
||||
return { token, locationId, baseUrl }
|
||||
}
|
||||
|
||||
async function fetchGhlJson(pathname: string, init?: RequestInit) {
|
||||
const env = getGhlSyncEnv()
|
||||
const response = await fetch(`${env.baseUrl}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.token}`,
|
||||
Version: process.env.GHL_API_VERSION || "2021-07-28",
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
let body: any = null
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GHL request failed (${response.status}) for ${pathname}: ${body?.message || text || "Unknown error"}`
|
||||
)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
export async function fetchGhlContacts(args?: {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
}) {
|
||||
const env = getGhlSyncEnv()
|
||||
const searchParams = new URLSearchParams({
|
||||
locationId: env.locationId,
|
||||
limit: String(Math.min(100, Math.max(1, args?.limit || 100))),
|
||||
})
|
||||
if (args?.cursor) {
|
||||
searchParams.set("startAfterId", args.cursor)
|
||||
}
|
||||
|
||||
const payload = await fetchGhlJson(`/contacts/?${searchParams.toString()}`)
|
||||
const contacts = Array.isArray(payload?.contacts)
|
||||
? payload.contacts
|
||||
: Array.isArray(payload?.data?.contacts)
|
||||
? payload.data.contacts
|
||||
: []
|
||||
|
||||
const nextCursor =
|
||||
contacts.length > 0 ? String(contacts[contacts.length - 1]?.id || "") : ""
|
||||
|
||||
return {
|
||||
items: contacts,
|
||||
nextCursor: nextCursor || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGhlMessages(args?: {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
channel?: "Call" | "SMS"
|
||||
}) {
|
||||
const env = getGhlSyncEnv()
|
||||
const url = new URL(`${env.baseUrl}/conversations/messages/export`)
|
||||
url.searchParams.set("locationId", env.locationId)
|
||||
url.searchParams.set("limit", String(Math.min(100, Math.max(1, args?.limit || 100))))
|
||||
url.searchParams.set("channel", args?.channel || "SMS")
|
||||
if (args?.cursor) {
|
||||
url.searchParams.set("cursor", args.cursor)
|
||||
}
|
||||
|
||||
const payload = await fetchGhlJson(url.pathname + url.search)
|
||||
return {
|
||||
items: Array.isArray(payload?.messages) ? payload.messages : [],
|
||||
nextCursor:
|
||||
typeof payload?.nextCursor === "string" && payload.nextCursor
|
||||
? payload.nextCursor
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGhlCallLogs(args?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const env = getGhlSyncEnv()
|
||||
const url = new URL(`${env.baseUrl}/voice-ai/dashboard/call-logs`)
|
||||
url.searchParams.set("locationId", env.locationId)
|
||||
url.searchParams.set("page", String(Math.max(1, args?.page || 1)))
|
||||
url.searchParams.set(
|
||||
"pageSize",
|
||||
String(Math.min(50, Math.max(1, args?.pageSize || 50)))
|
||||
)
|
||||
|
||||
const payload = await fetchGhlJson(url.pathname + url.search)
|
||||
return {
|
||||
items: Array.isArray(payload?.callLogs) ? payload.callLogs : [],
|
||||
page: Number(payload?.page || args?.page || 1),
|
||||
total: Number(payload?.total || 0),
|
||||
pageSize: Number(payload?.pageSize || args?.pageSize || 50),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import {
|
||||
rankListingsForQuery,
|
||||
sortListingsByFreshness,
|
||||
type CachedEbayListing,
|
||||
} from "@/lib/ebay-parts-match"
|
||||
|
||||
export type ManualPartRow = {
|
||||
partNumber: string
|
||||
description: string
|
||||
ebayListings?: CachedEbayListing[]
|
||||
}
|
||||
|
||||
type ManualPartsLookup = Record<string, ManualPartRow[]>
|
||||
type ManualPagesPartsLookup = Record<string, Record<string, ManualPartRow[]>>
|
||||
|
||||
let manualPartsCache: ManualPartsLookup | null = null
|
||||
let manualPagesPartsCache: ManualPagesPartsLookup | null = null
|
||||
let staticEbayListingsCache: CachedEbayListing[] | null = null
|
||||
|
||||
async function readJsonFile<T>(filename: string): Promise<T> {
|
||||
const filePath = path.join(process.cwd(), "public", filename)
|
||||
const contents = await readFile(filePath, "utf8")
|
||||
return JSON.parse(contents) as T
|
||||
}
|
||||
|
||||
export async function loadManualPartsLookup(): Promise<ManualPartsLookup> {
|
||||
if (!manualPartsCache) {
|
||||
manualPartsCache = await readJsonFile<ManualPartsLookup>(
|
||||
"manual_parts_lookup.json"
|
||||
)
|
||||
}
|
||||
|
||||
return manualPartsCache
|
||||
}
|
||||
|
||||
export async function loadManualPagesPartsLookup(): Promise<ManualPagesPartsLookup> {
|
||||
if (!manualPagesPartsCache) {
|
||||
manualPagesPartsCache = await readJsonFile<ManualPagesPartsLookup>(
|
||||
"manual_pages_parts.json"
|
||||
)
|
||||
}
|
||||
|
||||
return manualPagesPartsCache
|
||||
}
|
||||
|
||||
export async function findManualParts(
|
||||
manualFilename: string
|
||||
): Promise<ManualPartRow[]> {
|
||||
const manualParts = await loadManualPartsLookup()
|
||||
if (manualFilename in manualParts) {
|
||||
return manualParts[manualFilename]
|
||||
}
|
||||
|
||||
const lowerFilename = manualFilename.toLowerCase()
|
||||
for (const [filename, parts] of Object.entries(manualParts)) {
|
||||
if (filename.toLowerCase() === lowerFilename) {
|
||||
return parts
|
||||
}
|
||||
}
|
||||
|
||||
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "")
|
||||
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
|
||||
|
||||
for (const [filename, parts] of Object.entries(manualParts)) {
|
||||
const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase()
|
||||
if (
|
||||
otherWithoutExt === lowerWithoutExt ||
|
||||
otherWithoutExt.includes(lowerWithoutExt) ||
|
||||
lowerWithoutExt.includes(otherWithoutExt)
|
||||
) {
|
||||
return parts
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export async function findManualPageParts(
|
||||
manualFilename: string,
|
||||
pageNumber: number
|
||||
): Promise<ManualPartRow[]> {
|
||||
const manualPagesParts = await loadManualPagesPartsLookup()
|
||||
if (
|
||||
manualPagesParts[manualFilename] &&
|
||||
manualPagesParts[manualFilename][pageNumber.toString()]
|
||||
) {
|
||||
return manualPagesParts[manualFilename][pageNumber.toString()]
|
||||
}
|
||||
|
||||
const lowerFilename = manualFilename.toLowerCase()
|
||||
for (const [filename, pages] of Object.entries(manualPagesParts)) {
|
||||
if (
|
||||
filename.toLowerCase() === lowerFilename &&
|
||||
pages[pageNumber.toString()]
|
||||
) {
|
||||
return pages[pageNumber.toString()]
|
||||
}
|
||||
}
|
||||
|
||||
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "")
|
||||
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
|
||||
|
||||
for (const [filename, pages] of Object.entries(manualPagesParts)) {
|
||||
const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase()
|
||||
if (
|
||||
otherWithoutExt === lowerWithoutExt ||
|
||||
otherWithoutExt.includes(lowerWithoutExt) ||
|
||||
lowerWithoutExt.includes(otherWithoutExt)
|
||||
) {
|
||||
if (pages[pageNumber.toString()]) {
|
||||
return pages[pageNumber.toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export async function listManualsWithParts(): Promise<Set<string>> {
|
||||
const manualParts = await loadManualPartsLookup()
|
||||
const manualsWithParts = new Set<string>()
|
||||
|
||||
for (const [filename, parts] of Object.entries(manualParts)) {
|
||||
if (parts.length > 0) {
|
||||
manualsWithParts.add(filename)
|
||||
manualsWithParts.add(filename.toLowerCase())
|
||||
manualsWithParts.add(filename.replace(/\.pdf$/i, ""))
|
||||
manualsWithParts.add(filename.replace(/\.pdf$/i, "").toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return manualsWithParts
|
||||
}
|
||||
|
||||
function dedupeListings(listings: CachedEbayListing[]): CachedEbayListing[] {
|
||||
const byItemId = new Map<string, CachedEbayListing>()
|
||||
|
||||
for (const listing of listings) {
|
||||
const itemId = listing.itemId?.trim()
|
||||
if (!itemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = byItemId.get(itemId)
|
||||
if (!existing) {
|
||||
byItemId.set(itemId, listing)
|
||||
continue
|
||||
}
|
||||
|
||||
const existingFreshness = existing.lastSeenAt ?? existing.fetchedAt ?? 0
|
||||
const nextFreshness = listing.lastSeenAt ?? listing.fetchedAt ?? 0
|
||||
if (nextFreshness >= existingFreshness) {
|
||||
byItemId.set(itemId, {
|
||||
...existing,
|
||||
...listing,
|
||||
sourceQueries: Array.from(
|
||||
new Set([...(existing.sourceQueries || []), ...(listing.sourceQueries || [])])
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sortListingsByFreshness(Array.from(byItemId.values()))
|
||||
}
|
||||
|
||||
export async function loadStaticEbayListings(): Promise<CachedEbayListing[]> {
|
||||
if (staticEbayListingsCache) {
|
||||
return staticEbayListingsCache
|
||||
}
|
||||
|
||||
const [manualParts, manualPagesParts] = await Promise.all([
|
||||
loadManualPartsLookup(),
|
||||
loadManualPagesPartsLookup(),
|
||||
])
|
||||
|
||||
const listings: CachedEbayListing[] = []
|
||||
|
||||
for (const parts of Object.values(manualParts)) {
|
||||
for (const part of parts) {
|
||||
if (Array.isArray(part.ebayListings)) {
|
||||
listings.push(...part.ebayListings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pages of Object.values(manualPagesParts)) {
|
||||
for (const parts of Object.values(pages)) {
|
||||
for (const part of parts) {
|
||||
if (Array.isArray(part.ebayListings)) {
|
||||
listings.push(...part.ebayListings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
staticEbayListingsCache = dedupeListings(listings)
|
||||
return staticEbayListingsCache
|
||||
}
|
||||
|
||||
export async function searchStaticEbayListings(
|
||||
query: string,
|
||||
limit = 6
|
||||
): Promise<CachedEbayListing[]> {
|
||||
const listings = await loadStaticEbayListings()
|
||||
return rankListingsForQuery(query, listings, limit)
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import {
|
||||
getManualsQdrantCorpus,
|
||||
searchManualsQdrantCorpus,
|
||||
} from "@/lib/manuals-qdrant-corpus"
|
||||
|
||||
export async function searchServiceKnowledge(args: {
|
||||
query: string
|
||||
limit?: number
|
||||
}) {
|
||||
const corpus = await getManualsQdrantCorpus()
|
||||
const results = searchManualsQdrantCorpus(corpus, args.query, {
|
||||
limit: args.limit ?? 4,
|
||||
profile: "public_safe",
|
||||
})
|
||||
|
||||
return results.map((result) => ({
|
||||
score: result.score,
|
||||
title: result.chunk.title,
|
||||
manufacturer: result.chunk.manufacturer,
|
||||
model: result.chunk.model,
|
||||
sourceKind: result.chunk.sourceKind,
|
||||
manualType: result.chunk.manualType,
|
||||
sourceFilename: result.chunk.sourceFilename,
|
||||
manualUrl: result.chunk.manualUrl,
|
||||
snippet: result.chunk.text.slice(0, 600).trim(),
|
||||
}))
|
||||
}
|
||||
10
package.json
10
package.json
|
|
@ -7,19 +7,12 @@
|
|||
"scripts": {
|
||||
"build": "next build",
|
||||
"copy:check": "node scripts/check-public-copy.mjs",
|
||||
"diagnose:ebay": "node scripts/staging-smoke.mjs",
|
||||
"deploy:staging:convex-gate": "node scripts/check-convex-manuals-gate.mjs",
|
||||
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
|
||||
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build && pnpm deploy:staging:convex-gate",
|
||||
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
|
||||
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||
"manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts",
|
||||
"manuals:qdrant:eval": "tsx scripts/evaluate-manuals-qdrant-corpus.ts",
|
||||
"manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
|
||||
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
|
||||
"manuals:backfill:tenant": "tsx scripts/backfill-convex-manuals-tenant.ts",
|
||||
"ghl:export:call-transcripts": "tsx scripts/export-ghl-call-transcripts.ts",
|
||||
"ghl:export:outbound-call-transcripts": "tsx scripts/export-ghl-outbound-call-transcripts.ts",
|
||||
"convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint .",
|
||||
|
|
@ -37,7 +30,6 @@
|
|||
"lighthouse:ci": "lighthouse-ci autorun",
|
||||
"analyze": "ANALYZE=true next build",
|
||||
"generate:links": "node scripts/generate-internal-links.js",
|
||||
"contacts:import:ghl": "tsx scripts/import-ghl-contacts-to-contact-profiles.ts",
|
||||
"links": "node scripts/generate-internal-links.js",
|
||||
"mcp": "pnpm dlx shadcn@latest mcp",
|
||||
"seo:sitemap": "node scripts/seo-internal-link-tool.js sitemap",
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import { config as loadEnv } from "dotenv"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { resolveManualsTenantDomain } from "../lib/manuals-tenant"
|
||||
|
||||
loadEnv({ path: ".env.local" })
|
||||
loadEnv({ path: ".env.staging", override: false })
|
||||
|
||||
const BACKFILL_MUTATION = makeFunctionReference<"mutation">(
|
||||
"manuals:backfillTenantVisibility"
|
||||
)
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const domainFlagIndex = argv.indexOf("--domain")
|
||||
const domain =
|
||||
domainFlagIndex >= 0 ? (argv[domainFlagIndex + 1] || "").trim() : ""
|
||||
|
||||
return {
|
||||
domain,
|
||||
dryRun: argv.includes("--dry-run"),
|
||||
}
|
||||
}
|
||||
|
||||
function readConvexUrl() {
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_CONVEX_URL (or CONVEX_URL) is required for Convex backfill."
|
||||
)
|
||||
}
|
||||
|
||||
return convexUrl
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const domain = resolveManualsTenantDomain({
|
||||
envTenantDomain: args.domain || process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
||||
})
|
||||
|
||||
if (!domain) {
|
||||
throw new Error(
|
||||
"Could not resolve tenant domain. Pass --domain or set MANUALS_TENANT_DOMAIN / NEXT_PUBLIC_SITE_DOMAIN."
|
||||
)
|
||||
}
|
||||
|
||||
const convex = new ConvexHttpClient(readConvexUrl())
|
||||
const result = await convex.mutation(BACKFILL_MUTATION, {
|
||||
domain,
|
||||
dryRun: args.dryRun,
|
||||
})
|
||||
|
||||
console.log("[manuals-tenant-backfill] result", result)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[manuals-tenant-backfill] failed", error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import process from "node:process"
|
||||
import dotenv from "dotenv"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
|
||||
const LIST_MANUALS = makeFunctionReference("manuals:list")
|
||||
|
||||
function canonicalizeDomain(input) {
|
||||
return String(input || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/\.$/, "")
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const domainIndex = argv.indexOf("--domain")
|
||||
const domain = domainIndex >= 0 ? argv[domainIndex + 1] : ""
|
||||
const minCountIndex = argv.indexOf("--min-count")
|
||||
const minCount =
|
||||
minCountIndex >= 0
|
||||
? Number.parseInt(argv[minCountIndex + 1] || "", 10)
|
||||
: 1
|
||||
|
||||
return {
|
||||
domain,
|
||||
minCount: Number.isFinite(minCount) && minCount > 0 ? minCount : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function readConvexUrl() {
|
||||
const value =
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL || ""
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
dotenv.config({ path: ".env.local", override: false })
|
||||
dotenv.config({ path: ".env.staging", override: false })
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const domain = canonicalizeDomain(
|
||||
args.domain ||
|
||||
process.env.MANUALS_TENANT_DOMAIN ||
|
||||
process.env.NEXT_PUBLIC_SITE_DOMAIN
|
||||
)
|
||||
const convexUrl = readConvexUrl()
|
||||
|
||||
if (!convexUrl) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_CONVEX_URL (or CONVEX_URL) is required for Convex manuals gate."
|
||||
)
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
throw new Error(
|
||||
"A tenant domain is required. Set NEXT_PUBLIC_SITE_DOMAIN / MANUALS_TENANT_DOMAIN or pass --domain."
|
||||
)
|
||||
}
|
||||
|
||||
const convex = new ConvexHttpClient(convexUrl)
|
||||
const manuals = await convex.query(LIST_MANUALS, { domain })
|
||||
const count = Array.isArray(manuals) ? manuals.length : 0
|
||||
|
||||
console.log(
|
||||
`[convex-manuals-gate] domain=${domain} count=${count} min=${args.minCount}`
|
||||
)
|
||||
|
||||
if (count < args.minCount) {
|
||||
throw new Error(
|
||||
`Convex manuals gate failed for ${domain}: expected at least ${args.minCount} manuals, got ${count}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[convex-manuals-gate] failed", error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -112,35 +112,6 @@ function readValue(name) {
|
|||
return String(process.env[name] ?? "").trim()
|
||||
}
|
||||
|
||||
function canonicalizeDomain(input) {
|
||||
return String(input || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/\.$/, "")
|
||||
}
|
||||
|
||||
function isValidHttpUrl(value) {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === "https:" || url.protocol === "http:"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isValidHostname(value) {
|
||||
const host = canonicalizeDomain(value)
|
||||
return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host)
|
||||
}
|
||||
|
||||
function hasVoiceRecordingConfig() {
|
||||
return [
|
||||
readValue("VOICE_RECORDING_ACCESS_KEY_ID") ||
|
||||
|
|
@ -263,21 +234,6 @@ function main() {
|
|||
console.log(`${group.label}: fallback in use`)
|
||||
}
|
||||
|
||||
const convexUrl = readValue("NEXT_PUBLIC_CONVEX_URL")
|
||||
if (!isValidHttpUrl(convexUrl)) {
|
||||
failures.push(
|
||||
"NEXT_PUBLIC_CONVEX_URL is malformed. It must be a full http(s) URL."
|
||||
)
|
||||
}
|
||||
|
||||
const siteDomain =
|
||||
readValue("MANUALS_TENANT_DOMAIN") || readValue("NEXT_PUBLIC_SITE_DOMAIN")
|
||||
if (!isValidHostname(siteDomain)) {
|
||||
failures.push(
|
||||
"NEXT_PUBLIC_SITE_DOMAIN (or MANUALS_TENANT_DOMAIN) is malformed. It must be a valid hostname."
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasManualStorageCredentials()) {
|
||||
failures.push(
|
||||
"Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release."
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
import { readFile } from "node:fs/promises"
|
||||
import { resolve } from "node:path"
|
||||
import { config as loadEnv } from "dotenv"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
||||
loadEnv({ path: ".env.local" })
|
||||
|
||||
type ImportRecord = {
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
company?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
function normalizePhone(value?: string | null) {
|
||||
const digits = String(value || "").replace(/\D/g, "")
|
||||
if (!digits) {
|
||||
return ""
|
||||
}
|
||||
if (digits.length === 10) {
|
||||
return `+1${digits}`
|
||||
}
|
||||
if (digits.length === 11 && digits.startsWith("1")) {
|
||||
return `+${digits}`
|
||||
}
|
||||
if (digits.length >= 11) {
|
||||
return `+${digits}`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function splitCsvLine(line: string) {
|
||||
const values: string[] = []
|
||||
let current = ""
|
||||
let inQuotes = false
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index]
|
||||
const next = line[index + 1]
|
||||
|
||||
if (char === '"' && inQuotes && next === '"') {
|
||||
current += '"'
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "," && !inQuotes) {
|
||||
values.push(current.trim())
|
||||
current = ""
|
||||
continue
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
values.push(current.trim())
|
||||
return values
|
||||
}
|
||||
|
||||
function parseCsv(content: string): ImportRecord[] {
|
||||
const lines = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (lines.length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
const headers = splitCsvLine(lines[0]).map((header) => header.toLowerCase())
|
||||
return lines.slice(1).map((line) => {
|
||||
const values = splitCsvLine(line)
|
||||
const record: Record<string, string> = {}
|
||||
headers.forEach((header, index) => {
|
||||
record[header] = values[index] || ""
|
||||
})
|
||||
|
||||
return {
|
||||
firstName: record.firstname || record["first name"] || record.first_name,
|
||||
lastName: record.lastname || record["last name"] || record.last_name,
|
||||
name: record.name || record.fullname || record["full name"],
|
||||
email: record.email || record["email address"],
|
||||
phone: record.phone || record["phone number"] || record.mobile,
|
||||
company: record.company || record["company name"],
|
||||
notes: record.notes || record.note,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseJson(content: string): ImportRecord[] {
|
||||
const value = JSON.parse(content)
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("JSON import file must contain an array of contacts.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function loadRecords(pathname: string) {
|
||||
const absolutePath = resolve(pathname)
|
||||
const content = await readFile(absolutePath, "utf8")
|
||||
if (absolutePath.endsWith(".json")) {
|
||||
return parseJson(content)
|
||||
}
|
||||
return parseCsv(content)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputPath = process.argv[2]
|
||||
if (!inputPath) {
|
||||
throw new Error("Usage: tsx scripts/import-ghl-contacts-to-contact-profiles.ts <contacts.json|contacts.csv>")
|
||||
}
|
||||
|
||||
const records = await loadRecords(inputPath)
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const record of records) {
|
||||
const normalizedPhone = normalizePhone(record.phone)
|
||||
if (!normalizedPhone) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const displayName =
|
||||
record.name?.trim() ||
|
||||
[record.firstName, record.lastName].filter(Boolean).join(" ").trim()
|
||||
|
||||
await fetchMutation(api.contactProfiles.upsertByPhone, {
|
||||
normalizedPhone,
|
||||
displayName: displayName || undefined,
|
||||
firstName: record.firstName?.trim() || undefined,
|
||||
lastName: record.lastName?.trim() || undefined,
|
||||
email: record.email?.trim() || undefined,
|
||||
company: record.company?.trim() || undefined,
|
||||
reminderNotes: record.notes?.trim() || undefined,
|
||||
source: "ghl-import",
|
||||
})
|
||||
imported += 1
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
imported,
|
||||
skipped,
|
||||
total: records.length,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -1,582 +0,0 @@
|
|||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import process from "node:process"
|
||||
import dotenv from "dotenv"
|
||||
|
||||
const DEFAULT_BASE_URL = "http://127.0.0.1:3000"
|
||||
const DEFAULT_MANUAL_CARD_TEXT = "653-655-657-hot-drink-center-parts-manual"
|
||||
const DEFAULT_MANUAL_FILENAME = "653-655-657-hot-drink-center-parts-manual.pdf"
|
||||
const DEFAULT_PART_NUMBER = "CABINET"
|
||||
const DEFAULT_PART_DESCRIPTION = "- CABINET ASSEMBLY (SEE FIGURES 27, 28, 29) -"
|
||||
|
||||
function loadEnvFile() {
|
||||
const envPath = path.resolve(process.cwd(), ".env.local")
|
||||
if (existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath, override: false })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
baseUrl: process.env.BASE_URL || DEFAULT_BASE_URL,
|
||||
manualCardText: DEFAULT_MANUAL_CARD_TEXT,
|
||||
manualFilename: DEFAULT_MANUAL_FILENAME,
|
||||
partNumber: DEFAULT_PART_NUMBER,
|
||||
partDescription: DEFAULT_PART_DESCRIPTION,
|
||||
adminToken: process.env.ADMIN_API_TOKEN || "",
|
||||
skipBrowser: process.env.SMOKE_SKIP_BROWSER === "1",
|
||||
}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index]
|
||||
|
||||
if (token === "--base-url") {
|
||||
args.baseUrl = argv[index + 1] || args.baseUrl
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (token === "--manual-card-text") {
|
||||
args.manualCardText = argv[index + 1] || args.manualCardText
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (token === "--manual-filename") {
|
||||
args.manualFilename = argv[index + 1] || args.manualFilename
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (token === "--part-number") {
|
||||
args.partNumber = argv[index + 1] || args.partNumber
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (token === "--part-description") {
|
||||
args.partDescription = argv[index + 1] || args.partDescription
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (token === "--admin-token") {
|
||||
args.adminToken = argv[index + 1] || args.adminToken
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (token === "--skip-browser") {
|
||||
args.skipBrowser = true
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return value.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function canonicalizeDomain(input) {
|
||||
return String(input || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/\.$/, "")
|
||||
}
|
||||
|
||||
function envPresence(name) {
|
||||
return Boolean(String(process.env[name] ?? "").trim())
|
||||
}
|
||||
|
||||
function heading(title) {
|
||||
console.log(`\n== ${title} ==`)
|
||||
}
|
||||
|
||||
function report(name, state, detail = "") {
|
||||
const suffix = detail ? ` — ${detail}` : ""
|
||||
console.log(`${name}: ${state}${suffix}`)
|
||||
}
|
||||
|
||||
function summarizeCache(cache) {
|
||||
if (!cache || typeof cache !== "object") {
|
||||
return "no cache payload"
|
||||
}
|
||||
|
||||
const status = String(cache.status ?? "unknown")
|
||||
const listingCount = Number(cache.listingCount ?? cache.itemCount ?? 0)
|
||||
const activeCount = Number(cache.activeListingCount ?? 0)
|
||||
const lastError = typeof cache.lastError === "string" ? cache.lastError : ""
|
||||
const freshnessMs =
|
||||
typeof cache.freshnessMs === "number" ? `${cache.freshnessMs}ms` : "n/a"
|
||||
|
||||
return [
|
||||
`status=${status}`,
|
||||
`listings=${listingCount}`,
|
||||
`active=${activeCount}`,
|
||||
`freshness=${freshnessMs}`,
|
||||
lastError ? `lastError=${lastError}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
function parseUrl(value) {
|
||||
try {
|
||||
return new URL(String(value || ""))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function listingLooksSynthetic(listing) {
|
||||
const itemId = String(listing?.itemId || "").trim()
|
||||
const viewItemUrl = String(listing?.viewItemUrl || "").trim()
|
||||
const imageUrl = String(listing?.imageUrl || "").trim()
|
||||
|
||||
if (!itemId || itemId.startsWith("123456789")) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (viewItemUrl.includes("123456789")) {
|
||||
return true
|
||||
}
|
||||
|
||||
const parsedImageUrl = parseUrl(imageUrl)
|
||||
const imageHost = parsedImageUrl?.hostname?.toLowerCase?.() || ""
|
||||
if (
|
||||
imageHost.includes("images.unsplash.com") ||
|
||||
imageHost.includes("via.placeholder.com") ||
|
||||
imageHost.includes("placehold.co")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function listingHasAffiliateCampaign(listing) {
|
||||
const parsed = parseUrl(listing?.affiliateLink || "")
|
||||
if (!parsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(parsed.searchParams.get("campid"))
|
||||
}
|
||||
|
||||
function hasFallbackCacheMessage(cache) {
|
||||
const message = typeof cache?.message === "string" ? cache.message.toLowerCase() : ""
|
||||
return (
|
||||
message.includes("bundled manual cache") ||
|
||||
message.includes("cached listings failed")
|
||||
)
|
||||
}
|
||||
|
||||
async function requestJson(url, init) {
|
||||
const response = await fetch(url, {
|
||||
redirect: "follow",
|
||||
...init,
|
||||
})
|
||||
const text = await response.text()
|
||||
|
||||
let body = null
|
||||
if (text.trim()) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
}
|
||||
|
||||
return { response, body, text }
|
||||
}
|
||||
|
||||
async function checkPages(baseUrl, failures) {
|
||||
heading("Public Pages")
|
||||
const pages = ["/", "/contact-us", "/products", "/manuals"]
|
||||
|
||||
for (const pagePath of pages) {
|
||||
const { response } = await requestJson(`${baseUrl}${pagePath}`)
|
||||
const ok = response.status === 200
|
||||
report(pagePath, ok ? "ok" : "fail", `status=${response.status}`)
|
||||
if (!ok) {
|
||||
failures.push(`GET ${pagePath} returned ${response.status}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkManualsPayload(baseUrl, failures) {
|
||||
heading("Manuals Payload")
|
||||
const { response, text } = await requestJson(`${baseUrl}/manuals`)
|
||||
const ok = response.status === 200
|
||||
|
||||
report("GET /manuals payload", ok ? "ok" : "fail", `status=${response.status}`)
|
||||
if (!ok) {
|
||||
failures.push(`GET /manuals returned ${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (text.includes("Manuals Library Temporarily Unavailable")) {
|
||||
failures.push("Manuals page is in degraded mode.")
|
||||
}
|
||||
|
||||
if (text.includes('"initialManuals":[]')) {
|
||||
failures.push("Manuals page rendered with zero initial manuals.")
|
||||
}
|
||||
|
||||
const expectedHost = canonicalizeDomain(new URL(baseUrl).host)
|
||||
const domainMatch = text.match(/data-manuals-domain=\"([^\"]+)\"/)
|
||||
const runtimeDomain = canonicalizeDomain(domainMatch?.[1] || "")
|
||||
|
||||
console.log(` expectedDomain: ${expectedHost}`)
|
||||
console.log(` runtimeDomain: ${runtimeDomain || "missing"}`)
|
||||
|
||||
if (!runtimeDomain) {
|
||||
failures.push("Manuals page is missing runtime tenant domain marker.")
|
||||
} else if (runtimeDomain !== expectedHost) {
|
||||
failures.push(
|
||||
`Manuals runtime domain mismatch. Expected ${expectedHost}, got ${runtimeDomain}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEbaySearch(baseUrl, failures) {
|
||||
heading("eBay Cache Search")
|
||||
const url = new URL(`${baseUrl}/api/ebay/search`)
|
||||
url.searchParams.set("keywords", "vending machine part")
|
||||
url.searchParams.set("maxResults", "3")
|
||||
url.searchParams.set("sortOrder", "BestMatch")
|
||||
|
||||
const { response, body, text } = await requestJson(url)
|
||||
const ok = response.status === 200
|
||||
const cache = body?.cache
|
||||
const cacheStatus = cache?.status ?? "unknown"
|
||||
|
||||
report("GET /api/ebay/search", ok ? "ok" : "fail", `status=${response.status}`)
|
||||
if (!ok) {
|
||||
failures.push(`GET /api/ebay/search returned ${response.status}`)
|
||||
console.log(text)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(` cache: ${summarizeCache(cache)}`)
|
||||
console.log(` results: ${Array.isArray(body?.results) ? body.results.length : 0}`)
|
||||
|
||||
const results = Array.isArray(body?.results) ? body.results : []
|
||||
const trustedResults = results.filter((listing) => !listingLooksSynthetic(listing))
|
||||
|
||||
if (hasFallbackCacheMessage(cache)) {
|
||||
failures.push("eBay search is serving fallback cache data instead of Convex cache.")
|
||||
}
|
||||
|
||||
if (cacheStatus === "success" && Number(cache?.listingCount ?? cache?.itemCount ?? 0) === 0) {
|
||||
failures.push(
|
||||
"eBay search returned status=success but cache has zero listings; backend cache is not healthy."
|
||||
)
|
||||
}
|
||||
|
||||
if (results.some((listing) => listingLooksSynthetic(listing))) {
|
||||
failures.push("eBay search returned synthetic placeholder listings.")
|
||||
}
|
||||
|
||||
if (trustedResults.length === 0) {
|
||||
failures.push("eBay search did not return any trusted listings.")
|
||||
}
|
||||
|
||||
if (trustedResults.length > 0 && !trustedResults.some(listingHasAffiliateCampaign)) {
|
||||
failures.push("eBay search trusted listings are missing affiliate campaign tracking.")
|
||||
}
|
||||
}
|
||||
|
||||
async function checkManualParts(baseUrl, failures, manualFilename, partNumber, partDescription) {
|
||||
heading("Manual Parts Match")
|
||||
const { response, body, text } = await requestJson(`${baseUrl}/api/ebay/manual-parts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
manualFilename,
|
||||
limit: 3,
|
||||
parts: [
|
||||
{
|
||||
partNumber,
|
||||
description: partDescription,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const ok = response.status === 200
|
||||
const cache = body?.cache
|
||||
const cacheStatus = cache?.status ?? "unknown"
|
||||
|
||||
report("POST /api/ebay/manual-parts", ok ? "ok" : "fail", `status=${response.status}`)
|
||||
if (!ok) {
|
||||
failures.push(`POST /api/ebay/manual-parts returned ${response.status}`)
|
||||
console.log(text)
|
||||
return
|
||||
}
|
||||
|
||||
const parts = Array.isArray(body?.parts) ? body.parts : []
|
||||
const firstCount = Array.isArray(parts[0]?.ebayListings) ? parts[0].ebayListings.length : 0
|
||||
console.log(` cache: ${summarizeCache(cache)}`)
|
||||
console.log(` matched parts: ${parts.length}`)
|
||||
console.log(` first part listings: ${firstCount}`)
|
||||
|
||||
const allListings = parts.flatMap((part) =>
|
||||
Array.isArray(part?.ebayListings) ? part.ebayListings : []
|
||||
)
|
||||
const trustedListings = allListings.filter((listing) => !listingLooksSynthetic(listing))
|
||||
|
||||
if (hasFallbackCacheMessage(cache)) {
|
||||
failures.push("Manual parts route is serving fallback cache data instead of Convex cache.")
|
||||
}
|
||||
|
||||
if (cacheStatus === "success" && Number(cache?.listingCount ?? cache?.itemCount ?? 0) === 0) {
|
||||
failures.push(
|
||||
"Manual parts route returned status=success but cache has zero listings; backend cache is not healthy."
|
||||
)
|
||||
}
|
||||
|
||||
if (allListings.some((listing) => listingLooksSynthetic(listing))) {
|
||||
failures.push("Manual parts route returned synthetic placeholder listings.")
|
||||
}
|
||||
|
||||
if (trustedListings.length === 0) {
|
||||
failures.push("Manual parts route did not return any trusted listings for the smoke manual.")
|
||||
}
|
||||
|
||||
if (trustedListings.length > 0 && !trustedListings.some(listingHasAffiliateCampaign)) {
|
||||
failures.push("Manual parts trusted listings are missing affiliate campaign tracking.")
|
||||
}
|
||||
|
||||
if (!body?.manualFilename || body.manualFilename !== manualFilename) {
|
||||
failures.push("Manual parts response did not echo the requested manualFilename.")
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNotifications(baseUrl, failures) {
|
||||
heading("eBay Notification Challenge")
|
||||
const url = new URL(`${baseUrl}/api/ebay/notifications`)
|
||||
url.searchParams.set("challenge_code", "diagnostic-test")
|
||||
|
||||
const { response, body, text } = await requestJson(url)
|
||||
const ok = response.status === 200
|
||||
report("GET /api/ebay/notifications", ok ? "ok" : "fail", `status=${response.status}`)
|
||||
|
||||
if (!ok) {
|
||||
failures.push(`GET /api/ebay/notifications returned ${response.status}`)
|
||||
console.log(text)
|
||||
return
|
||||
}
|
||||
|
||||
const challengeResponse = typeof body?.challengeResponse === "string"
|
||||
? body.challengeResponse
|
||||
: ""
|
||||
console.log(` challengeResponse: ${challengeResponse ? "present" : "missing"}`)
|
||||
|
||||
if (!challengeResponse || challengeResponse.length < 32) {
|
||||
failures.push("Notification challenge response is missing or malformed.")
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAdminRefresh(baseUrl, failures, adminToken) {
|
||||
heading("Admin Refresh")
|
||||
if (!adminToken) {
|
||||
report("POST /api/admin/ebay/refresh", "skipped", "no admin token provided")
|
||||
return
|
||||
}
|
||||
|
||||
const { response, body, text } = await requestJson(
|
||||
`${baseUrl}/api/admin/ebay/refresh`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-admin-token": adminToken,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const ok = response.status >= 200 && response.status < 300
|
||||
report("POST /api/admin/ebay/refresh", ok ? "ok" : "fail", `status=${response.status}`)
|
||||
if (!ok) {
|
||||
failures.push(`POST /api/admin/ebay/refresh returned ${response.status}`)
|
||||
console.log(text)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
` result: ${body && typeof body === "object" ? JSON.stringify(body) : text || "empty"}`
|
||||
)
|
||||
}
|
||||
|
||||
async function checkBrowser(baseUrl, manualCardText, failures) {
|
||||
heading("Browser UI")
|
||||
if (process.env.SMOKE_SKIP_BROWSER === "1") {
|
||||
report("Browser smoke", "skipped", "SMOKE_SKIP_BROWSER=1")
|
||||
return
|
||||
}
|
||||
|
||||
let chromium
|
||||
try {
|
||||
chromium = (await import("playwright")).chromium
|
||||
} catch (error) {
|
||||
report(
|
||||
"Browser smoke",
|
||||
"skipped",
|
||||
error instanceof Error ? error.message : "playwright unavailable"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let browser
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true })
|
||||
} catch (error) {
|
||||
report(
|
||||
"Browser smoke",
|
||||
"skipped",
|
||||
error instanceof Error ? error.message : "browser launch failed"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
const consoleErrors = []
|
||||
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
const text = message.text()
|
||||
if (!text.includes("Failed to load resource") && !text.includes("404")) {
|
||||
consoleErrors.push(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto(`${baseUrl}/manuals`, { waitUntil: "domcontentloaded" })
|
||||
await page.waitForTimeout(1200)
|
||||
const titleVisible = await page
|
||||
.getByRole("heading", { name: "Vending Machine Manuals" })
|
||||
.isVisible()
|
||||
if (!titleVisible) {
|
||||
failures.push("Manuals page title was not visible in the browser smoke test.")
|
||||
report("Manuals page", "fail", "title not visible")
|
||||
return
|
||||
}
|
||||
|
||||
const openButton = page.getByRole("button", { name: "View PDF" }).first()
|
||||
await openButton.click({ force: true })
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
const viewerOpen = await page.getByText("Parts").first().isVisible().catch(() => false)
|
||||
const viewerFallback =
|
||||
(await page
|
||||
.getByText("No parts data extracted for this manual yet")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)) ||
|
||||
(await page
|
||||
.getByText("No cached eBay matches yet")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
|
||||
if (!viewerOpen && !viewerFallback) {
|
||||
failures.push("Manual viewer did not open or did not show a parts/cache state.")
|
||||
report("Manual viewer", "fail", "no parts state visible")
|
||||
} else {
|
||||
report("Manual viewer", "ok", viewerFallback ? "fallback state visible" : "viewer open")
|
||||
}
|
||||
|
||||
if (consoleErrors.length > 0) {
|
||||
failures.push(
|
||||
`Browser smoke saw console errors: ${consoleErrors.slice(0, 3).join(" | ")}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`Browser smoke failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
report("Browser smoke", "fail", error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
await browser?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadEnvFile()
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const baseUrl = normalizeBaseUrl(args.baseUrl)
|
||||
const failures = []
|
||||
|
||||
heading("Environment")
|
||||
report(
|
||||
"NEXT_PUBLIC_CONVEX_URL",
|
||||
envPresence("NEXT_PUBLIC_CONVEX_URL") ? "present" : "missing"
|
||||
)
|
||||
report("CONVEX_URL", envPresence("CONVEX_URL") ? "present" : "missing")
|
||||
report("EBAY_APP_ID", envPresence("EBAY_APP_ID") ? "present" : "missing")
|
||||
report(
|
||||
"EBAY_AFFILIATE_CAMPAIGN_ID",
|
||||
envPresence("EBAY_AFFILIATE_CAMPAIGN_ID") ? "present" : "missing"
|
||||
)
|
||||
report(
|
||||
"EBAY_NOTIFICATION_ENDPOINT",
|
||||
envPresence("EBAY_NOTIFICATION_ENDPOINT") ? "present" : "missing"
|
||||
)
|
||||
report(
|
||||
"EBAY_NOTIFICATION_VERIFICATION_TOKEN",
|
||||
envPresence("EBAY_NOTIFICATION_VERIFICATION_TOKEN") ? "present" : "missing"
|
||||
)
|
||||
report(
|
||||
"EBAY_NOTIFICATION_APP_ID",
|
||||
envPresence("EBAY_NOTIFICATION_APP_ID") ? "present" : "missing"
|
||||
)
|
||||
report(
|
||||
"EBAY_NOTIFICATION_CERT_ID",
|
||||
envPresence("EBAY_NOTIFICATION_CERT_ID") ? "present" : "missing"
|
||||
)
|
||||
report("Base URL", baseUrl)
|
||||
|
||||
await checkPages(baseUrl, failures)
|
||||
await checkManualsPayload(baseUrl, failures)
|
||||
await checkEbaySearch(baseUrl, failures)
|
||||
await checkManualParts(
|
||||
baseUrl,
|
||||
failures,
|
||||
args.manualFilename,
|
||||
args.partNumber,
|
||||
args.partDescription
|
||||
)
|
||||
await checkNotifications(baseUrl, failures)
|
||||
await checkAdminRefresh(baseUrl, failures, args.adminToken)
|
||||
if (args.skipBrowser) {
|
||||
heading("Browser UI")
|
||||
report("Browser smoke", "skipped", "--skip-browser was provided")
|
||||
} else {
|
||||
await checkBrowser(baseUrl, args.manualCardText, failures)
|
||||
}
|
||||
|
||||
heading("Summary")
|
||||
if (failures.length > 0) {
|
||||
console.log("Failures:")
|
||||
for (const failure of failures) {
|
||||
console.log(`- ${failure}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log("All smoke checks passed.")
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -12,11 +12,6 @@ import {
|
|||
import { businessConfig } from "../lib/seo-config"
|
||||
import { getSiteDomain } from "../lib/site-config"
|
||||
import { selectManualsForSite } from "../lib/manuals-site-selection"
|
||||
import {
|
||||
canonicalizeTenantDomain,
|
||||
resolveManualsTenantDomain,
|
||||
tenantDomainVariants,
|
||||
} from "../lib/manuals-tenant"
|
||||
import {
|
||||
getManualsFilesRoot,
|
||||
getManualsThumbnailsRoot,
|
||||
|
|
@ -78,15 +73,8 @@ function readConvexUrl() {
|
|||
}
|
||||
|
||||
function canonicalSiteVisibility(siteDomain: string) {
|
||||
const canonicalHost = canonicalizeTenantDomain(
|
||||
new URL(businessConfig.website).hostname
|
||||
)
|
||||
return Array.from(
|
||||
new Set([
|
||||
...tenantDomainVariants(siteDomain),
|
||||
...tenantDomainVariants(canonicalHost),
|
||||
])
|
||||
)
|
||||
const canonicalHost = new URL(businessConfig.website).hostname
|
||||
return Array.from(new Set([siteDomain, canonicalHost]))
|
||||
}
|
||||
|
||||
async function uploadSelectedAssets(
|
||||
|
|
@ -181,17 +169,7 @@ function normalizeManualForConvex(
|
|||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const siteDomain = resolveManualsTenantDomain({
|
||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: getSiteDomain(),
|
||||
})
|
||||
|
||||
if (!siteDomain) {
|
||||
throw new Error(
|
||||
"Could not resolve manuals tenant domain. Set MANUALS_TENANT_DOMAIN or NEXT_PUBLIC_SITE_DOMAIN."
|
||||
)
|
||||
}
|
||||
|
||||
const siteDomain = getSiteDomain()
|
||||
const allManuals = await scanManuals()
|
||||
const selection = selectManualsForSite(allManuals, siteDomain)
|
||||
const selectedManuals = args.limit
|
||||
|
|
|
|||
Loading…
Reference in a new issue