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
|
# Direct phone-call visibility
|
||||||
PHONE_AGENT_INTERNAL_TOKEN=
|
PHONE_AGENT_INTERNAL_TOKEN=
|
||||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||||
ENABLE_GHL_SYNC=false
|
|
||||||
GOOGLE_CALENDAR_CLIENT_ID=
|
|
||||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
|
||||||
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
|
||||||
GOOGLE_CALENDAR_ID=
|
|
||||||
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
|
||||||
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
|
||||||
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
|
||||||
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
|
||||||
|
|
||||||
# Placeholder for a later LiveKit rollout
|
# Placeholder for a later LiveKit rollout
|
||||||
LIVEKIT_URL=
|
LIVEKIT_URL=
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,6 @@ ADMIN_EMAIL=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||||
ENABLE_GHL_SYNC=false
|
|
||||||
GOOGLE_CALENDAR_CLIENT_ID=
|
|
||||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
|
||||||
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
|
||||||
GOOGLE_CALENDAR_ID=
|
|
||||||
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
|
||||||
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
|
||||||
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
|
||||||
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,10 @@ import { FAQSection } from "@/components/faq-section"
|
||||||
import { ContactPage } from "@/components/contact-page"
|
import { ContactPage } from "@/components/contact-page"
|
||||||
import { AboutPage } from "@/components/about-page"
|
import { AboutPage } from "@/components/about-page"
|
||||||
import { WhoWeServePage } from "@/components/who-we-serve-page"
|
import { WhoWeServePage } from "@/components/who-we-serve-page"
|
||||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
|
||||||
import {
|
|
||||||
PublicInset,
|
|
||||||
PublicPageHeader,
|
|
||||||
PublicProse,
|
|
||||||
PublicSurface,
|
|
||||||
} from "@/components/public-surface"
|
|
||||||
import {
|
import {
|
||||||
generateLocationPageMetadata,
|
generateLocationPageMetadata,
|
||||||
LocationLandingPage,
|
LocationLandingPage,
|
||||||
} from "@/components/location-landing-page"
|
} from "@/components/location-landing-page"
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
// Required for static export - ensures this route is statically generated
|
// Required for static export - ensures this route is statically generated
|
||||||
export const dynamic = "force-static"
|
export const dynamic = "force-static"
|
||||||
|
|
@ -56,7 +48,6 @@ const routeMapping: Record<string, string> = {
|
||||||
|
|
||||||
// Food & Beverage
|
// Food & Beverage
|
||||||
"food-and-beverage/healthy-options": "healthy-vending",
|
"food-and-beverage/healthy-options": "healthy-vending",
|
||||||
"food-and-beverage/snack-and-drink-delivery": "snack-and-drink-delivery",
|
|
||||||
"food-and-beverage/traditional-options": "traditional-vending",
|
"food-and-beverage/traditional-options": "traditional-vending",
|
||||||
"food-and-beverage/suppliers":
|
"food-and-beverage/suppliers":
|
||||||
"diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts",
|
"diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts",
|
||||||
|
|
@ -357,7 +348,6 @@ export default async function WordPressPage({ params }: PageProps) {
|
||||||
"vending-machines-for-your-car-wash",
|
"vending-machines-for-your-car-wash",
|
||||||
]
|
]
|
||||||
const isWhoWeServePage = whoWeServeSlugs.includes(pageSlug)
|
const isWhoWeServePage = whoWeServeSlugs.includes(pageSlug)
|
||||||
const routePath = `/${slugArray.join("/")}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -387,58 +377,13 @@ export default async function WordPressPage({ params }: PageProps) {
|
||||||
pageSlug !== "contact-us" &&
|
pageSlug !== "contact-us" &&
|
||||||
pageSlug !== "about-us" &&
|
pageSlug !== "about-us" &&
|
||||||
!isWhoWeServePage && (
|
!isWhoWeServePage && (
|
||||||
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
|
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||||
<Breadcrumbs
|
<header className="mb-8">
|
||||||
className="mb-6"
|
<h1 className="text-4xl md:text-5xl font-bold mb-6">
|
||||||
items={[
|
{page.title || "Page"}
|
||||||
{ label: "Home", href: "/" },
|
</h1>
|
||||||
{ label: page.title || "Page", href: routePath },
|
</header>
|
||||||
]}
|
{content}
|
||||||
/>
|
|
||||||
<PublicPageHeader
|
|
||||||
eyebrow={pageSlug.startsWith("blog") ? "Article" : "Information"}
|
|
||||||
title={page.title || "Page"}
|
|
||||||
description={
|
|
||||||
page.seoDescription ||
|
|
||||||
page.excerpt ||
|
|
||||||
"Explore the details, service guidance, and next steps from Rocky Mountain Vending."
|
|
||||||
}
|
|
||||||
className="mx-auto mb-10 max-w-3xl text-center"
|
|
||||||
align="center"
|
|
||||||
/>
|
|
||||||
<PublicSurface className="p-5 md:p-7 lg:p-9">
|
|
||||||
<PublicProse className="mx-auto max-w-3xl">{content}</PublicProse>
|
|
||||||
</PublicSurface>
|
|
||||||
<PublicInset className="mx-auto mt-8 max-w-4xl border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.84))] p-5 md:p-6">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
|
||||||
Need Help Choosing The Right Next Step?
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
|
|
||||||
Talk with Rocky Mountain Vending
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
|
||||||
Reach out about placement, machine sales, repairs, moving help,
|
|
||||||
manuals, or parts and we'll point you in the right direction.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
|
||||||
<Link
|
|
||||||
href="/contact-us#contact-form"
|
|
||||||
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Talk to Our Team
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/#request-machine"
|
|
||||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
|
||||||
>
|
|
||||||
See If You Qualify
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PublicInset>
|
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,7 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
Phone Call Detail
|
Phone Call Detail
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{detail.call.contactDisplayName ||
|
{normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
|
||||||
detail.call.participantIdentity}
|
detail.call.participantIdentity}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,22 +98,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
{detail.call.participantIdentity || "Unknown"}
|
{detail.call.participantIdentity || "Unknown"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Caller Phone
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{detail.call.callerPhone ||
|
|
||||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
|
||||||
"Unknown"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Company
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">{detail.call.contactCompany || "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
Call Status
|
Call Status
|
||||||
|
|
@ -152,22 +135,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium">{detail.call.notificationStatus}</p>
|
<p className="font-medium">{detail.call.notificationStatus}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Reminder
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{detail.call.reminderStatus || "none"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Warm Transfer
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{detail.call.warmTransferStatus || "none"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
Summary
|
Summary
|
||||||
|
|
@ -190,26 +157,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
||||||
</div>
|
</div>
|
||||||
{detail.call.reminderStartAt ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Reminder Time
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{formatPhoneCallTimestamp(detail.call.reminderStartAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{detail.call.warmTransferFailureReason ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Transfer Detail
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{detail.call.warmTransferFailureReason}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{detail.call.recordingUrl ? (
|
{detail.call.recordingUrl ? (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -232,18 +179,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{detail.call.reminderCalendarHtmlLink ? (
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<Link
|
|
||||||
href={detail.call.reminderCalendarHtmlLink}
|
|
||||||
target="_blank"
|
|
||||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Open reminder
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -302,23 +237,6 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
lead.
|
lead.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{detail.contactProfile ? (
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Contact Profile
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{detail.contactProfile.displayName ||
|
|
||||||
[detail.contactProfile.firstName, detail.contactProfile.lastName]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ") ||
|
|
||||||
"Known caller"}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{detail.contactProfile.company || detail.contactProfile.email || "No company or email yet"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[1240px] text-sm">
|
<table className="w-full min-w-[1050px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left text-muted-foreground">
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
<th className="py-3 pr-4 font-medium">Caller</th>
|
<th className="py-3 pr-4 font-medium">Caller</th>
|
||||||
|
|
@ -116,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">Recording</th>
|
||||||
<th className="py-3 pr-4 font-medium">Lead</th>
|
<th className="py-3 pr-4 font-medium">Lead</th>
|
||||||
<th className="py-3 pr-4 font-medium">Email</th>
|
<th className="py-3 pr-4 font-medium">Email</th>
|
||||||
<th className="py-3 pr-4 font-medium">Reminder</th>
|
|
||||||
<th className="py-3 pr-4 font-medium">Transfer</th>
|
|
||||||
<th className="py-3 pr-4 font-medium">Summary</th>
|
<th className="py-3 pr-4 font-medium">Summary</th>
|
||||||
<th className="py-3 font-medium">Open</th>
|
<th className="py-3 font-medium">Open</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -126,7 +124,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={13}
|
colSpan={11}
|
||||||
className="py-8 text-center text-muted-foreground"
|
className="py-8 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
No phone calls matched this filter.
|
No phone calls matched this filter.
|
||||||
|
|
@ -140,18 +138,12 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
>
|
>
|
||||||
<td className="py-3 pr-4 font-medium">
|
<td className="py-3 pr-4 font-medium">
|
||||||
<div>
|
<div>
|
||||||
{call.contactDisplayName ||
|
{normalizePhoneFromIdentity(
|
||||||
normalizePhoneFromIdentity(
|
|
||||||
call.participantIdentity
|
call.participantIdentity
|
||||||
) ||
|
) || call.participantIdentity}
|
||||||
call.participantIdentity}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{call.contactCompany ||
|
{call.roomName}
|
||||||
normalizePhoneFromIdentity(
|
|
||||||
call.participantIdentity
|
|
||||||
) ||
|
|
||||||
call.roomName}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4">
|
<td className="py-3 pr-4">
|
||||||
|
|
@ -180,16 +172,6 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
|
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4">{call.notificationStatus}</td>
|
<td className="py-3 pr-4">{call.notificationStatus}</td>
|
||||||
<td className="py-3 pr-4">
|
|
||||||
{call.reminderStatus === "none"
|
|
||||||
? "—"
|
|
||||||
: call.reminderStatus}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pr-4">
|
|
||||||
{call.warmTransferStatus === "none"
|
|
||||||
? "—"
|
|
||||||
: call.warmTransferStatus}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
|
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
|
||||||
<span className="line-clamp-2">
|
<span className="line-clamp-2">
|
||||||
{call.summaryText || "No summary yet"}
|
{call.summaryText || "No summary yet"}
|
||||||
|
|
|
||||||
|
|
@ -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 { redirect } from "next/navigation"
|
||||||
import {
|
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
|
||||||
getAdminUserFromCookies,
|
|
||||||
isAdminUiEnabled,
|
|
||||||
} from "@/lib/server/admin-auth"
|
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -14,29 +10,5 @@ export default async function AdminLayout({
|
||||||
redirect("/")
|
redirect("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminUser = await getAdminUserFromCookies()
|
return <>{children}</>
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { fetchQuery } from "convex/nextjs"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { api } from "@/convex/_generated/api"
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -23,8 +21,6 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Phone,
|
Phone,
|
||||||
MessageSquare,
|
|
||||||
ContactRound,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { fetchAllProducts } from "@/lib/stripe/products"
|
import { fetchAllProducts } from "@/lib/stripe/products"
|
||||||
|
|
||||||
|
|
@ -60,25 +56,10 @@ async function getOrdersCount() {
|
||||||
return mockAnalytics.totalOrders
|
return mockAnalytics.totalOrders
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(value?: number | null) {
|
|
||||||
if (!value) {
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(value).toLocaleString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminDashboard() {
|
export default async function AdminDashboard() {
|
||||||
const [productsCount, ordersCount, sync] = await Promise.all([
|
const [productsCount, ordersCount] = await Promise.all([
|
||||||
getProductsCount(),
|
getProductsCount(),
|
||||||
getOrdersCount(),
|
getOrdersCount(),
|
||||||
fetchQuery(api.crm.getAdminSyncOverview, {}),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const dashboardCards = [
|
const dashboardCards = [
|
||||||
|
|
@ -211,22 +192,10 @@ export default async function AdminDashboard() {
|
||||||
Admin Dashboard
|
Admin Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
Manage orders, contacts, conversations, and calls
|
Overview of your store performance and management tools
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href="/admin/contacts">
|
|
||||||
<Button variant="outline">
|
|
||||||
<ContactRound className="h-4 w-4 mr-2" />
|
|
||||||
Contacts
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/conversations">
|
|
||||||
<Button variant="outline">
|
|
||||||
<MessageSquare className="h-4 w-4 mr-2" />
|
|
||||||
Conversations
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/calls">
|
<Link href="/admin/calls">
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Phone className="h-4 w-4 mr-2" />
|
<Phone className="h-4 w-4 mr-2" />
|
||||||
|
|
@ -243,25 +212,6 @@ export default async function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>CRM Sync Status</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{!sync.ghlConfigured
|
|
||||||
? "Connect GHL to load contacts and conversations."
|
|
||||||
: "Customer data is mirrored here from GHL and your call flows."}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
|
||||||
<Badge variant="outline">{sync.overallStatus}</Badge>
|
|
||||||
<span>Last sync: {formatTimestamp(sync.latestSyncAt)}</span>
|
|
||||||
{!sync.ghlConfigured ? <span>GHL is not connected.</span> : null}
|
|
||||||
{!sync.livekitConfigured ? (
|
|
||||||
<span>LiveKit recordings are not connected yet.</span>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Main Stats */}
|
{/* Main Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{dashboardCards.map((card, index) => {
|
{dashboardCards.map((card, index) => {
|
||||||
|
|
|
||||||
|
|
@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { fetchQuery } from "convex/nextjs"
|
|
||||||
import { api } from "@/convex/_generated/api"
|
|
||||||
import { hasConvexUrl } from "@/lib/convex-config"
|
|
||||||
import {
|
|
||||||
filterTrustedEbayListings,
|
|
||||||
rankListingsForQuery,
|
|
||||||
type CachedEbayListing,
|
|
||||||
type EbayCacheState,
|
|
||||||
} from "@/lib/ebay-parts-match"
|
|
||||||
|
|
||||||
type CacheSource = "convex" | "fallback"
|
/**
|
||||||
|
* eBay API Proxy Route
|
||||||
|
* Proxies requests to eBay Finding API to avoid CORS issues
|
||||||
|
*/
|
||||||
|
|
||||||
function getDisabledCacheState(message: string): EbayCacheState {
|
interface eBaySearchParams {
|
||||||
return {
|
keywords: string
|
||||||
key: "manual-parts",
|
categoryId?: string
|
||||||
status: "disabled",
|
sortOrder?: string
|
||||||
lastSuccessfulAt: null,
|
maxResults?: number
|
||||||
lastAttemptAt: null,
|
}
|
||||||
nextEligibleAt: null,
|
|
||||||
lastError: message,
|
interface eBaySearchResult {
|
||||||
consecutiveFailures: 0,
|
itemId: string
|
||||||
queryCount: 0,
|
title: string
|
||||||
itemCount: 0,
|
price: string
|
||||||
sourceQueries: [],
|
currency: string
|
||||||
freshnessMs: null,
|
imageUrl?: string
|
||||||
isStale: true,
|
viewItemUrl: string
|
||||||
listingCount: 0,
|
condition?: string
|
||||||
activeListingCount: 0,
|
shippingCost?: string
|
||||||
message,
|
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 {
|
function first<T>(value: MaybeArray<T> | undefined): T | undefined {
|
||||||
const now = Date.now()
|
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 {
|
return {
|
||||||
key: "manual-parts",
|
itemId: item.itemId || "",
|
||||||
status: "error",
|
title: item.title || "Unknown Item",
|
||||||
lastSuccessfulAt: null,
|
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
|
||||||
lastAttemptAt: now,
|
currency: currentPrice?.currencyId || "USD",
|
||||||
nextEligibleAt: null,
|
imageUrl: first(item.galleryURL) || undefined,
|
||||||
lastError: message,
|
viewItemUrl,
|
||||||
consecutiveFailures: 1,
|
condition: condition?.conditionDisplayName || undefined,
|
||||||
queryCount: 0,
|
shippingCost: shippingCost?.value
|
||||||
itemCount: 0,
|
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
|
||||||
sourceQueries: [],
|
: undefined,
|
||||||
freshnessMs: null,
|
affiliateLink: generateAffiliateLink(viewItemUrl),
|
||||||
isStale: true,
|
|
||||||
listingCount: 0,
|
|
||||||
activeListingCount: 0,
|
|
||||||
message,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { searchParams } = new URL(request.url)
|
||||||
const keywords = searchParams.get("keywords")?.trim() || ""
|
|
||||||
const maxResults = Math.min(
|
const keywords = searchParams.get("keywords")
|
||||||
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
|
const categoryId = searchParams.get("categoryId") || undefined
|
||||||
20
|
const sortOrder = searchParams.get("sortOrder") || "BestMatch"
|
||||||
|
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
|
||||||
|
const cacheKey = buildCacheKey(
|
||||||
|
keywords || "",
|
||||||
|
categoryId,
|
||||||
|
sortOrder,
|
||||||
|
maxResults
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!keywords) {
|
if (!keywords) {
|
||||||
|
|
@ -67,54 +171,114 @@ export async function GET(request: Request) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasConvexUrl()) {
|
const appId = process.env.EBAY_APP_ID?.trim()
|
||||||
const message =
|
|
||||||
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
|
if (!appId) {
|
||||||
return NextResponse.json({
|
console.error("EBAY_APP_ID not configured")
|
||||||
query: keywords,
|
return NextResponse.json(
|
||||||
results: [],
|
{
|
||||||
cache: getDisabledCacheState(message),
|
error:
|
||||||
cacheSource: "fallback" satisfies CacheSource,
|
"eBay API not configured. Please set EBAY_APP_ID environment variable.",
|
||||||
error: message,
|
},
|
||||||
})
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedResults = getCachedSearchResults(cacheKey)
|
||||||
|
if (cachedResults) {
|
||||||
|
return NextResponse.json(cachedResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inFlight = inFlightSearchResponses.get(cacheKey)
|
||||||
|
if (inFlight) {
|
||||||
|
try {
|
||||||
|
const results = await inFlight
|
||||||
|
return NextResponse.json(results)
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to fetch products from eBay",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build eBay Finding API URL
|
||||||
|
const baseUrl = "https://svcs.ebay.com/services/search/FindingService/v1"
|
||||||
|
const url = new URL(baseUrl)
|
||||||
|
|
||||||
|
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
|
||||||
|
url.searchParams.set("SERVICE-VERSION", "1.0.0")
|
||||||
|
url.searchParams.set("SECURITY-APPNAME", appId)
|
||||||
|
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
|
||||||
|
url.searchParams.set("REST-PAYLOAD", "true")
|
||||||
|
url.searchParams.set("keywords", keywords)
|
||||||
|
url.searchParams.set("sortOrder", sortOrder)
|
||||||
|
url.searchParams.set("paginationInput.entriesPerPage", maxResults.toString())
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
url.searchParams.set("categoryId", categoryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [overview, listings] = await Promise.all([
|
const request = (async () => {
|
||||||
fetchQuery(api.ebay.getCacheOverview, {}),
|
const response = await fetch(url.toString(), {
|
||||||
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
|
method: "GET",
|
||||||
])
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
const trustedListings = filterTrustedEbayListings(
|
},
|
||||||
listings as CachedEbayListing[]
|
|
||||||
)
|
|
||||||
const ranked = rankListingsForQuery(
|
|
||||||
keywords,
|
|
||||||
trustedListings,
|
|
||||||
maxResults
|
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
query: keywords,
|
|
||||||
results: ranked,
|
|
||||||
cache: overview,
|
|
||||||
cacheSource: "convex" satisfies CacheSource,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = await readEbayErrorMessage(response)
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to load cached eBay listings:", error)
|
console.error("Error fetching from eBay API:", error)
|
||||||
const message =
|
|
||||||
error instanceof Error
|
|
||||||
? `Cached eBay listings are unavailable: ${error.message}`
|
|
||||||
: "Cached eBay listings are unavailable."
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
query: keywords,
|
error:
|
||||||
results: [],
|
error instanceof Error
|
||||||
cache: getErrorCacheState(message),
|
? error.message
|
||||||
cacheSource: "fallback" satisfies CacheSource,
|
: "Failed to fetch products from eBay",
|
||||||
error: message,
|
|
||||||
},
|
},
|
||||||
{ 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, {
|
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
||||||
sessionId: body.sessionId,
|
sessionId: body.sessionId,
|
||||||
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
|
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
|
||||||
contactProfileId: body.contactProfileId || undefined,
|
|
||||||
contactDisplayName: body.contactDisplayName
|
|
||||||
? String(body.contactDisplayName)
|
|
||||||
: undefined,
|
|
||||||
contactCompany: body.contactCompany
|
|
||||||
? String(body.contactCompany)
|
|
||||||
: undefined,
|
|
||||||
leadOutcome: body.leadOutcome || "none",
|
leadOutcome: body.leadOutcome || "none",
|
||||||
handoffRequested:
|
handoffRequested:
|
||||||
typeof body.handoffRequested === "boolean"
|
typeof body.handoffRequested === "boolean"
|
||||||
|
|
@ -29,41 +22,6 @@ export async function POST(request: Request) {
|
||||||
handoffReason: body.handoffReason
|
handoffReason: body.handoffReason
|
||||||
? String(body.handoffReason)
|
? String(body.handoffReason)
|
||||||
: undefined,
|
: undefined,
|
||||||
reminderStatus: body.reminderStatus || undefined,
|
|
||||||
reminderRequestedAt:
|
|
||||||
typeof body.reminderRequestedAt === "number"
|
|
||||||
? body.reminderRequestedAt
|
|
||||||
: undefined,
|
|
||||||
reminderStartAt:
|
|
||||||
typeof body.reminderStartAt === "number"
|
|
||||||
? body.reminderStartAt
|
|
||||||
: undefined,
|
|
||||||
reminderEndAt:
|
|
||||||
typeof body.reminderEndAt === "number"
|
|
||||||
? body.reminderEndAt
|
|
||||||
: undefined,
|
|
||||||
reminderCalendarEventId: body.reminderCalendarEventId
|
|
||||||
? String(body.reminderCalendarEventId)
|
|
||||||
: undefined,
|
|
||||||
reminderCalendarHtmlLink: body.reminderCalendarHtmlLink
|
|
||||||
? String(body.reminderCalendarHtmlLink)
|
|
||||||
: undefined,
|
|
||||||
reminderNote: body.reminderNote ? String(body.reminderNote) : undefined,
|
|
||||||
warmTransferStatus: body.warmTransferStatus || undefined,
|
|
||||||
warmTransferTarget: body.warmTransferTarget
|
|
||||||
? String(body.warmTransferTarget)
|
|
||||||
: undefined,
|
|
||||||
warmTransferAttemptedAt:
|
|
||||||
typeof body.warmTransferAttemptedAt === "number"
|
|
||||||
? body.warmTransferAttemptedAt
|
|
||||||
: undefined,
|
|
||||||
warmTransferConnectedAt:
|
|
||||||
typeof body.warmTransferConnectedAt === "number"
|
|
||||||
? body.warmTransferConnectedAt
|
|
||||||
: undefined,
|
|
||||||
warmTransferFailureReason: body.warmTransferFailureReason
|
|
||||||
? String(body.warmTransferFailureReason)
|
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true, call: result })
|
return NextResponse.json({ success: true, call: result })
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { fetchMutation, fetchQuery } from "convex/nextjs"
|
import { fetchMutation } from "convex/nextjs"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||||
import { normalizePhoneE164 } from "@/lib/phone-normalization"
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requirePhoneAgentInternalAuth(request)
|
const authError = await requirePhoneAgentInternalAuth(request)
|
||||||
|
|
@ -12,44 +11,16 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
let metadata: Record<string, unknown> = {}
|
|
||||||
if (typeof body.metadata === "string" && body.metadata.trim()) {
|
|
||||||
try {
|
|
||||||
metadata = JSON.parse(body.metadata)
|
|
||||||
} catch {
|
|
||||||
metadata = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const callerPhone = normalizePhoneE164(
|
|
||||||
metadata.participantPhone || body.participantIdentity
|
|
||||||
)
|
|
||||||
const contactContext = callerPhone
|
|
||||||
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
|
||||||
normalizedPhone: callerPhone,
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
|
|
||||||
const result = await fetchMutation(
|
const result = await fetchMutation(
|
||||||
api.voiceSessions.upsertPhoneCallSession,
|
api.voiceSessions.upsertPhoneCallSession,
|
||||||
{
|
{
|
||||||
roomName: String(body.roomName || ""),
|
roomName: String(body.roomName || ""),
|
||||||
participantIdentity: String(body.participantIdentity || ""),
|
participantIdentity: String(body.participantIdentity || ""),
|
||||||
callerPhone: callerPhone || undefined,
|
|
||||||
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
||||||
pathname: body.pathname ? String(body.pathname) : undefined,
|
pathname: body.pathname ? String(body.pathname) : undefined,
|
||||||
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
||||||
source: "phone-agent",
|
source: "phone-agent",
|
||||||
metadata: body.metadata ? String(body.metadata) : undefined,
|
metadata: body.metadata ? String(body.metadata) : undefined,
|
||||||
contactProfileId: contactContext?.contactProfile?.id,
|
|
||||||
contactDisplayName:
|
|
||||||
contactContext?.contactProfile?.displayName ||
|
|
||||||
(contactContext?.recentLead
|
|
||||||
? `${contactContext.recentLead.firstName} ${contactContext.recentLead.lastName}`.trim()
|
|
||||||
: undefined),
|
|
||||||
contactCompany:
|
|
||||||
contactContext?.contactProfile?.company ||
|
|
||||||
contactContext?.recentLead?.company ||
|
|
||||||
undefined,
|
|
||||||
startedAt:
|
startedAt:
|
||||||
typeof body.startedAt === "number" ? body.startedAt : undefined,
|
typeof body.startedAt === "number" ? body.startedAt : undefined,
|
||||||
recordingDisclosureAt:
|
recordingDisclosureAt:
|
||||||
|
|
@ -64,10 +35,6 @@ export async function POST(request: Request) {
|
||||||
success: true,
|
success: true,
|
||||||
sessionId: result?._id,
|
sessionId: result?._id,
|
||||||
roomName: result?.roomName,
|
roomName: result?.roomName,
|
||||||
callerPhone,
|
|
||||||
contactProfile: contactContext?.contactProfile || null,
|
|
||||||
recentLead: contactContext?.recentLead || null,
|
|
||||||
recentSession: contactContext?.recentSession || null,
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start phone call sync:", error)
|
console.error("Failed to start phone call sync:", error)
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,7 @@ import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||||
import {
|
|
||||||
PublicInset,
|
|
||||||
PublicPageHeader,
|
|
||||||
PublicProse,
|
|
||||||
PublicSurface,
|
|
||||||
} from "@/components/public-surface"
|
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
const WORDPRESS_SLUG = "abandoned-vending-machines"
|
const WORDPRESS_SLUG = "abandoned-vending-machines"
|
||||||
|
|
||||||
|
|
@ -88,9 +81,7 @@ export default async function AbandonedVendingMachinesPage() {
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
/>
|
/>
|
||||||
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
|
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
className="mb-6"
|
|
||||||
items={[
|
items={[
|
||||||
{ label: "Blog", href: "/blog" },
|
{ label: "Blog", href: "/blog" },
|
||||||
{
|
{
|
||||||
|
|
@ -99,50 +90,8 @@ export default async function AbandonedVendingMachinesPage() {
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PublicPageHeader
|
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||||
eyebrow="Article"
|
{content}
|
||||||
title={page.title || "Abandoned Vending Machines"}
|
|
||||||
description={
|
|
||||||
page.seoDescription ||
|
|
||||||
page.excerpt ||
|
|
||||||
"Guidance, next steps, and practical considerations from Rocky Mountain Vending."
|
|
||||||
}
|
|
||||||
align="center"
|
|
||||||
className="mx-auto mb-10 max-w-3xl"
|
|
||||||
/>
|
|
||||||
<PublicSurface className="p-5 md:p-7 lg:p-9">
|
|
||||||
<PublicProse className="mx-auto max-w-3xl">{content}</PublicProse>
|
|
||||||
</PublicSurface>
|
|
||||||
<PublicInset className="mx-auto mt-8 max-w-4xl border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.84))] p-5 md:p-6">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
|
||||||
Need Help With A Machine Situation?
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
|
|
||||||
Get the right kind of support quickly
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
|
||||||
Reach out if you need help with abandoned machines, service questions,
|
|
||||||
moving help, or figuring out the right next step for your location.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
|
||||||
<Link
|
|
||||||
href="/contact-us#contact-form"
|
|
||||||
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Talk to Our Team
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services/repairs"
|
|
||||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
|
||||||
>
|
|
||||||
Explore Repair Help
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PublicInset>
|
|
||||||
</article>
|
</article>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -183,8 +183,7 @@
|
||||||
transition:
|
transition:
|
||||||
color 0.2s ease,
|
color 0.2s ease,
|
||||||
text-decoration-color 0.2s ease,
|
text-decoration-color 0.2s ease,
|
||||||
opacity 0.2s ease,
|
opacity 0.2s ease;
|
||||||
transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover,
|
a:hover,
|
||||||
|
|
@ -203,8 +202,7 @@
|
||||||
transition:
|
transition:
|
||||||
color 0.2s ease,
|
color 0.2s ease,
|
||||||
text-decoration-color 0.2s ease,
|
text-decoration-color 0.2s ease,
|
||||||
opacity 0.2s ease,
|
opacity 0.2s ease;
|
||||||
transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a[href]:hover,
|
a[href]:hover,
|
||||||
|
|
@ -213,29 +211,6 @@
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
button a,
|
|
||||||
[role="button"] a,
|
|
||||||
.bg-primary a,
|
|
||||||
.text-primary-foreground a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button a:hover,
|
|
||||||
button a:focus,
|
|
||||||
[role="button"] a:hover,
|
|
||||||
[role="button"] a:focus,
|
|
||||||
.bg-primary a:hover,
|
|
||||||
.bg-primary a:focus,
|
|
||||||
.text-primary-foreground a:hover,
|
|
||||||
.text-primary-foreground a:focus {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
li {
|
|
||||||
text-wrap: pretty;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:focus-visible,
|
a:focus-visible,
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
[role="button"]:focus-visible,
|
[role="button"]:focus-visible,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
import { createHash } from "node:crypto"
|
import { existsSync } from "fs"
|
||||||
import { existsSync } from "node:fs"
|
import { join } from "path"
|
||||||
import { Metadata } from "next"
|
import { Metadata } from "next"
|
||||||
import { headers } from "next/headers"
|
|
||||||
import { businessConfig } from "@/lib/seo-config"
|
import { businessConfig } from "@/lib/seo-config"
|
||||||
import { ManualsPageExperience } from "@/components/manuals-page-experience"
|
import { ManualsPageExperience } from "@/components/manuals-page-experience"
|
||||||
import { listConvexManuals } from "@/lib/convex-service"
|
import { listConvexManuals } from "@/lib/convex-service"
|
||||||
|
|
@ -11,8 +10,6 @@ import { scanManuals } from "@/lib/manuals"
|
||||||
import { selectManualsForSite } from "@/lib/manuals-site-selection"
|
import { selectManualsForSite } from "@/lib/manuals-site-selection"
|
||||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||||
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
|
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
|
||||||
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
|
||||||
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
|
|
||||||
|
|
||||||
export const metadata: Metadata = generateSEOMetadata({
|
export const metadata: Metadata = generateSEOMetadata({
|
||||||
title: "Vending Machine Manuals | Rocky Mountain Vending",
|
title: "Vending Machine Manuals | Rocky Mountain Vending",
|
||||||
|
|
@ -34,59 +31,29 @@ export const metadata: Metadata = generateSEOMetadata({
|
||||||
})
|
})
|
||||||
|
|
||||||
export default async function ManualsPage() {
|
export default async function ManualsPage() {
|
||||||
const requestHeaders = await headers()
|
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
|
||||||
const requestHost =
|
const convexManuals = await listConvexManuals()
|
||||||
requestHeaders.get("x-forwarded-host") || requestHeaders.get("host")
|
|
||||||
const manualsDomain = resolveManualsTenantDomain({
|
|
||||||
requestHost,
|
|
||||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
|
||||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
|
||||||
})
|
|
||||||
|
|
||||||
const convexManuals = manualsDomain
|
|
||||||
? await listConvexManuals(manualsDomain)
|
|
||||||
: []
|
|
||||||
|
|
||||||
const isLocalDevelopment = process.env.NODE_ENV === "development"
|
|
||||||
const shouldUseFilesystemFallback = isLocalDevelopment
|
|
||||||
|
|
||||||
const allManuals =
|
const allManuals =
|
||||||
convexManuals.length > 0 || !shouldUseFilesystemFallback
|
convexManuals.length > 0 ? convexManuals : await scanManuals()
|
||||||
? convexManuals
|
|
||||||
: await scanManuals()
|
|
||||||
let manuals =
|
let manuals =
|
||||||
convexManuals.length > 0
|
convexManuals.length > 0
|
||||||
? convexManuals
|
? convexManuals
|
||||||
: shouldUseFilesystemFallback
|
: selectManualsForSite(allManuals).manuals
|
||||||
? selectManualsForSite(allManuals, manualsDomain).manuals
|
|
||||||
: []
|
|
||||||
|
|
||||||
const shouldShowDegradedState =
|
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
|
||||||
!shouldUseFilesystemFallback && manuals.length === 0
|
const thumbnailsRoot = getManualsThumbnailsRoot()
|
||||||
|
manuals = manuals.map((manual) => {
|
||||||
if (shouldShowDegradedState) {
|
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
|
||||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || ""
|
return manual
|
||||||
const convexUrlHash = convexUrl
|
|
||||||
? createHash("sha256").update(convexUrl).digest("hex").slice(0, 12)
|
|
||||||
: "missing"
|
|
||||||
|
|
||||||
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, {
|
const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/")
|
||||||
isLocalDevelopment,
|
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "")
|
||||||
thumbnailsRoot: getManualsThumbnailsRoot(),
|
: manual.thumbnailUrl
|
||||||
fileExists: existsSync,
|
|
||||||
|
return existsSync(join(thumbnailsRoot, relativeThumbnailPath))
|
||||||
|
? manual
|
||||||
|
: { ...manual, thumbnailUrl: undefined }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Generate structured data for SEO
|
// Generate structured data for SEO
|
||||||
|
|
@ -132,22 +99,8 @@ export default async function ManualsPage() {
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
||||||
/>
|
/>
|
||||||
<div className="public-page" data-manuals-domain={manualsDomain}>
|
<div className="public-page">
|
||||||
{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} />
|
<ManualsPageExperience initialManuals={manuals} />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,10 @@ function LocationCard({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link href={href} className="group block h-full">
|
<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="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<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}
|
{city}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">{zipCode}</p>
|
<p className="text-sm text-muted-foreground">{zipCode}</p>
|
||||||
|
|
@ -49,15 +49,15 @@ function LocationCard({
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/75">
|
||||||
Popular Areas
|
Popular Areas
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||||
{neighborhoods.slice(0, 2).join(", ")}
|
{neighborhoods.slice(0, 2).join(", ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</PublicInset>
|
</PublicInset>
|
||||||
|
</PublicSurface>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +222,7 @@ export default function ServiceAreasPage() {
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-12 space-y-8">
|
<section className="mt-12 space-y-12">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: "Salt Lake County",
|
title: "Salt Lake County",
|
||||||
|
|
@ -243,8 +243,7 @@ export default function ServiceAreasPage() {
|
||||||
items: utahCounty,
|
items: utahCounty,
|
||||||
},
|
},
|
||||||
].map((section) => (
|
].map((section) => (
|
||||||
<PublicSurface key={section.title} className="p-5 md:p-6">
|
<div key={section.title} className="space-y-5">
|
||||||
<div className="flex flex-col gap-4 border-b border-border/55 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||||
Coverage Area
|
Coverage Area
|
||||||
|
|
@ -256,11 +255,7 @@ export default function ServiceAreasPage() {
|
||||||
{section.description}
|
{section.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-fit rounded-full border border-border/55 bg-background/70 px-3 py-1 text-sm text-muted-foreground">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{section.items.length} cities
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{section.items.map((location) => (
|
{section.items.map((location) => (
|
||||||
<LocationCard
|
<LocationCard
|
||||||
key={location.slug}
|
key={location.slug}
|
||||||
|
|
@ -271,11 +266,11 @@ export default function ServiceAreasPage() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PublicSurface>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</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>
|
<PublicSurface>
|
||||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">
|
<h2 className="text-3xl font-semibold tracking-tight text-balance">
|
||||||
Why businesses choose Rocky Mountain Vending
|
Why businesses choose Rocky Mountain Vending
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||||
import {
|
import {
|
||||||
generateRegistryMetadata,
|
generateRegistryMetadata,
|
||||||
generateRegistryStructuredData,
|
generateRegistryStructuredData,
|
||||||
} from "@/lib/seo"
|
} from "@/lib/seo"
|
||||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||||
|
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||||
import { FAQSection } from "@/components/faq-section"
|
import { FAQSection } from "@/components/faq-section"
|
||||||
import { ServiceAreasSection } from "@/components/service-areas-section"
|
import { ServiceAreasSection } from "@/components/service-areas-section"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
@ -56,13 +58,21 @@ export default async function RepairsPage() {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let imageMapping: any = {}
|
||||||
|
try {
|
||||||
|
imageMapping = loadImageMapping()
|
||||||
|
} catch (e) {
|
||||||
|
imageMapping = {}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract FAQs from content
|
// Extract FAQs from content
|
||||||
const faqs: Array<{ question: string; answer: string }> = []
|
const faqs: Array<{ question: string; answer: string }> = []
|
||||||
|
let contentWithoutFAQs = page.content || ""
|
||||||
|
let contentWithoutVirtualServices = ""
|
||||||
let virtualServicesContent = ""
|
let virtualServicesContent = ""
|
||||||
|
|
||||||
if (page.content) {
|
if (page.content) {
|
||||||
const contentStr = String(page.content)
|
const contentStr = String(page.content)
|
||||||
let strippedContent = contentStr
|
|
||||||
|
|
||||||
// Extract FAQ items from accordion structure
|
// Extract FAQ items from accordion structure
|
||||||
const questionMatches = contentStr.matchAll(
|
const questionMatches = contentStr.matchAll(
|
||||||
|
|
@ -102,28 +112,40 @@ export default async function RepairsPage() {
|
||||||
if (faqs.length > 0) {
|
if (faqs.length > 0) {
|
||||||
const faqSectionRegex =
|
const faqSectionRegex =
|
||||||
/<h2[^>]*>.*?Answers\s+To\s+Common\s+Questions.*?<\/h2>[\s\S]*?(?=<h2[^>]*>.*?Virtual\s+Services|<h2[^>]*>.*?Service\s+Area|$)/i
|
/<h2[^>]*>.*?Answers\s+To\s+Common\s+Questions.*?<\/h2>[\s\S]*?(?=<h2[^>]*>.*?Virtual\s+Services|<h2[^>]*>.*?Service\s+Area|$)/i
|
||||||
strippedContent = contentStr.replace(faqSectionRegex, "").trim()
|
contentWithoutFAQs = contentStr.replace(faqSectionRegex, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract Virtual Services section
|
// Extract Virtual Services section
|
||||||
const virtualServicesRegex =
|
const virtualServicesRegex =
|
||||||
/<h2[^>]*>.*?Virtual\s+Services.*?<\/h2>([\s\S]*?)(?=<h2[^>]*>.*?Service\s+Area|$)/i
|
/<h2[^>]*>.*?Virtual\s+Services.*?<\/h2>([\s\S]*?)(?=<h2[^>]*>.*?Service\s+Area|$)/i
|
||||||
const virtualMatch = strippedContent.match(virtualServicesRegex)
|
const virtualMatch = contentStr.match(virtualServicesRegex)
|
||||||
if (virtualMatch) {
|
if (virtualMatch) {
|
||||||
virtualServicesContent = virtualMatch[1]
|
virtualServicesContent = virtualMatch[1]
|
||||||
|
// Remove Virtual Services from main content
|
||||||
|
contentWithoutVirtualServices = contentWithoutFAQs
|
||||||
|
.replace(virtualServicesRegex, "")
|
||||||
|
.trim()
|
||||||
|
} else {
|
||||||
|
contentWithoutVirtualServices = contentWithoutFAQs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const content = contentWithoutVirtualServices ? (
|
||||||
|
<div className="max-w-none">
|
||||||
|
{cleanWordPressContent(String(contentWithoutVirtualServices), {
|
||||||
|
imageMapping,
|
||||||
|
pageTitle: page.title,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">No content available.</p>
|
||||||
|
)
|
||||||
|
|
||||||
const structuredData = generateRegistryStructuredData("repairs", {
|
const structuredData = generateRegistryStructuredData("repairs", {
|
||||||
datePublished: page.date,
|
datePublished: page.date,
|
||||||
dateModified: page.modified || page.date,
|
dateModified: page.modified || page.date,
|
||||||
})
|
})
|
||||||
|
|
||||||
const excerpt = String(page.excerpt || "")
|
|
||||||
.replace(/<[^>]+>/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
const surfaceCardClass =
|
const surfaceCardClass =
|
||||||
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] shadow-[var(--public-surface-shadow)]"
|
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] shadow-[var(--public-surface-shadow)]"
|
||||||
const insetCardClass =
|
const insetCardClass =
|
||||||
|
|
@ -149,14 +171,37 @@ export default async function RepairsPage() {
|
||||||
align="center"
|
align="center"
|
||||||
className="mb-8"
|
className="mb-8"
|
||||||
eyebrow="Repair Services"
|
eyebrow="Repair Services"
|
||||||
title="Vending machine repairs and service for Utah businesses"
|
title={page.title || "Vending Machine Repairs and Service"}
|
||||||
description={
|
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">
|
<p className="mx-auto max-w-3xl text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||||
{excerpt ||
|
Rocky Mountain Vending delivers expert{" "}
|
||||||
"Rocky Mountain Vending helps businesses across Davis, Salt Lake, and Utah counties keep machines running with practical repair, maintenance, and support guidance."}
|
<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>
|
</p>
|
||||||
</PublicPageHeader>
|
</PublicPageHeader>
|
||||||
{/* Images Carousel */}
|
{/* Images Carousel */}
|
||||||
|
|
@ -166,71 +211,15 @@ export default async function RepairsPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{contentWithoutVirtualServices ? (
|
||||||
<section className="py-16 md:py-20 bg-background">
|
<section className="py-16 md:py-20 bg-background">
|
||||||
<div className="container mx-auto grid max-w-6xl gap-6 px-4 lg:grid-cols-[1.08fr_0.92fr]">
|
<div className="container mx-auto px-4 max-w-4xl">
|
||||||
<PublicSurface className="h-full">
|
<PublicSurface>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
<div className="max-w-none">{content}</div>
|
||||||
Repair Overview
|
|
||||||
</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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Services Section */}
|
{/* Services Section */}
|
||||||
<section className="py-20 md:py-28 bg-muted/30">
|
<section className="py-20 md:py-28 bg-muted/30">
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,12 @@
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import {
|
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
|
||||||
getAdminUserFromCookies,
|
|
||||||
isAdminCredentialLoginConfigured,
|
|
||||||
isAdminUiEnabled,
|
|
||||||
} from "@/lib/server/admin-auth"
|
|
||||||
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||||
|
|
||||||
type PageProps = {
|
export default function SignInPage() {
|
||||||
searchParams: Promise<{
|
|
||||||
error?: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SignInPage({ searchParams }: PageProps) {
|
|
||||||
if (!isAdminUiEnabled()) {
|
if (!isAdminUiEnabled()) {
|
||||||
redirect("/")
|
redirect("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminUser = await getAdminUserFromCookies()
|
|
||||||
if (adminUser) {
|
|
||||||
redirect("/admin")
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = await searchParams
|
|
||||||
const errorMessage =
|
|
||||||
params.error === "invalid"
|
|
||||||
? "That email or password was not accepted."
|
|
||||||
: params.error === "config"
|
|
||||||
? "Admin access is not available right now."
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-8 md:py-12">
|
<div className="px-4 py-8 md:py-12">
|
||||||
<div className="mx-auto flex min-h-[calc(100dvh-7rem)] max-w-3xl items-start justify-center md:items-center">
|
<div className="mx-auto flex min-h-[calc(100dvh-7rem)] max-w-3xl items-start justify-center md:items-center">
|
||||||
|
|
@ -42,55 +19,13 @@ export default async function SignInPage({ searchParams }: PageProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PublicSurface className="p-6 text-center md:p-8">
|
<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">
|
<h2 className="text-2xl font-semibold">
|
||||||
Admin sign-in is not configured
|
Admin sign-in is not configured
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 text-sm text-muted-foreground">
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
Admin access is not available right now.
|
Enable the admin UI and connect an auth provider before using this
|
||||||
|
area.
|
||||||
</p>
|
</p>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,16 @@
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import type { Metadata } from "next"
|
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||||
import Image from "next/image"
|
import type { ImageMapping } from "@/lib/wordpress-content"
|
||||||
import Link from "next/link"
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
CreditCard,
|
|
||||||
Refrigerator,
|
|
||||||
ShoppingCart,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||||
import {
|
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||||
PublicInset,
|
import type { Metadata } from "next"
|
||||||
PublicPageHeader,
|
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||||
PublicSectionHeader,
|
|
||||||
PublicSurface,
|
|
||||||
} from "@/components/public-surface"
|
|
||||||
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
|
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
|
||||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
const WORDPRESS_SLUG = "vending-machines-for-sale-in-utah"
|
const WORDPRESS_SLUG = "vending-machines-for-sale-in-utah"
|
||||||
|
|
||||||
const machineHighlights = [
|
|
||||||
"Snack, drink, combo, and card-reader-ready equipment options",
|
|
||||||
"New and used machine guidance based on traffic, budget, and product mix",
|
|
||||||
"Local help with payment hardware, installation planning, and next-step questions",
|
|
||||||
]
|
|
||||||
|
|
||||||
const machineOptions = [
|
|
||||||
{
|
|
||||||
title: "Snack and drink machines",
|
|
||||||
description:
|
|
||||||
"Traditional snack, beverage, and combo machines for breakrooms, customer spaces, and mixed-traffic locations.",
|
|
||||||
icon: ShoppingCart,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Cashless payment hardware",
|
|
||||||
description:
|
|
||||||
"Card reader and mobile-payment options that help modernize older machines or support new installs.",
|
|
||||||
icon: CreditCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Refrigerated equipment",
|
|
||||||
description:
|
|
||||||
"Cold drink and refrigerated machine options for workplaces that need dependable temperature-controlled service.",
|
|
||||||
icon: Refrigerator,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const buyingSteps = [
|
|
||||||
{
|
|
||||||
title: "Tell us about the location",
|
|
||||||
body: "We learn about traffic, available space, product needs, and whether you are comparing free placement with a direct purchase.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Compare the right machine options",
|
|
||||||
body: "We help narrow down machine styles, payment hardware, and new-versus-used tradeoffs so you are looking at realistic fits.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Plan install and support",
|
|
||||||
body: "Once you know what you want, we can talk through delivery, setup, payment configuration, and any follow-up service needs.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function normalizeWpImageUrl(url?: string) {
|
|
||||||
if (!url) return null
|
|
||||||
return url.replace("https:///", "https://rockymountainvending.com/")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||||
|
|
||||||
|
|
@ -97,6 +39,13 @@ export default async function MachinesForSalePage() {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let imageMapping: ImageMapping = {}
|
||||||
|
try {
|
||||||
|
imageMapping = loadImageMapping()
|
||||||
|
} catch {
|
||||||
|
imageMapping = {}
|
||||||
|
}
|
||||||
|
|
||||||
const structuredData = (() => {
|
const structuredData = (() => {
|
||||||
try {
|
try {
|
||||||
return generateStructuredData({
|
return generateStructuredData({
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
|
|
@ -149,168 +88,19 @@ export default async function MachinesForSalePage() {
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PublicPageHeader
|
<PublicPageHeader
|
||||||
align="center"
|
|
||||||
eyebrow="Machine Sales"
|
eyebrow="Machine Sales"
|
||||||
title="Compare vending machines, payment hardware, and purchase options with a local Utah team."
|
title={page.title || "Vending Machines for Sale in Utah"}
|
||||||
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."
|
description="Compare machine options, payment hardware, and support with help from the Rocky Mountain Vending team."
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
<PublicSurface className="mt-10">
|
||||||
<PublicSectionHeader
|
<div className="max-w-none">
|
||||||
eyebrow="Machine Options"
|
{cleanWordPressContent(String(page.content || ""), {
|
||||||
title="What businesses usually want help comparing"
|
imageMapping,
|
||||||
description="Most sales conversations come down to the machine type, payment setup, and whether a direct purchase makes more sense than placement."
|
pageTitle: page.title,
|
||||||
className="mx-auto mb-8 max-w-3xl text-center"
|
|
||||||
/>
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
|
||||||
{machineOptions.map((option) => {
|
|
||||||
const Icon = option.icon
|
|
||||||
return (
|
|
||||||
<PublicInset key={option.title} className="h-full p-5 md:p-6">
|
|
||||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-4 text-xl font-semibold tracking-tight text-foreground">
|
|
||||||
{option.title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
|
|
||||||
{option.description}
|
|
||||||
</p>
|
|
||||||
</PublicInset>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<PublicSurface className="overflow-hidden p-0">
|
|
||||||
<div className="grid gap-0 md:grid-cols-2">
|
|
||||||
<div className="order-2 flex flex-col justify-center p-5 md:order-1 md:p-6">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
|
||||||
Payment Hardware
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
|
||||||
Card readers and cashless upgrades are often part of the decision.
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
|
|
||||||
If you are trying to modernize how people pay, we can help
|
|
||||||
you think through card readers, mobile payments, and
|
|
||||||
compatibility before you commit to a machine.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="relative order-1 min-h-[260px] md:order-2">
|
|
||||||
<Image
|
|
||||||
src={paymentImage}
|
|
||||||
alt="Cashless payment hardware for vending machines"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 768px) 100vw, 360px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PublicSurface>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-12">
|
|
||||||
<PublicSectionHeader
|
|
||||||
eyebrow="Buying Process"
|
|
||||||
title="A simpler way to move from questions to a real option"
|
|
||||||
description="You do not need to have the exact model picked out before you reach out. Most of the work is narrowing to the right fit."
|
|
||||||
className="mx-auto mb-8 max-w-3xl text-center"
|
|
||||||
/>
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
|
||||||
{buyingSteps.map((step, index) => (
|
|
||||||
<PublicInset key={step.title} className="h-full p-5 md:p-6">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-lg font-semibold text-primary-foreground">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-4 text-xl font-semibold tracking-tight text-foreground">
|
|
||||||
{step.title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-muted-foreground md:text-base">
|
|
||||||
{step.body}
|
|
||||||
</p>
|
|
||||||
</PublicInset>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
<section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||||
<PublicSurface>
|
<PublicSurface>
|
||||||
|
|
@ -321,9 +111,9 @@ export default async function MachinesForSalePage() {
|
||||||
Need a free machine instead of buying one?
|
Need a free machine instead of buying one?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||||
If you are a business looking for placement instead of a
|
If you're a business looking for placement instead of a
|
||||||
purchase, we can help you figure out whether your location is a
|
purchase, we can help you find the right setup for your
|
||||||
fit before you spend money on equipment.
|
location.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<GetFreeMachineCta buttonLabel="Get Free Placement" />
|
<GetFreeMachineCta buttonLabel="Get Free Placement" />
|
||||||
|
|
@ -335,23 +125,13 @@ export default async function MachinesForSalePage() {
|
||||||
Need Sales Help?
|
Need Sales Help?
|
||||||
</p>
|
</p>
|
||||||
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
||||||
Talk through machine sales, placement, or payment questions.
|
Talk through machine sales, placement, or feature questions.
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
We can help with new vs. used options, payment hardware, and
|
We can help with new vs. used options, payment hardware, and
|
||||||
whether free placement or a direct purchase makes more sense
|
whether free placement or a direct purchase makes more sense
|
||||||
for your location.
|
for your location.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="min-h-11 rounded-full px-6"
|
|
||||||
>
|
|
||||||
<Link href="/contact-us#contact-form">Talk to Sales</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
|
|
||||||
export function AboutPage() {
|
export function AboutPage() {
|
||||||
return (
|
return (
|
||||||
<div className="public-page">
|
<div className="public-page max-w-6xl">
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
items={[{ label: "About Us", href: "/about-us" }]}
|
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."
|
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">
|
<div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)] lg:items-start">
|
||||||
<PublicInset className="h-full p-5">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
|
||||||
Repairs
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
Include the machine model, what the machine is doing, and any photos
|
|
||||||
or videos that can help us triage the issue faster.
|
|
||||||
</p>
|
|
||||||
</PublicInset>
|
|
||||||
<PublicInset className="h-full p-5">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
|
||||||
Sales or Placement
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
Tell us about your location, traffic, and whether you are asking
|
|
||||||
about free placement, machine sales, or both.
|
|
||||||
</p>
|
|
||||||
</PublicInset>
|
|
||||||
<PublicInset className="h-full p-5">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
|
||||||
Manuals or Parts
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
Share the machine brand and model so we can point you toward the
|
|
||||||
right part, manual, or support path.
|
|
||||||
</p>
|
|
||||||
</PublicInset>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] lg:items-start">
|
|
||||||
<PublicSurface id="contact-form" as="section" className="p-5 md:p-7">
|
<PublicSurface id="contact-form" as="section" className="p-5 md:p-7">
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||||
|
|
@ -75,21 +45,7 @@ export function ContactPage() {
|
||||||
/>
|
/>
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
|
|
||||||
<aside className="space-y-4 lg:sticky lg:top-28">
|
<aside className="space-y-5">
|
||||||
<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>
|
|
||||||
|
|
||||||
<PublicSurface className="overflow-hidden p-6">
|
<PublicSurface className="overflow-hidden p-6">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||||
Direct Options
|
Direct Options
|
||||||
|
|
@ -103,10 +59,10 @@ export function ContactPage() {
|
||||||
below.
|
below.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-6 space-y-4">
|
||||||
<a
|
<a
|
||||||
href={businessConfig.publicCallUrl}
|
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">
|
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
<Phone className="h-5 w-5" />
|
<Phone className="h-5 w-5" />
|
||||||
|
|
@ -124,7 +80,7 @@ export function ContactPage() {
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`}
|
href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`}
|
||||||
className="flex items-start gap-4 rounded-2xl border border-border/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">
|
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
<Mail className="h-5 w-5" />
|
<Mail className="h-5 w-5" />
|
||||||
|
|
@ -157,7 +113,7 @@ export function ContactPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-5 space-y-2">
|
||||||
{businessHours.map((schedule) => (
|
{businessHours.map((schedule) => (
|
||||||
<PublicInset
|
<PublicInset
|
||||||
key={schedule.day}
|
key={schedule.day}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { Breadcrumbs, type BreadcrumbItem } from "@/components/breadcrumbs"
|
||||||
import {
|
import {
|
||||||
PublicInset,
|
PublicInset,
|
||||||
PublicPageHeader,
|
PublicPageHeader,
|
||||||
PublicProse,
|
|
||||||
PublicSurface,
|
PublicSurface,
|
||||||
} from "@/components/public-surface"
|
} from "@/components/public-surface"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
@ -22,7 +21,6 @@ interface DropdownPageShellProps {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
headerContent?: ReactNode
|
headerContent?: ReactNode
|
||||||
contentIntro?: ReactNode
|
|
||||||
content: ReactNode
|
content: ReactNode
|
||||||
contentClassName?: string
|
contentClassName?: string
|
||||||
contentSurfaceClassName?: string
|
contentSurfaceClassName?: string
|
||||||
|
|
@ -43,7 +41,6 @@ export function DropdownPageShell({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
headerContent,
|
headerContent,
|
||||||
contentIntro,
|
|
||||||
content,
|
content,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
contentSurfaceClassName,
|
contentSurfaceClassName,
|
||||||
|
|
@ -64,43 +61,19 @@ export function DropdownPageShell({
|
||||||
{headerContent}
|
{headerContent}
|
||||||
</PublicPageHeader>
|
</PublicPageHeader>
|
||||||
|
|
||||||
{contentIntro ? (
|
<section className="mt-10">
|
||||||
<section className="mt-10 grid gap-5 lg:grid-cols-2">{contentIntro}</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className={cn(contentIntro ? "mt-8" : "mt-10")}>
|
|
||||||
<PublicSurface
|
<PublicSurface
|
||||||
className={cn(
|
className={cn("overflow-hidden", contentSurfaceClassName)}
|
||||||
"relative overflow-hidden p-0 md:p-0",
|
|
||||||
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={cn("max-w-none", contentClassName)}>{content}</div>
|
||||||
<div className="relative p-5 md:p-7 lg:p-10">
|
|
||||||
<div className="mb-8 flex items-center justify-between gap-4 border-b border-border/55 pb-5">
|
|
||||||
<div>
|
|
||||||
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
|
|
||||||
Location Guide
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
|
||||||
How Rocky Mountain Vending typically approaches this type of
|
|
||||||
location, from placement fit to service expectations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<PublicProse className={cn("mx-auto max-w-3xl", contentClassName)}>
|
|
||||||
{content}
|
|
||||||
</PublicProse>
|
|
||||||
</div>
|
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{sections ? <div className="mt-14 space-y-14">{sections}</div> : null}
|
{sections ? <div className="mt-12 space-y-12">{sections}</div> : null}
|
||||||
|
|
||||||
{cta ? (
|
{cta ? (
|
||||||
<section className="mt-14">
|
<section className="mt-12">
|
||||||
<PublicSurface className="overflow-hidden text-center">
|
<PublicSurface className="text-center">
|
||||||
<div className="absolute inset-x-0 top-0 h-20 bg-[radial-gradient(circle_at_top,rgba(41,160,71,0.10),transparent_70%)]" />
|
|
||||||
{cta.eyebrow ? (
|
{cta.eyebrow ? (
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||||
{cta.eyebrow}
|
{cta.eyebrow}
|
||||||
|
|
@ -126,7 +99,7 @@ export function DropdownPageShell({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{cta.note ? (
|
{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}
|
{cta.note}
|
||||||
</PublicInset>
|
</PublicInset>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,15 @@ const features = [
|
||||||
title: "Free Placement",
|
title: "Free Placement",
|
||||||
description:
|
description:
|
||||||
"For qualifying locations, we can place the machines, stock them, and stay responsible for day-to-day service after install.",
|
"For qualifying locations, we can place the machines, stock them, and stay responsible for day-to-day service after install.",
|
||||||
link: "/#how-it-works",
|
link: "/about-us",
|
||||||
linkText: "How placement works",
|
linkText: "How placement works",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
title: "Repairs and Services",
|
title: "Repairs and Service",
|
||||||
description:
|
description:
|
||||||
"We handle repairs, restocking, and routine service so your team does not have to manage the machines.",
|
"We handle repairs, restocking, and routine service so your team does not have to manage the machines.",
|
||||||
link: "/services/repairs#request-service",
|
link: "/services/repairs",
|
||||||
linkText: "Repair services",
|
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="mx-auto w-full max-w-[var(--public-shell-max)] px-4 py-14 sm:px-5 md:py-20 lg:px-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.9fr_0.9fr_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.9fr_0.9fr_1fr]">
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
<div className="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">
|
<Link href="/" className="inline-flex">
|
||||||
<Image
|
<Image
|
||||||
src="/rmv-logo.png"
|
src="/rmv-logo.png"
|
||||||
|
|
@ -104,7 +104,7 @@ export function Footer() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<div className="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>
|
<h3 className="font-semibold mb-5 text-base">Services</h3>
|
||||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -159,7 +159,7 @@ export function Footer() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company */}
|
{/* Company */}
|
||||||
<div className="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>
|
<h3 className="font-semibold mb-5 text-base">Company</h3>
|
||||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -198,7 +198,7 @@ export function Footer() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Service Areas */}
|
{/* Service Areas */}
|
||||||
<div className="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>
|
<h3 className="font-semibold mb-5 text-base">Service Areas</h3>
|
||||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
|
|
|
||||||
|
|
@ -162,16 +162,9 @@ export function Header() {
|
||||||
{ label: "Reviews", href: "/reviews" },
|
{ label: "Reviews", href: "/reviews" },
|
||||||
{ label: "FAQs", href: "/about/faqs" },
|
{ label: "FAQs", href: "/about/faqs" },
|
||||||
]
|
]
|
||||||
const moreItems = [
|
|
||||||
{ label: "Food & Beverage", href: "/food-and-beverage/healthy-options" },
|
|
||||||
{ label: "Blog Posts", href: "/blog" },
|
|
||||||
{ label: "About Us", href: "/about-us" },
|
|
||||||
{ label: "Products", href: "/products" },
|
|
||||||
{ label: "Service Areas", href: "/service-areas" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const desktopLinkClassName =
|
const desktopLinkClassName =
|
||||||
"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 =
|
const mobileLinkClassName =
|
||||||
"flex min-h-11 items-center rounded-[1rem] px-4 text-sm font-medium text-foreground transition hover:bg-primary/6 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
"flex min-h-11 items-center rounded-[1rem] px-4 text-sm font-medium text-foreground transition hover:bg-primary/6 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||||
const mobileGroupButtonClassName =
|
const mobileGroupButtonClassName =
|
||||||
|
|
@ -182,7 +175,7 @@ export function Header() {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 w-full border-b border-border/50 bg-white/92 shadow-[0_10px_35px_rgba(15,23,42,0.06)] backdrop-blur supports-[backdrop-filter]:bg-white/80">
|
<header className="sticky top-0 z-40 w-full border-b border-border/50 bg-white/92 shadow-[0_10px_35px_rgba(15,23,42,0.06)] backdrop-blur supports-[backdrop-filter]:bg-white/80">
|
||||||
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 sm:px-5 lg:px-6">
|
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 sm:px-5 lg:px-6">
|
||||||
<div className="flex h-[var(--header-height)] items-center justify-between gap-3 md:hidden lg:gap-6">
|
<div className="flex h-[var(--header-height)] items-center justify-between gap-3 lg:gap-6">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
|
|
@ -199,7 +192,7 @@ export function Header() {
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* 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}>
|
<Link href="/" className={desktopLinkClassName}>
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -378,7 +371,7 @@ export function Header() {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Desktop CTA */}
|
{/* 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
|
<CartButton
|
||||||
onClick={() => dispatch({ type: "SET_CART", value: true })}
|
onClick={() => dispatch({ type: "SET_CART", value: true })}
|
||||||
/>
|
/>
|
||||||
|
|
@ -400,7 +393,7 @@ export function Header() {
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-white text-foreground transition hover:border-primary/35 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 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" })}
|
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
aria-expanded={state.isMenuOpen}
|
aria-expanded={state.isMenuOpen}
|
||||||
|
|
@ -413,161 +406,6 @@ export function Header() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<div className="flex min-h-[4.75rem] items-center justify-between gap-4 py-3">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex min-w-0 flex-shrink-0 items-center gap-2 rounded-full"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/rmv-logo.png"
|
|
||||||
alt="Rocky Mountain Vending"
|
|
||||||
width={220}
|
|
||||||
height={55}
|
|
||||||
className="h-12 w-auto object-contain lg:h-14"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-shrink-0 items-center gap-2 lg:gap-3">
|
|
||||||
<CartButton
|
|
||||||
onClick={() => dispatch({ type: "SET_CART", value: true })}
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="tel:+14352339668"
|
|
||||||
className="inline-flex min-h-10 items-center gap-2 rounded-full border border-border/60 bg-white px-3 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary lg:px-4"
|
|
||||||
>
|
|
||||||
<Phone className="h-4 w-4 flex-shrink-0" />
|
|
||||||
<span className="hidden xl:inline">(435) 233-9668</span>
|
|
||||||
<span className="xl:hidden">Call</span>
|
|
||||||
</a>
|
|
||||||
<Button
|
|
||||||
onClick={() => dispatch({ type: "SET_MODAL", value: true })}
|
|
||||||
className="h-10 whitespace-nowrap rounded-full bg-primary px-4 text-sm hover:bg-primary/90 lg:px-5"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
Get Free Machine
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-[3.5rem] items-center justify-center border-t border-border/45 py-2">
|
|
||||||
<nav className="flex flex-wrap items-center justify-center gap-x-1 gap-y-2 lg:gap-x-2">
|
|
||||||
<Link href="/" className={desktopLinkClassName}>
|
|
||||||
Home
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
desktopLinkClassName,
|
|
||||||
"gap-1.5 data-[state=open]:text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Who We Serve
|
|
||||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className={cn("w-56", desktopDropdownClassName)}
|
|
||||||
>
|
|
||||||
{whoWeServeItems.map((item) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={item.href}
|
|
||||||
asChild
|
|
||||||
className="rounded-xl px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<Link href={item.href}>{item.label}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
desktopLinkClassName,
|
|
||||||
"gap-1.5 data-[state=open]:text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Vending Machines
|
|
||||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className={cn("w-64", desktopDropdownClassName)}
|
|
||||||
>
|
|
||||||
{vendingMachinesItems.map((item) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={item.href}
|
|
||||||
asChild
|
|
||||||
className="rounded-xl px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<Link href={item.href}>{item.label}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
desktopLinkClassName,
|
|
||||||
"gap-1.5 data-[state=open]:text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Services
|
|
||||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className={cn("w-72", desktopDropdownClassName)}
|
|
||||||
>
|
|
||||||
{servicesItems.map((item) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={item.href}
|
|
||||||
asChild
|
|
||||||
className="rounded-xl px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<Link href={item.href}>{item.label}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Link href="/contact-us" className={desktopLinkClassName}>
|
|
||||||
Contact Us
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
desktopLinkClassName,
|
|
||||||
"gap-1.5 data-[state=open]:text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
More
|
|
||||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
className={cn("w-56", desktopDropdownClassName)}
|
|
||||||
>
|
|
||||||
{moreItems.map((item) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={item.href}
|
|
||||||
asChild
|
|
||||||
className="rounded-xl px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<Link href={item.href}>{item.label}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Navigation */}
|
{/* Mobile Navigation */}
|
||||||
{state.isMenuOpen && (
|
{state.isMenuOpen && (
|
||||||
<nav className="border-t border-border/40 py-5 md:hidden">
|
<nav className="border-t border-border/40 py-5 md:hidden">
|
||||||
|
|
|
||||||
|
|
@ -114,52 +114,11 @@ export function LocationLandingPage({
|
||||||
}: {
|
}: {
|
||||||
locationData: LocationData
|
locationData: LocationData
|
||||||
}) {
|
}) {
|
||||||
const isSaltLakeCity = locationData.slug === "salt-lake-city-utah"
|
|
||||||
const countyName = getCountyName(locationData.slug)
|
const countyName = getCountyName(locationData.slug)
|
||||||
const industries = getIndustryFocus(locationData)
|
const industries = getIndustryFocus(locationData)
|
||||||
const canonicalUrl = buildAbsoluteUrl(buildLocationRoute(locationData.slug))
|
const canonicalUrl = buildAbsoluteUrl(buildLocationRoute(locationData.slug))
|
||||||
const title = `Vending Machines in ${locationData.city}, ${locationData.stateAbbr}`
|
const title = `Vending Machines in ${locationData.city}, ${locationData.stateAbbr}`
|
||||||
const description = `Rocky Mountain Vending provides free placement for qualifying locations, machine sales, repairs, and vending service for businesses in ${locationData.city}, ${locationData.stateAbbr}.`
|
const description = `Rocky Mountain Vending provides free placement for qualifying locations, machine sales, repairs, and vending service for businesses in ${locationData.city}, ${locationData.stateAbbr}.`
|
||||||
const comparisonRows = [
|
|
||||||
["Credit card readers", "Yes", "Maybe", "Yes"],
|
|
||||||
["Locally owned", "Yes", "Yes", "Maybe"],
|
|
||||||
["Fast service", "Yes", "Maybe", "Maybe"],
|
|
||||||
["Large selection of products", "Yes", "Maybe", "Yes"],
|
|
||||||
["Quality of equipment used", "Excellent", "Varies", "Excellent"],
|
|
||||||
["Locked into Coke or Pepsi equipment", "No", "Maybe", "Probably"],
|
|
||||||
]
|
|
||||||
const saltLakeServiceLinks = [
|
|
||||||
{
|
|
||||||
title: "Traditional snacks and drinks",
|
|
||||||
body: "Stock the machine with the classic snacks, sodas, and convenience items most locations still want every day.",
|
|
||||||
href: "/food-and-beverage/traditional-options",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Healthy snacks and drinks",
|
|
||||||
body: "Offer protein bars, better-for-you snacks, and drink choices that fit health-conscious teams and customers.",
|
|
||||||
href: "/food-and-beverage/healthy-options",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Snack and drink delivery",
|
|
||||||
body: "Need product delivery or a broader refreshment setup beyond standard machine placement? We can help there too.",
|
|
||||||
href: "/food-and-beverage/snack-and-drink-delivery",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Vending machine sales",
|
|
||||||
body: "Compare purchase options if you want equipment ownership instead of a free-placement arrangement.",
|
|
||||||
href: "/vending-machines/machines-for-sale",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Parts, repairs, and moving",
|
|
||||||
body: "Get support for repair work, machine moving, replacement parts, and operational issues that need direct service help.",
|
|
||||||
href: "/services/parts",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Training and support",
|
|
||||||
body: "Browse the support pages and machine guides if you need help with specific models, manuals, or machine operation questions.",
|
|
||||||
href: "/blog",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const structuredData = {
|
const structuredData = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
|
@ -229,15 +188,15 @@ export function LocationLandingPage({
|
||||||
<PublicPageHeader
|
<PublicPageHeader
|
||||||
align="center"
|
align="center"
|
||||||
eyebrow="Local Service Area"
|
eyebrow="Local Service Area"
|
||||||
title={`Vending machine service for businesses in ${locationData.city}, ${locationData.stateAbbr}`}
|
title={`${locationData.city}, ${locationData.stateAbbr} vending machine service`}
|
||||||
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.`}
|
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"
|
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">
|
<PublicSurface className="p-6 md:p-8">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||||
A local vending partner for businesses across {locationData.city}
|
Vending service for businesses across {locationData.city}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
||||||
If your business is in {locationData.neighborhoods.join(", ")}, or
|
If your business is in {locationData.neighborhoods.join(", ")}, or
|
||||||
|
|
@ -260,11 +219,11 @@ export function LocationLandingPage({
|
||||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
|
||||||
Common business types we serve in {locationData.city}
|
Common business types we serve in {locationData.city}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
{industries.map((industry) => (
|
{industries.map((industry) => (
|
||||||
<span
|
<span
|
||||||
key={industry}
|
key={industry}
|
||||||
className="rounded-full border border-border/60 bg-background px-3 py-1.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}
|
{industry}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -277,15 +236,6 @@ export function LocationLandingPage({
|
||||||
Utah service area.
|
Utah service area.
|
||||||
</p>
|
</p>
|
||||||
</PublicInset>
|
</PublicInset>
|
||||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
|
||||||
<GetFreeMachineCta buttonLabel="Check Placement Fit" />
|
|
||||||
<Link
|
|
||||||
href="/contact-us#contact-form"
|
|
||||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
|
|
||||||
>
|
|
||||||
Talk to Our Team
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -293,7 +243,7 @@ export function LocationLandingPage({
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-balance">
|
<h2 className="text-3xl font-bold tracking-tight text-balance">
|
||||||
Vending services available in {locationData.city}
|
Vending services available in {locationData.city}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-8 grid gap-5 md:grid-cols-2">
|
<div className="mt-8 grid gap-6 md:grid-cols-2">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: "Free vending placement",
|
title: "Free vending placement",
|
||||||
|
|
@ -320,7 +270,7 @@ export function LocationLandingPage({
|
||||||
cta: "View manuals and parts",
|
cta: "View manuals and parts",
|
||||||
},
|
},
|
||||||
].map((service) => (
|
].map((service) => (
|
||||||
<PublicSurface key={service.title} className="h-full p-6 md:p-7">
|
<PublicSurface key={service.title} className="h-full p-6">
|
||||||
<h3 className="text-xl font-semibold">{service.title}</h3>
|
<h3 className="text-xl font-semibold">{service.title}</h3>
|
||||||
<p className="mt-3 leading-7 text-muted-foreground">
|
<p className="mt-3 leading-7 text-muted-foreground">
|
||||||
{service.body}
|
{service.body}
|
||||||
|
|
@ -337,95 +287,6 @@ export function LocationLandingPage({
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isSaltLakeCity ? (
|
|
||||||
<section className="mx-auto mb-16 max-w-5xl space-y-6">
|
|
||||||
<PublicSurface className="p-6 md:p-8">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
|
||||||
Why Rocky Mountain Vending
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-bold tracking-tight text-balance">
|
|
||||||
What Salt Lake City businesses usually want to verify before they choose a vendor.
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
|
||||||
Most businesses care about the same things: service speed,
|
|
||||||
product flexibility, local ownership, and whether the machines
|
|
||||||
feel modern and dependable after install.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 overflow-hidden rounded-[1.5rem] border border-border/60">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[680px] border-collapse text-sm">
|
|
||||||
<thead className="bg-muted/55">
|
|
||||||
<tr className="border-b border-border/60">
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-foreground">
|
|
||||||
Comparison point
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-foreground">
|
|
||||||
Rocky Mountain Vending
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-foreground">
|
|
||||||
Small Vendor
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-foreground">
|
|
||||||
Large Vendor
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{comparisonRows.map((row, index) => (
|
|
||||||
<tr
|
|
||||||
key={row[0]}
|
|
||||||
className={`border-b border-border/50 ${index % 2 === 0 ? "bg-background" : "bg-muted/20"}`}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 font-medium text-foreground">
|
|
||||||
{row[0]}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
|
||||||
{row[1]}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
|
||||||
{row[2]}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
|
||||||
{row[3]}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PublicSurface>
|
|
||||||
|
|
||||||
<PublicSurface className="p-6 md:p-8">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
|
||||||
Salt Lake City Services
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-bold tracking-tight text-balance">
|
|
||||||
The service paths Salt Lake City businesses usually ask about first.
|
|
||||||
</h2>
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
|
||||||
{saltLakeServiceLinks.map((item) => (
|
|
||||||
<PublicInset key={item.title} className="flex h-full flex-col">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{item.body}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Learn more
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</PublicInset>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PublicSurface>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="mx-auto mb-16 grid max-w-5xl gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
<section className="mx-auto mb-16 grid max-w-5xl gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
<PublicSurface className="p-6 md:p-8">
|
<PublicSurface className="p-6 md:p-8">
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-balance">
|
<h2 className="text-3xl font-bold tracking-tight text-balance">
|
||||||
|
|
@ -544,7 +405,7 @@ export function LocationLandingPage({
|
||||||
vending help you need. We'll follow up with the next best
|
vending help you need. We'll follow up with the next best
|
||||||
option for your location.
|
option for your location.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
<div className="mt-6 flex flex-col items-center gap-3">
|
||||||
<GetFreeMachineCta buttonLabel="See If Your Location Qualifies" />
|
<GetFreeMachineCta buttonLabel="See If Your Location Qualifies" />
|
||||||
<Link
|
<Link
|
||||||
href="/contact-us#contact-form"
|
href="/contact-us#contact-form"
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,6 @@ export function ManualViewer({
|
||||||
const [pdfError, setPdfError] = useState(false)
|
const [pdfError, setPdfError] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [showPartsPanel, setShowPartsPanel] = useState(true)
|
const [showPartsPanel, setShowPartsPanel] = useState(true)
|
||||||
const [partsPanelLoading, setPartsPanelLoading] = useState(true)
|
|
||||||
const [partsPanelVisible, setPartsPanelVisible] = useState(true)
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
// Reset error state when manual URL changes
|
// Reset error state when manual URL changes
|
||||||
|
|
@ -51,8 +49,6 @@ export function ManualViewer({
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setPdfError(false)
|
setPdfError(false)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setPartsPanelLoading(true)
|
|
||||||
setPartsPanelVisible(true)
|
|
||||||
}
|
}
|
||||||
}, [manualUrl, isOpen])
|
}, [manualUrl, isOpen])
|
||||||
|
|
||||||
|
|
@ -69,17 +65,6 @@ export function ManualViewer({
|
||||||
setPdfError(true)
|
setPdfError(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showPartsPanelWithData =
|
|
||||||
showPartsPanel && (partsPanelLoading || partsPanelVisible)
|
|
||||||
const canToggleParts = partsPanelLoading || partsPanelVisible
|
|
||||||
const partsToggleLabel = partsPanelLoading
|
|
||||||
? "Checking Parts..."
|
|
||||||
: partsPanelVisible
|
|
||||||
? showPartsPanel
|
|
||||||
? "Hide Parts"
|
|
||||||
: "Show Parts"
|
|
||||||
: "Parts Unavailable"
|
|
||||||
|
|
||||||
// Mobile layout - use Sheet
|
// Mobile layout - use Sheet
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -217,10 +202,9 @@ export function ManualViewer({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowPartsPanel(!showPartsPanel)}
|
onClick={() => setShowPartsPanel(!showPartsPanel)}
|
||||||
disabled={!canToggleParts}
|
|
||||||
>
|
>
|
||||||
<ShoppingCart className="h-4 w-4 mr-1" />
|
<ShoppingCart className="h-4 w-4 mr-1" />
|
||||||
{partsToggleLabel}
|
{showPartsPanel ? "Hide" : "Show"} Parts
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -255,7 +239,7 @@ export function ManualViewer({
|
||||||
{/* PDF Viewer - responsive width based on parts panel */}
|
{/* PDF Viewer - responsive width based on parts panel */}
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden min-h-0 relative transition-all duration-300 h-full ${
|
className={`overflow-hidden min-h-0 relative transition-all duration-300 h-full ${
|
||||||
showPartsPanelWithData ? "w-[75%] lg:w-[80%]" : "w-full"
|
showPartsPanel ? "w-[75%] lg:w-[80%]" : "w-full"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading && !pdfError && (
|
{isLoading && !pdfError && (
|
||||||
|
|
@ -313,14 +297,10 @@ export function ManualViewer({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Parts Panel - right side, responsive width */}
|
{/* Parts Panel - right side, responsive width */}
|
||||||
{showPartsPanelWithData && (
|
{showPartsPanel && (
|
||||||
<PartsPanel
|
<PartsPanel
|
||||||
manualFilename={filename}
|
manualFilename={filename}
|
||||||
className={`border-l border-yellow-300/20 bg-yellow-50 dark:bg-yellow-950/90 overflow-y-auto h-full ${"w-[25%] lg:w-[20%]"}`}
|
className={`border-l border-yellow-300/20 bg-yellow-50 dark:bg-yellow-950/90 overflow-y-auto h-full ${"w-[25%] lg:w-[20%]"}`}
|
||||||
onStateChange={(state) => {
|
|
||||||
setPartsPanelLoading(state.isLoading)
|
|
||||||
setPartsPanelVisible(state.isVisible)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ import {
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { Manual, ManualGroup } from "@/lib/manuals-types"
|
import type { Manual, ManualGroup } from "@/lib/manuals-types"
|
||||||
import { getManualUrl, getThumbnailUrl } from "@/lib/manuals-types"
|
import { getManualUrl, getThumbnailUrl } from "@/lib/manuals-types"
|
||||||
|
|
@ -37,6 +40,176 @@ import {
|
||||||
} from "@/lib/manuals-config"
|
} from "@/lib/manuals-config"
|
||||||
import { ManualViewer } from "@/components/manual-viewer"
|
import { ManualViewer } from "@/components/manual-viewer"
|
||||||
import { getManualsWithParts } from "@/lib/parts-lookup"
|
import { getManualsWithParts } from "@/lib/parts-lookup"
|
||||||
|
import { ebayClient } from "@/lib/ebay-api"
|
||||||
|
|
||||||
|
// Product Suggestion Component
|
||||||
|
interface ProductSuggestion {
|
||||||
|
itemId: string
|
||||||
|
title: string
|
||||||
|
price: string
|
||||||
|
currency: string
|
||||||
|
imageUrl?: string
|
||||||
|
viewItemUrl: string
|
||||||
|
affiliateLink: string
|
||||||
|
condition?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductSuggestionsProps {
|
||||||
|
manual: Manual
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductSuggestions({
|
||||||
|
manual,
|
||||||
|
className = "",
|
||||||
|
}: ProductSuggestionsProps) {
|
||||||
|
const [suggestions, setSuggestions] = useState<ProductSuggestion[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(ebayClient.isConfigured())
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ebayClient.isConfigured()) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSuggestions() {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate search query from manual content
|
||||||
|
const query = `${manual.manufacturer} ${manual.category} vending machine`
|
||||||
|
const results = await ebayClient.searchItems({
|
||||||
|
keywords: query,
|
||||||
|
maxResults: 6,
|
||||||
|
sortOrder: "BestMatch",
|
||||||
|
})
|
||||||
|
|
||||||
|
setSuggestions(results)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading product suggestions:", err)
|
||||||
|
setError("Could not load product suggestions")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manual) {
|
||||||
|
loadSuggestions()
|
||||||
|
}
|
||||||
|
}, [manual])
|
||||||
|
|
||||||
|
if (!ebayClient.isConfigured()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<Loader2 className="h-5 w-5 text-yellow-700 dark:text-yellow-300 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<AlertCircle className="h-6 w-6 text-red-500" />
|
||||||
|
<span className="ml-2 text-sm text-red-500">{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<AlertCircle className="h-6 w-6 text-yellow-500" />
|
||||||
|
<span className="ml-2 text-sm text-yellow-600">
|
||||||
|
No products found in sandbox environment
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-yellow-700 dark:text-yellow-300" />
|
||||||
|
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
|
||||||
|
Related Products
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{suggestions.map((product) => (
|
||||||
|
<a
|
||||||
|
key={product.itemId}
|
||||||
|
href={product.affiliateLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block group"
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-yellow-900/30 rounded border border-yellow-300/40 dark:border-yellow-700/40 p-2 hover:bg-yellow-50 dark:hover:bg-yellow-900/40 transition-colors">
|
||||||
|
{/* Image */}
|
||||||
|
{product.imageUrl && (
|
||||||
|
<div className="mb-2 rounded overflow-hidden bg-yellow-100 dark:bg-yellow-900/50">
|
||||||
|
<img
|
||||||
|
src={product.imageUrl}
|
||||||
|
alt={product.title}
|
||||||
|
className="w-full h-16 object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.src = `https://via.placeholder.com/120x80/fbbf24/1f2937?text=${encodeURIComponent(product.title)}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!product.imageUrl && (
|
||||||
|
<div className="mb-2 rounded overflow-hidden bg-yellow-100 dark:bg-yellow-900/50 h-16 flex items-center justify-center">
|
||||||
|
<span className="text-[10px] text-yellow-700 dark:text-yellow-300">
|
||||||
|
No Image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Details */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[11px] text-yellow-900 dark:text-yellow-100 line-clamp-2 min-h-[1.5rem]">
|
||||||
|
{product.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
|
||||||
|
{product.price}
|
||||||
|
</span>
|
||||||
|
<ExternalLink className="h-3 w-3 text-yellow-700 dark:text-yellow-300 group-hover:text-yellow-900 dark:group-hover:text-yellow-100 transition-colors flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
{product.condition && (
|
||||||
|
<div className="text-[9px] text-yellow-700/80 dark:text-yellow-300/80">
|
||||||
|
{product.condition}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ManualsPageClientProps {
|
interface ManualsPageClientProps {
|
||||||
manuals: Manual[]
|
manuals: Manual[]
|
||||||
|
|
@ -65,7 +238,7 @@ function ManualCard({
|
||||||
const thumbnailUrl = getThumbnailUrl(manual)
|
const thumbnailUrl = getThumbnailUrl(manual)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden rounded-[1.75rem] border border-border/70 bg-background shadow-[0_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 && (
|
{thumbnailUrl && (
|
||||||
<div className="relative h-48 min-h-[192px] w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.12),transparent_52%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,255,255,0.98))]">
|
<div className="relative h-48 min-h-[192px] w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.12),transparent_52%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,255,255,0.98))]">
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -77,13 +250,13 @@ function ManualCard({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<CardTitle className="line-clamp-2 text-base leading-snug">
|
||||||
{manual.filename.replace(/\.pdf$/i, "")}
|
{manual.filename.replace(/\.pdf$/i, "")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{manual.commonNames && manual.commonNames.length > 0 && (
|
{manual.commonNames && manual.commonNames.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{manual.commonNames.slice(0, 3).map((name, index) => (
|
{manual.commonNames.map((name, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={index}
|
key={index}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -92,21 +265,13 @@ function ManualCard({
|
||||||
{name}
|
{name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{manual.commonNames.length > 3 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="rounded-full border border-border/60 bg-muted/35 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
+{manual.commonNames.length - 3} more
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{manual.searchTerms &&
|
{manual.searchTerms &&
|
||||||
manual.searchTerms.length > 0 &&
|
manual.searchTerms.length > 0 &&
|
||||||
!manual.commonNames && (
|
!manual.commonNames && (
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{manual.searchTerms.slice(0, 4).map((term, index) => (
|
{manual.searchTerms.map((term, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={index}
|
key={index}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -115,14 +280,6 @@ function ManualCard({
|
||||||
{term}
|
{term}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{manual.searchTerms.length > 4 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="rounded-full border border-border/60 bg-muted/35 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
+{manual.searchTerms.length - 4} more
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -146,7 +303,7 @@ function ManualCard({
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
{showManufacturer && (
|
{showManufacturer && (
|
||||||
<p>
|
<p>
|
||||||
<strong>Manufacturer:</strong> {manual.manufacturer}
|
<strong>Manufacturer:</strong> {manual.manufacturer}
|
||||||
|
|
@ -490,28 +647,9 @@ export function ManualsPageClient({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Search and Filter Controls */}
|
{/* Search and Filter Controls */}
|
||||||
<PublicSurface className="p-4 md:p-6">
|
<PublicSurface>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 rounded-[1.5rem] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,249,240,0.94))] p-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
|
||||||
Start With Search
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-foreground">
|
|
||||||
Find the manual first, then narrow it down
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
Search by model, manufacturer, or category. Use filters if
|
|
||||||
you already know the brand or want manuals with parts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<PublicInset className="w-full rounded-[1.25rem] px-4 py-3 text-sm text-muted-foreground shadow-none md:max-w-xs">
|
|
||||||
Showing <strong>{filteredManuals.length}</strong> of{" "}
|
|
||||||
<strong>{manuals.length}</strong> manuals
|
|
||||||
</PublicInset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||||
|
|
@ -525,14 +663,12 @@ export function ManualsPageClient({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters Row */}
|
{/* Filters Row */}
|
||||||
<div className="rounded-[1.5rem] border border-border/60 bg-white/80 p-4">
|
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4 items-start sm:items-center">
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<Filter className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<Filter className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-sm font-medium">Filters:</span>
|
<span className="text-sm font-medium">Filters:</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full sm:w-auto">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -631,11 +767,9 @@ export function ManualsPageClient({
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View Mode Toggle and Results Count */}
|
{/* View Mode Toggle and Results Count */}
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4 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">
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
<span className="text-sm text-muted-foreground flex-shrink-0">
|
<span className="text-sm text-muted-foreground flex-shrink-0">
|
||||||
View:
|
View:
|
||||||
|
|
@ -673,8 +807,8 @@ export function ManualsPageClient({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground w-full sm:w-auto text-center sm:text-left">
|
<div className="text-sm text-muted-foreground w-full sm:w-auto text-center sm:text-left">
|
||||||
View the full library grouped by manufacturer or switch to list
|
Showing <strong>{filteredManuals.length}</strong> of{" "}
|
||||||
view for a faster scan.
|
<strong>{manuals.length}</strong> manuals
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -702,15 +836,11 @@ export function ManualsPageClient({
|
||||||
{filteredGroupedManuals.map((group) => {
|
{filteredGroupedManuals.map((group) => {
|
||||||
const organized = organizeCategories(group.categories)
|
const organized = organizeCategories(group.categories)
|
||||||
return (
|
return (
|
||||||
<PublicSurface key={group.manufacturer} className="space-y-7 p-5 md:p-7">
|
<PublicSurface key={group.manufacturer} className="space-y-6">
|
||||||
<div className="border-b border-border/60 pb-4">
|
<div className="border-b border-border/60 pb-3">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
<h2 className="text-2xl font-bold tracking-tight">
|
||||||
{group.manufacturer}
|
{group.manufacturer}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{Object.values(group.categories).flat().length} manuals
|
|
||||||
available from this manufacturer.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Machine Type Categories First */}
|
{/* Machine Type Categories First */}
|
||||||
|
|
@ -748,7 +878,7 @@ export function ManualsPageClient({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) => (
|
{categoryManuals.map((manual) => (
|
||||||
<ManualCard
|
<ManualCard
|
||||||
key={manual.path}
|
key={manual.path}
|
||||||
|
|
@ -791,7 +921,7 @@ export function ManualsPageClient({
|
||||||
)
|
)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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) => (
|
{categoryManuals.map((manual) => (
|
||||||
<ManualCard
|
<ManualCard
|
||||||
key={manual.path}
|
key={manual.path}
|
||||||
|
|
@ -808,13 +938,28 @@ export function ManualsPageClient({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Product Suggestions Section */}
|
||||||
|
{filteredManuals.length > 0 && (
|
||||||
|
<div className="space-y-6 mt-8">
|
||||||
|
<div className="border-t border-border/60 pt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-muted-foreground mb-4">
|
||||||
|
Related Products
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredManuals.slice(0, 3).map((manual) => (
|
||||||
|
<ProductSuggestions key={manual.path} manual={manual} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* List View */
|
/* List View */
|
||||||
<div className="grid grid-cols-1 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) => (
|
{filteredManuals.map((manual) => (
|
||||||
<ManualCard
|
<ManualCard
|
||||||
key={manual.path}
|
key={manual.path}
|
||||||
|
|
|
||||||
|
|
@ -3,63 +3,32 @@
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
|
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { EbayCacheState } from "@/lib/ebay-parts-match"
|
|
||||||
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
|
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
|
||||||
import {
|
|
||||||
hasTrustedPartsListings,
|
|
||||||
shouldShowEbayPartsPanel,
|
|
||||||
} from "@/lib/ebay-parts-visibility"
|
|
||||||
|
|
||||||
interface PartsPanelProps {
|
interface PartsPanelProps {
|
||||||
manualFilename: string
|
manualFilename: string
|
||||||
className?: string
|
className?: string
|
||||||
onStateChange?: (state: { isLoading: boolean; isVisible: boolean }) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PartsPanel({
|
export function PartsPanel({
|
||||||
manualFilename,
|
manualFilename,
|
||||||
className = "",
|
className = "",
|
||||||
onStateChange,
|
|
||||||
}: PartsPanelProps) {
|
}: PartsPanelProps) {
|
||||||
const [parts, setParts] = useState<PartForPage[]>([])
|
const [parts, setParts] = useState<PartForPage[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [cache, setCache] = useState<EbayCacheState | null>(null)
|
|
||||||
|
|
||||||
const formatFreshness = (value: number | null) => {
|
|
||||||
if (!value) {
|
|
||||||
return "not refreshed yet"
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.max(0, Math.floor(value / 60000))
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
if (hours < 24) {
|
|
||||||
return `${hours}h ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = Math.floor(hours / 24)
|
|
||||||
return `${days}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadParts = useCallback(async () => {
|
const loadParts = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setParts([])
|
|
||||||
setCache(null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getTopPartsForManual(manualFilename, 5)
|
const result = await getTopPartsForManual(manualFilename, 5)
|
||||||
setParts(result.parts)
|
setParts(result.parts)
|
||||||
setError(result.error ?? null)
|
setError(result.error ?? null)
|
||||||
setCache(result.cache ?? null)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading parts:", err)
|
console.error("Error loading parts:", err)
|
||||||
setParts([])
|
setParts([])
|
||||||
setCache(null)
|
|
||||||
setError("Could not load parts")
|
setError("Could not load parts")
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|
@ -72,25 +41,37 @@ export function PartsPanel({
|
||||||
}
|
}
|
||||||
}, [loadParts, manualFilename])
|
}, [loadParts, manualFilename])
|
||||||
|
|
||||||
const hasListings = hasTrustedPartsListings(parts)
|
const hasListings = parts.some((part) => part.ebayListings.length > 0)
|
||||||
const shouldShowPanel = shouldShowEbayPartsPanel({
|
|
||||||
isLoading,
|
|
||||||
parts,
|
|
||||||
cache,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const renderStatusCard = (title: string, message: string) => (
|
||||||
if (!onStateChange) {
|
<div className={`flex flex-col h-full ${className}`}>
|
||||||
return
|
<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" />
|
||||||
onStateChange({
|
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
|
||||||
isLoading,
|
Parts
|
||||||
isVisible: shouldShowPanel,
|
</span>
|
||||||
})
|
</div>
|
||||||
}, [isLoading, onStateChange, shouldShowPanel])
|
</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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -102,14 +83,6 @@ export function PartsPanel({
|
||||||
Parts
|
Parts
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{cache && (
|
|
||||||
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
|
|
||||||
{cache.lastSuccessfulAt
|
|
||||||
? `Cache updated ${cacheFreshnessText}`
|
|
||||||
: "Cache warming up"}
|
|
||||||
{cache.isStale ? " • stale" : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
|
@ -119,8 +92,53 @@ export function PartsPanel({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldShowPanel) {
|
if (error && !hasListings) {
|
||||||
return null
|
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 (
|
return (
|
||||||
|
|
@ -132,14 +150,6 @@ export function PartsPanel({
|
||||||
Parts ({parts.length})
|
Parts ({parts.length})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{cache && (
|
|
||||||
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
|
|
||||||
{cache.lastSuccessfulAt
|
|
||||||
? `Cache updated ${cacheFreshnessText}`
|
|
||||||
: "Cache warming up"}
|
|
||||||
{cache.isStale ? " • stale" : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
|
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
|
||||||
|
|
@ -149,10 +159,15 @@ export function PartsPanel({
|
||||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
Cached eBay listings are unavailable right now.
|
Live eBay listings are unavailable right now.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
|
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
|
||||||
{error}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,6 @@ type PublicPageHeaderProps = {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicProseProps = {
|
|
||||||
className?: string
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PublicSection({
|
export function PublicSection({
|
||||||
id,
|
id,
|
||||||
tone = "default",
|
tone = "default",
|
||||||
|
|
@ -107,7 +102,7 @@ export function PublicSurface({
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -125,7 +120,7 @@ export function PublicInset({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -149,29 +144,14 @@ export function PublicSectionHeader({
|
||||||
className,
|
className,
|
||||||
}: PublicSectionHeaderProps) {
|
}: PublicSectionHeaderProps) {
|
||||||
return (
|
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">
|
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-foreground md:text-[1.375rem]">
|
<h2 className="text-xl font-semibold tracking-tight text-foreground md:text-[1.375rem]">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PublicProse({ className, children }: PublicProseProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"prose prose-slate max-w-none prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-foreground prose-p:text-muted-foreground prose-p:leading-7 prose-li:text-muted-foreground prose-li:leading-7 prose-strong:text-foreground prose-a:text-foreground prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-img:rounded-[1.5rem] prose-img:border prose-img:border-border/55 prose-img:shadow-[0_18px_45px_rgba(15,23,42,0.08)] prose-hr:border-border/50 prose-blockquote:border-primary/25 prose-blockquote:text-foreground md:prose-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function RequestMachineSection() {
|
||||||
<section id="request-machine" className="bg-background py-16 md:py-24">
|
<section id="request-machine" className="bg-background py-16 md:py-24">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
|
||||||
<PublicSurface className="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">
|
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
Free Placement
|
Free Placement
|
||||||
|
|
@ -61,7 +61,7 @@ export function RequestMachineSection() {
|
||||||
</div>
|
</div>
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
|
|
||||||
<PublicSurface className="p-5 md:p-7">
|
<PublicSurface className="bg-white p-5 md:p-7">
|
||||||
<RequestMachineForm
|
<RequestMachineForm
|
||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
console.log("Machine request form submitted:", data)
|
console.log("Machine request form submitted:", data)
|
||||||
|
|
|
||||||
|
|
@ -9,47 +9,6 @@ import {
|
||||||
PublicSurface,
|
PublicSurface,
|
||||||
} from "@/components/public-surface"
|
} from "@/components/public-surface"
|
||||||
|
|
||||||
const reviewThemes = [
|
|
||||||
{
|
|
||||||
title: "Always cold and stocked",
|
|
||||||
body: "Customers consistently mention that the machines stay full, drinks stay cold, and the day-to-day experience feels dependable instead of neglected.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Fast, friendly service",
|
|
||||||
body: "When something needs attention, businesses talk about quick follow-through, easy communication, and issues getting handled without a long delay.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Wide product variety",
|
|
||||||
body: "Reviews regularly call out strong drink selection, snack variety, and the ability to request items that fit the people using the location.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Fair pricing and reliability",
|
|
||||||
body: "People mention competitive pricing, clean machines, and a setup that feels professional instead of frustrating or outdated.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const featuredQuotes = [
|
|
||||||
{
|
|
||||||
quote:
|
|
||||||
"He is arguably one of the best vendors in the industry. There probably isn't too many people I would trust more than him.",
|
|
||||||
author: "Martin Harrison",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quote: "He always has my favorite energy drink at a great price!",
|
|
||||||
author: "TOPX Kingsford",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quote:
|
|
||||||
"This vending machine is my favorite at my job! It always works and has the best stuff!!",
|
|
||||||
author: "DJ Montoya",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quote:
|
|
||||||
"Great to work with, looking forward to having him in more locations.",
|
|
||||||
author: "Jennifer Spencer",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ReviewsPage() {
|
export function ReviewsPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const existingScript = document.querySelector(
|
const existingScript = document.querySelector(
|
||||||
|
|
@ -85,65 +44,8 @@ export function ReviewsPage() {
|
||||||
align="center"
|
align="center"
|
||||||
eyebrow="Customer Reviews"
|
eyebrow="Customer Reviews"
|
||||||
title="What Utah businesses say about working with Rocky Mountain Vending."
|
title="What Utah businesses say about working with Rocky Mountain Vending."
|
||||||
description="See the real themes customers mention most often, browse featured comments, and then dig into the live Google review feed."
|
description="Browse the live Google review feed and see what Utah businesses say about placement, restocking, repairs, and service."
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<section className="mt-12">
|
<section className="mt-12">
|
||||||
<PublicSurface className="overflow-hidden p-5 md:p-7">
|
<PublicSurface className="overflow-hidden p-5 md:p-7">
|
||||||
|
|
@ -167,9 +69,9 @@ export function ReviewsPage() {
|
||||||
</div>
|
</div>
|
||||||
</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
|
<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"
|
src="https://reputationhub.site/reputation/widgets/review_widget/YAoWLgNSid8oG44j9BjG"
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
|
|
@ -179,7 +81,7 @@ export function ReviewsPage() {
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
</section>
|
</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>
|
<PublicSurface>
|
||||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">
|
<h2 className="text-3xl font-semibold tracking-tight text-balance">
|
||||||
What businesses usually want to verify before they choose a vendor
|
What businesses usually want to verify before they choose a vendor
|
||||||
|
|
@ -218,33 +120,21 @@ export function ReviewsPage() {
|
||||||
<PublicSurface className="flex flex-col justify-between">
|
<PublicSurface className="flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||||
Why It Matters
|
Next Step
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
|
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
|
||||||
Reviews are usually the last confidence check before someone reaches out.
|
Want to see whether your location qualifies?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||||
Most businesses are trying to verify the same things: follow-through,
|
Tell us about your traffic, breakroom, or customer area and
|
||||||
communication, and whether the machines stay stocked and working
|
we'll help you decide between free placement, machine sales,
|
||||||
after install. If that sounds like your checklist too, we can help
|
or service help.
|
||||||
you sort through next steps quickly.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-4">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
<PublicInset className="p-5">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
|
||||||
Common Questions
|
|
||||||
</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
|
<Link
|
||||||
href="/#request-machine"
|
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"
|
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">
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
Free Placement
|
Free Placement
|
||||||
|
|
@ -256,7 +146,7 @@ export function ReviewsPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/contact-us#contact-form"
|
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"
|
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">
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
Service or Sales
|
Service or Sales
|
||||||
|
|
@ -267,17 +157,6 @@ export function ReviewsPage() {
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import { ReactNode } from "react"
|
||||||
import { CheckCircle2 } from "lucide-react"
|
import { CheckCircle2 } from "lucide-react"
|
||||||
import { DropdownPageShell } from "@/components/dropdown-page-shell"
|
import { DropdownPageShell } from "@/components/dropdown-page-shell"
|
||||||
import { PublicInset } from "@/components/public-surface"
|
import { PublicInset } from "@/components/public-surface"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
interface WhoWeServePageProps {
|
interface WhoWeServePageProps {
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -57,58 +55,13 @@ export function WhoWeServePage({
|
||||||
description ||
|
description ||
|
||||||
"See how Rocky Mountain Vending adapts machine placement, product mix, and ongoing service to the way this kind of location actually runs."
|
"See how Rocky Mountain Vending adapts machine placement, product mix, and ongoing service to the way this kind of location actually runs."
|
||||||
}
|
}
|
||||||
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={
|
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={
|
sections={
|
||||||
<section>
|
<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">
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||||
Why Rocky
|
Why Rocky
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -154,12 +107,6 @@ export function WhoWeServePage({
|
||||||
variant: "outline",
|
variant: "outline",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
note: (
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
We can help you sort out whether this should start as a placement request,
|
|
||||||
a machine-sales conversation, or direct service support.
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
// @ts-nocheck
|
||||||
import { action, mutation } from "./_generated/server"
|
import { action, mutation } from "./_generated/server"
|
||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
import {
|
|
||||||
ensureConversationParticipant,
|
|
||||||
upsertContactRecord,
|
|
||||||
upsertConversationRecord,
|
|
||||||
upsertMessageRecord,
|
|
||||||
} from "./crmModel"
|
|
||||||
|
|
||||||
const leadSyncStatus = v.union(
|
const leadSyncStatus = v.union(
|
||||||
v.literal("pending"),
|
v.literal("pending"),
|
||||||
|
|
@ -125,60 +119,8 @@ export const createLead = mutation({
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const contact = await upsertContactRecord(ctx, {
|
|
||||||
firstName: args.firstName,
|
|
||||||
lastName: args.lastName,
|
|
||||||
email: args.email,
|
|
||||||
phone: args.phone,
|
|
||||||
company: args.company,
|
|
||||||
source: args.source,
|
|
||||||
status: args.status === "delivered" ? "active" : "lead",
|
|
||||||
lastActivityAt: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
const conversation = await upsertConversationRecord(ctx, {
|
|
||||||
contactId: contact?._id,
|
|
||||||
title:
|
|
||||||
args.type === "requestMachine"
|
|
||||||
? "Machine request"
|
|
||||||
: "Website contact",
|
|
||||||
channel: "chat",
|
|
||||||
source: args.source || "website",
|
|
||||||
status: args.status === "failed" ? "archived" : "open",
|
|
||||||
direction: "inbound",
|
|
||||||
startedAt: now,
|
|
||||||
lastMessageAt: now,
|
|
||||||
lastMessagePreview: args.message || args.intent,
|
|
||||||
summaryText: args.intent,
|
|
||||||
})
|
|
||||||
|
|
||||||
await ensureConversationParticipant(ctx, {
|
|
||||||
conversationId: conversation._id,
|
|
||||||
contactId: contact?._id,
|
|
||||||
role: "contact",
|
|
||||||
displayName: `${args.firstName} ${args.lastName}`.trim(),
|
|
||||||
phone: args.phone,
|
|
||||||
email: args.email,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (args.message || args.intent) {
|
|
||||||
await upsertMessageRecord(ctx, {
|
|
||||||
conversationId: conversation._id,
|
|
||||||
contactId: contact?._id,
|
|
||||||
direction: "inbound",
|
|
||||||
channel: "chat",
|
|
||||||
source: args.source || "website",
|
|
||||||
messageType: args.type,
|
|
||||||
body: args.message || args.intent || "",
|
|
||||||
status: args.status,
|
|
||||||
sentAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return await ctx.db.insert("leadSubmissions", {
|
return await ctx.db.insert("leadSubmissions", {
|
||||||
...args,
|
...args,
|
||||||
contactId: contact?._id,
|
|
||||||
conversationId: conversation?._id,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
deliveredAt: args.status === "delivered" ? now : undefined,
|
deliveredAt: args.status === "delivered" ? now : undefined,
|
||||||
|
|
@ -234,54 +176,6 @@ export const ingestLead = mutation({
|
||||||
const fallbackName = splitName(args.name)
|
const fallbackName = splitName(args.name)
|
||||||
const type = mapServiceToType(args.service)
|
const type = mapServiceToType(args.service)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const contact = await upsertContactRecord(ctx, {
|
|
||||||
firstName: args.firstName || fallbackName.firstName,
|
|
||||||
lastName: args.lastName || fallbackName.lastName,
|
|
||||||
email: args.email,
|
|
||||||
phone: args.phone,
|
|
||||||
company: args.company,
|
|
||||||
source: args.source,
|
|
||||||
status: "lead",
|
|
||||||
lastActivityAt: now,
|
|
||||||
})
|
|
||||||
const conversation = await upsertConversationRecord(ctx, {
|
|
||||||
contactId: contact?._id,
|
|
||||||
title: type === "requestMachine" ? "Machine request" : "Website contact",
|
|
||||||
channel: "chat",
|
|
||||||
source: args.source || "website",
|
|
||||||
status: "open",
|
|
||||||
direction: "inbound",
|
|
||||||
startedAt: now,
|
|
||||||
lastMessageAt: now,
|
|
||||||
lastMessagePreview: args.message || args.intent,
|
|
||||||
summaryText: args.intent || args.service,
|
|
||||||
})
|
|
||||||
|
|
||||||
await ensureConversationParticipant(ctx, {
|
|
||||||
conversationId: conversation._id,
|
|
||||||
contactId: contact?._id,
|
|
||||||
role: "contact",
|
|
||||||
displayName: `${args.firstName || fallbackName.firstName} ${args.lastName || fallbackName.lastName}`.trim(),
|
|
||||||
phone: args.phone,
|
|
||||||
email: args.email,
|
|
||||||
})
|
|
||||||
|
|
||||||
await upsertMessageRecord(ctx, {
|
|
||||||
conversationId: conversation._id,
|
|
||||||
contactId: contact?._id,
|
|
||||||
direction: "inbound",
|
|
||||||
channel: "chat",
|
|
||||||
source: args.source || "website",
|
|
||||||
messageType: type,
|
|
||||||
body: args.message,
|
|
||||||
status: "pending",
|
|
||||||
sentAt: now,
|
|
||||||
metadata: JSON.stringify({
|
|
||||||
intent: args.intent,
|
|
||||||
service: args.service,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const leadId = await ctx.db.insert("leadSubmissions", {
|
const leadId = await ctx.db.insert("leadSubmissions", {
|
||||||
type,
|
type,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
|
@ -304,8 +198,6 @@ export const ingestLead = mutation({
|
||||||
consentVersion: args.consentVersion,
|
consentVersion: args.consentVersion,
|
||||||
consentCapturedAt: args.consentCapturedAt,
|
consentCapturedAt: args.consentCapturedAt,
|
||||||
consentSourcePage: args.consentSourcePage,
|
consentSourcePage: args.consentSourcePage,
|
||||||
contactId: contact?._id,
|
|
||||||
conversationId: conversation?._id,
|
|
||||||
usesendStatus: "pending",
|
usesendStatus: "pending",
|
||||||
ghlStatus: "pending",
|
ghlStatus: "pending",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|
@ -349,22 +241,6 @@ export const updateLeadSyncStatus = mutation({
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (lead.contactId) {
|
|
||||||
await ctx.db.patch(lead.contactId, {
|
|
||||||
status: status === "delivered" ? "active" : "lead",
|
|
||||||
lastActivityAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lead.conversationId) {
|
|
||||||
await ctx.db.patch(lead.conversationId, {
|
|
||||||
status: status === "failed" ? "archived" : "open",
|
|
||||||
lastMessageAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return await ctx.db.get(args.leadId)
|
return await ctx.db.get(args.leadId)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { mutation, query } from "./_generated/server"
|
import { mutation, query } from "./_generated/server"
|
||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
import {
|
|
||||||
canonicalizeTenantDomain,
|
|
||||||
manualVisibleForTenant,
|
|
||||||
tenantDomainVariants,
|
|
||||||
} from "../lib/manuals-tenant"
|
|
||||||
|
|
||||||
const manualInput = v.object({
|
const manualInput = v.object({
|
||||||
filename: v.string(),
|
filename: v.string(),
|
||||||
|
|
@ -28,19 +23,10 @@ const manualInput = v.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {},
|
||||||
domain: v.string(),
|
handler: async (ctx) => {
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const tenantDomain = canonicalizeTenantDomain(args.domain)
|
|
||||||
if (!tenantDomain) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const manuals = await ctx.db.query("manuals").collect()
|
const manuals = await ctx.db.query("manuals").collect()
|
||||||
return manuals
|
return manuals.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||||
.filter((manual) => manualVisibleForTenant(manual, tenantDomain))
|
|
||||||
.sort((a, b) => a.filename.localeCompare(b.filename))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -120,64 +106,3 @@ export const upsertMany = mutation({
|
||||||
return results
|
return results
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const backfillTenantVisibility = mutation({
|
|
||||||
args: {
|
|
||||||
domain: v.string(),
|
|
||||||
dryRun: v.optional(v.boolean()),
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const tenantDomain = canonicalizeTenantDomain(args.domain)
|
|
||||||
if (!tenantDomain) {
|
|
||||||
throw new Error("A valid tenant domain is required.")
|
|
||||||
}
|
|
||||||
|
|
||||||
const aliases = tenantDomainVariants(tenantDomain)
|
|
||||||
const dryRun = Boolean(args.dryRun)
|
|
||||||
const now = Date.now()
|
|
||||||
const manuals = await ctx.db.query("manuals").collect()
|
|
||||||
|
|
||||||
let patched = 0
|
|
||||||
let alreadyCovered = 0
|
|
||||||
|
|
||||||
for (const manual of manuals) {
|
|
||||||
const visibilitySet = new Set(
|
|
||||||
(manual.siteVisibility || [])
|
|
||||||
.map((entry) => canonicalizeTenantDomain(entry))
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
|
|
||||||
const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain)
|
|
||||||
const hasDomain =
|
|
||||||
aliases.some((alias) => visibilitySet.has(alias)) ||
|
|
||||||
(sourceDomain ? aliases.includes(sourceDomain) : false)
|
|
||||||
|
|
||||||
if (hasDomain) {
|
|
||||||
alreadyCovered += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextVisibility = Array.from(
|
|
||||||
new Set([...visibilitySet, ...aliases])
|
|
||||||
).sort()
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
await ctx.db.patch(manual._id, {
|
|
||||||
sourceDomain: sourceDomain || tenantDomain,
|
|
||||||
siteVisibility: nextVisibility,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
patched += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
domain: tenantDomain,
|
|
||||||
total: manuals.length,
|
|
||||||
patched,
|
|
||||||
alreadyCovered,
|
|
||||||
dryRun,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
||||||
228
convex/schema.ts
228
convex/schema.ts
|
|
@ -90,50 +90,6 @@ export default defineSchema({
|
||||||
.index("by_category", ["category"])
|
.index("by_category", ["category"])
|
||||||
.index("by_path", ["path"]),
|
.index("by_path", ["path"]),
|
||||||
|
|
||||||
ebayListings: defineTable({
|
|
||||||
itemId: v.string(),
|
|
||||||
title: v.string(),
|
|
||||||
normalizedTitle: v.string(),
|
|
||||||
price: v.string(),
|
|
||||||
currency: v.string(),
|
|
||||||
imageUrl: v.optional(v.string()),
|
|
||||||
viewItemUrl: v.string(),
|
|
||||||
condition: v.optional(v.string()),
|
|
||||||
shippingCost: v.optional(v.string()),
|
|
||||||
affiliateLink: v.string(),
|
|
||||||
sourceQueries: v.array(v.string()),
|
|
||||||
fetchedAt: v.number(),
|
|
||||||
firstSeenAt: v.number(),
|
|
||||||
lastSeenAt: v.number(),
|
|
||||||
expiresAt: v.number(),
|
|
||||||
active: v.boolean(),
|
|
||||||
})
|
|
||||||
.index("by_itemId", ["itemId"])
|
|
||||||
.index("by_active", ["active"])
|
|
||||||
.index("by_expiresAt", ["expiresAt"])
|
|
||||||
.index("by_lastSeenAt", ["lastSeenAt"]),
|
|
||||||
|
|
||||||
ebayPollState: defineTable({
|
|
||||||
key: v.string(),
|
|
||||||
status: v.union(
|
|
||||||
v.literal("idle"),
|
|
||||||
v.literal("success"),
|
|
||||||
v.literal("rate_limited"),
|
|
||||||
v.literal("error"),
|
|
||||||
v.literal("missing_config"),
|
|
||||||
v.literal("skipped")
|
|
||||||
),
|
|
||||||
lastSuccessfulAt: v.optional(v.number()),
|
|
||||||
lastAttemptAt: v.optional(v.number()),
|
|
||||||
nextEligibleAt: v.optional(v.number()),
|
|
||||||
lastError: v.optional(v.string()),
|
|
||||||
consecutiveFailures: v.number(),
|
|
||||||
queryCount: v.number(),
|
|
||||||
itemCount: v.number(),
|
|
||||||
sourceQueries: v.array(v.string()),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
}).index("by_key", ["key"]),
|
|
||||||
|
|
||||||
manualCategories: defineTable({
|
manualCategories: defineTable({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
slug: v.string(),
|
slug: v.string(),
|
||||||
|
|
@ -189,8 +145,6 @@ export default defineSchema({
|
||||||
v.literal("skipped")
|
v.literal("skipped")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
contactId: v.optional(v.id("contacts")),
|
|
||||||
conversationId: v.optional(v.id("conversations")),
|
|
||||||
error: v.optional(v.string()),
|
error: v.optional(v.string()),
|
||||||
deliveredAt: v.optional(v.number()),
|
deliveredAt: v.optional(v.number()),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
|
|
@ -245,186 +199,6 @@ export default defineSchema({
|
||||||
.index("by_kind", ["kind"])
|
.index("by_kind", ["kind"])
|
||||||
.index("by_status", ["status"]),
|
.index("by_status", ["status"]),
|
||||||
|
|
||||||
contacts: defineTable({
|
|
||||||
firstName: v.string(),
|
|
||||||
lastName: v.string(),
|
|
||||||
email: v.optional(v.string()),
|
|
||||||
normalizedEmail: v.optional(v.string()),
|
|
||||||
phone: v.optional(v.string()),
|
|
||||||
normalizedPhone: v.optional(v.string()),
|
|
||||||
company: v.optional(v.string()),
|
|
||||||
tags: v.optional(v.array(v.string())),
|
|
||||||
status: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal("active"),
|
|
||||||
v.literal("lead"),
|
|
||||||
v.literal("customer"),
|
|
||||||
v.literal("inactive")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
source: v.optional(v.string()),
|
|
||||||
notes: v.optional(v.string()),
|
|
||||||
ghlContactId: v.optional(v.string()),
|
|
||||||
livekitIdentity: v.optional(v.string()),
|
|
||||||
lastActivityAt: v.optional(v.number()),
|
|
||||||
createdAt: v.number(),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
})
|
|
||||||
.index("by_normalizedEmail", ["normalizedEmail"])
|
|
||||||
.index("by_normalizedPhone", ["normalizedPhone"])
|
|
||||||
.index("by_ghlContactId", ["ghlContactId"])
|
|
||||||
.index("by_lastActivityAt", ["lastActivityAt"])
|
|
||||||
.index("by_updatedAt", ["updatedAt"]),
|
|
||||||
|
|
||||||
conversations: defineTable({
|
|
||||||
contactId: v.optional(v.id("contacts")),
|
|
||||||
title: v.optional(v.string()),
|
|
||||||
channel: v.union(
|
|
||||||
v.literal("call"),
|
|
||||||
v.literal("sms"),
|
|
||||||
v.literal("chat"),
|
|
||||||
v.literal("unknown")
|
|
||||||
),
|
|
||||||
source: v.optional(v.string()),
|
|
||||||
status: v.optional(
|
|
||||||
v.union(v.literal("open"), v.literal("closed"), v.literal("archived"))
|
|
||||||
),
|
|
||||||
direction: v.optional(
|
|
||||||
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("mixed"))
|
|
||||||
),
|
|
||||||
startedAt: v.number(),
|
|
||||||
endedAt: v.optional(v.number()),
|
|
||||||
lastMessageAt: v.optional(v.number()),
|
|
||||||
lastMessagePreview: v.optional(v.string()),
|
|
||||||
unreadCount: v.optional(v.number()),
|
|
||||||
summaryText: v.optional(v.string()),
|
|
||||||
ghlConversationId: v.optional(v.string()),
|
|
||||||
livekitRoomName: v.optional(v.string()),
|
|
||||||
voiceSessionId: v.optional(v.id("voiceSessions")),
|
|
||||||
createdAt: v.number(),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
})
|
|
||||||
.index("by_contactId", ["contactId"])
|
|
||||||
.index("by_channel", ["channel"])
|
|
||||||
.index("by_status", ["status"])
|
|
||||||
.index("by_ghlConversationId", ["ghlConversationId"])
|
|
||||||
.index("by_livekitRoomName", ["livekitRoomName"])
|
|
||||||
.index("by_voiceSessionId", ["voiceSessionId"])
|
|
||||||
.index("by_lastMessageAt", ["lastMessageAt"]),
|
|
||||||
|
|
||||||
conversationParticipants: defineTable({
|
|
||||||
conversationId: v.id("conversations"),
|
|
||||||
contactId: v.optional(v.id("contacts")),
|
|
||||||
role: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal("contact"),
|
|
||||||
v.literal("agent"),
|
|
||||||
v.literal("system"),
|
|
||||||
v.literal("unknown")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
displayName: v.optional(v.string()),
|
|
||||||
phone: v.optional(v.string()),
|
|
||||||
normalizedPhone: v.optional(v.string()),
|
|
||||||
email: v.optional(v.string()),
|
|
||||||
normalizedEmail: v.optional(v.string()),
|
|
||||||
externalContactId: v.optional(v.string()),
|
|
||||||
createdAt: v.number(),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
})
|
|
||||||
.index("by_conversationId", ["conversationId"])
|
|
||||||
.index("by_contactId", ["contactId"])
|
|
||||||
.index("by_externalContactId", ["externalContactId"]),
|
|
||||||
|
|
||||||
messages: defineTable({
|
|
||||||
conversationId: v.id("conversations"),
|
|
||||||
contactId: v.optional(v.id("contacts")),
|
|
||||||
direction: v.optional(
|
|
||||||
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("system"))
|
|
||||||
),
|
|
||||||
channel: v.union(
|
|
||||||
v.literal("call"),
|
|
||||||
v.literal("sms"),
|
|
||||||
v.literal("chat"),
|
|
||||||
v.literal("unknown")
|
|
||||||
),
|
|
||||||
source: v.optional(v.string()),
|
|
||||||
messageType: v.optional(v.string()),
|
|
||||||
body: v.string(),
|
|
||||||
status: v.optional(v.string()),
|
|
||||||
sentAt: v.number(),
|
|
||||||
ghlMessageId: v.optional(v.string()),
|
|
||||||
voiceTranscriptTurnId: v.optional(v.id("voiceTranscriptTurns")),
|
|
||||||
voiceSessionId: v.optional(v.id("voiceSessions")),
|
|
||||||
livekitRoomName: v.optional(v.string()),
|
|
||||||
metadata: v.optional(v.string()),
|
|
||||||
createdAt: v.number(),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
})
|
|
||||||
.index("by_conversationId", ["conversationId"])
|
|
||||||
.index("by_contactId", ["contactId"])
|
|
||||||
.index("by_ghlMessageId", ["ghlMessageId"])
|
|
||||||
.index("by_voiceTranscriptTurnId", ["voiceTranscriptTurnId"])
|
|
||||||
.index("by_sentAt", ["sentAt"]),
|
|
||||||
|
|
||||||
callArtifacts: defineTable({
|
|
||||||
conversationId: v.id("conversations"),
|
|
||||||
contactId: v.optional(v.id("contacts")),
|
|
||||||
source: v.optional(v.string()),
|
|
||||||
recordingId: v.optional(v.string()),
|
|
||||||
recordingUrl: v.optional(v.string()),
|
|
||||||
recordingStatus: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal("pending"),
|
|
||||||
v.literal("starting"),
|
|
||||||
v.literal("recording"),
|
|
||||||
v.literal("completed"),
|
|
||||||
v.literal("failed")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
transcriptionText: v.optional(v.string()),
|
|
||||||
durationMs: v.optional(v.number()),
|
|
||||||
startedAt: v.optional(v.number()),
|
|
||||||
endedAt: v.optional(v.number()),
|
|
||||||
ghlMessageId: v.optional(v.string()),
|
|
||||||
voiceSessionId: v.optional(v.id("voiceSessions")),
|
|
||||||
livekitRoomName: v.optional(v.string()),
|
|
||||||
metadata: v.optional(v.string()),
|
|
||||||
createdAt: v.number(),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
})
|
|
||||||
.index("by_conversationId", ["conversationId"])
|
|
||||||
.index("by_contactId", ["contactId"])
|
|
||||||
.index("by_recordingId", ["recordingId"])
|
|
||||||
.index("by_voiceSessionId", ["voiceSessionId"])
|
|
||||||
.index("by_ghlMessageId", ["ghlMessageId"]),
|
|
||||||
|
|
||||||
externalSyncState: defineTable({
|
|
||||||
provider: v.string(),
|
|
||||||
entityType: v.string(),
|
|
||||||
entityId: v.string(),
|
|
||||||
cursor: v.optional(v.string()),
|
|
||||||
checksum: v.optional(v.string()),
|
|
||||||
status: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal("running"),
|
|
||||||
v.literal("pending"),
|
|
||||||
v.literal("synced"),
|
|
||||||
v.literal("failed"),
|
|
||||||
v.literal("missing_config"),
|
|
||||||
v.literal("reconciled"),
|
|
||||||
v.literal("mismatch")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
lastAttemptAt: v.optional(v.number()),
|
|
||||||
lastSyncedAt: v.optional(v.number()),
|
|
||||||
error: v.optional(v.string()),
|
|
||||||
metadata: v.optional(v.string()),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
})
|
|
||||||
.index("by_provider_entityType", ["provider", "entityType"])
|
|
||||||
.index("by_provider_entityType_entityId", ["provider", "entityType", "entityId"]),
|
|
||||||
|
|
||||||
voiceSessions: defineTable({
|
voiceSessions: defineTable({
|
||||||
roomName: v.string(),
|
roomName: v.string(),
|
||||||
participantIdentity: v.string(),
|
participantIdentity: v.string(),
|
||||||
|
|
@ -474,8 +248,6 @@ export default defineSchema({
|
||||||
recordingUrl: v.optional(v.string()),
|
recordingUrl: v.optional(v.string()),
|
||||||
recordingError: v.optional(v.string()),
|
recordingError: v.optional(v.string()),
|
||||||
metadata: v.optional(v.string()),
|
metadata: v.optional(v.string()),
|
||||||
contactId: v.optional(v.id("contacts")),
|
|
||||||
conversationId: v.optional(v.id("conversations")),
|
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,6 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { mutation, query } from "./_generated/server"
|
import { mutation, query } from "./_generated/server"
|
||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
import {
|
|
||||||
ensureConversationParticipant,
|
|
||||||
upsertCallArtifactRecord,
|
|
||||||
upsertContactRecord,
|
|
||||||
upsertConversationRecord,
|
|
||||||
upsertMessageRecord,
|
|
||||||
} from "./crmModel"
|
|
||||||
|
|
||||||
async function syncPhoneConversation(ctx, session, overrides = {}) {
|
|
||||||
const contact = await upsertContactRecord(ctx, {
|
|
||||||
firstName: "Phone",
|
|
||||||
lastName: "Caller",
|
|
||||||
phone: session.participantIdentity,
|
|
||||||
livekitIdentity: session.participantIdentity,
|
|
||||||
source: "phone-agent",
|
|
||||||
status: "lead",
|
|
||||||
lastActivityAt:
|
|
||||||
overrides.lastActivityAt ?? session.updatedAt ?? session.startedAt ?? Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const conversation = await upsertConversationRecord(ctx, {
|
|
||||||
contactId: contact?._id,
|
|
||||||
title: `Phone call ${session.roomName}`,
|
|
||||||
channel: "call",
|
|
||||||
source: "phone-agent",
|
|
||||||
status:
|
|
||||||
session.callStatus === "completed"
|
|
||||||
? "closed"
|
|
||||||
: session.callStatus === "failed"
|
|
||||||
? "archived"
|
|
||||||
: "open",
|
|
||||||
direction: "inbound",
|
|
||||||
startedAt: session.startedAt,
|
|
||||||
endedAt: session.endedAt,
|
|
||||||
lastMessageAt: overrides.lastActivityAt ?? session.updatedAt ?? session.startedAt,
|
|
||||||
lastMessagePreview:
|
|
||||||
overrides.lastMessagePreview ?? session.summaryText ?? session.handoffReason,
|
|
||||||
summaryText: session.summaryText,
|
|
||||||
livekitRoomName: session.roomName,
|
|
||||||
voiceSessionId: session._id,
|
|
||||||
})
|
|
||||||
|
|
||||||
await ensureConversationParticipant(ctx, {
|
|
||||||
conversationId: conversation._id,
|
|
||||||
contactId: contact?._id,
|
|
||||||
role: "contact",
|
|
||||||
displayName: contact ? `${contact.firstName} ${contact.lastName}`.trim() : "Phone caller",
|
|
||||||
phone: contact?.phone || session.participantIdentity,
|
|
||||||
email: contact?.email,
|
|
||||||
})
|
|
||||||
|
|
||||||
await ctx.db.patch(session._id, {
|
|
||||||
contactId: contact?._id,
|
|
||||||
conversationId: conversation?._id,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
contact,
|
|
||||||
conversation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getByRoom = query({
|
export const getByRoom = query({
|
||||||
args: {
|
args: {
|
||||||
|
|
@ -145,7 +83,7 @@ export const createSession = mutation({
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const now = args.startedAt ?? Date.now()
|
const now = args.startedAt ?? Date.now()
|
||||||
const id = await ctx.db.insert("voiceSessions", {
|
return await ctx.db.insert("voiceSessions", {
|
||||||
...args,
|
...args,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
callStatus: args.callStatus,
|
callStatus: args.callStatus,
|
||||||
|
|
@ -156,13 +94,6 @@ export const createSession = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = await ctx.db.get(id)
|
|
||||||
if (session) {
|
|
||||||
await syncPhoneConversation(ctx, session)
|
|
||||||
}
|
|
||||||
|
|
||||||
return id
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -210,11 +141,7 @@ export const upsertPhoneCallSession = mutation({
|
||||||
notificationStatus: existing.notificationStatus || "pending",
|
notificationStatus: existing.notificationStatus || "pending",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
const updated = await ctx.db.get(existing._id)
|
return await ctx.db.get(existing._id)
|
||||||
if (updated) {
|
|
||||||
await syncPhoneConversation(ctx, updated)
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = await ctx.db.insert("voiceSessions", {
|
const id = await ctx.db.insert("voiceSessions", {
|
||||||
|
|
@ -237,11 +164,7 @@ export const upsertPhoneCallSession = mutation({
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = await ctx.db.get(id)
|
return await ctx.db.get(id)
|
||||||
if (session) {
|
|
||||||
await syncPhoneConversation(ctx, session)
|
|
||||||
}
|
|
||||||
return session
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -280,33 +203,6 @@ export const addTranscriptTurn = mutation({
|
||||||
: session.agentAnsweredAt,
|
: session.agentAnsweredAt,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { contact, conversation } = await syncPhoneConversation(ctx, {
|
|
||||||
...session,
|
|
||||||
updatedAt: createdAt,
|
|
||||||
}, {
|
|
||||||
lastActivityAt: createdAt,
|
|
||||||
lastMessagePreview: args.text,
|
|
||||||
})
|
|
||||||
|
|
||||||
await upsertMessageRecord(ctx, {
|
|
||||||
conversationId: conversation._id,
|
|
||||||
contactId: args.role === "user" ? contact?._id : undefined,
|
|
||||||
direction:
|
|
||||||
args.role === "user"
|
|
||||||
? "inbound"
|
|
||||||
: args.role === "assistant"
|
|
||||||
? "outbound"
|
|
||||||
: "system",
|
|
||||||
channel: "call",
|
|
||||||
source: args.source || "phone-agent",
|
|
||||||
messageType: args.kind || "transcript",
|
|
||||||
body: args.text,
|
|
||||||
sentAt: createdAt,
|
|
||||||
voiceTranscriptTurnId: turnId,
|
|
||||||
voiceSessionId: args.sessionId,
|
|
||||||
livekitRoomName: args.roomName,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return turnId
|
return turnId
|
||||||
|
|
@ -335,21 +231,8 @@ export const linkPhoneCallLead = mutation({
|
||||||
handoffReason: args.handoffReason,
|
handoffReason: args.handoffReason,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
const session = await ctx.db.get(args.sessionId)
|
|
||||||
if (session) {
|
|
||||||
const { conversation } = await syncPhoneConversation(ctx, session)
|
|
||||||
if (args.linkedLeadId || args.leadOutcome || args.handoffReason) {
|
|
||||||
await ctx.db.patch(conversation._id, {
|
|
||||||
summaryText:
|
|
||||||
session.summaryText ||
|
|
||||||
args.handoffReason ||
|
|
||||||
conversation.summaryText,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return session
|
return await ctx.db.get(args.sessionId)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -377,21 +260,7 @@ export const updateRecording = mutation({
|
||||||
recordingError: args.recordingError,
|
recordingError: args.recordingError,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
const session = await ctx.db.get(args.sessionId)
|
return 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
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -441,31 +310,7 @@ export const completeSession = mutation({
|
||||||
notificationError: args.notificationError,
|
notificationError: args.notificationError,
|
||||||
updatedAt: endedAt,
|
updatedAt: endedAt,
|
||||||
})
|
})
|
||||||
const session = await ctx.db.get(args.sessionId)
|
return 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
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { makeFunctionReference } from "convex/server"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { hasConvexUrl } from "@/lib/convex-config"
|
import { hasConvexUrl } from "@/lib/convex-config"
|
||||||
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
|
||||||
import { normalizeManualAssetValue } from "@/lib/manuals-asset-paths"
|
|
||||||
import { deriveThumbnailPathFromManualPath } from "@/lib/manuals-thumbnail-fallback"
|
|
||||||
import type { Product } from "@/lib/products/types"
|
import type { Product } from "@/lib/products/types"
|
||||||
import type { Manual } from "@/lib/manuals-types"
|
import type { Manual } from "@/lib/manuals-types"
|
||||||
|
|
||||||
|
|
@ -83,15 +80,14 @@ function getServerConvexClient(useAdminAuth: boolean) {
|
||||||
async function queryManualsWithAuthFallback<TData>(
|
async function queryManualsWithAuthFallback<TData>(
|
||||||
label: string,
|
label: string,
|
||||||
queryRef: ReturnType<typeof makeFunctionReference<"query">>,
|
queryRef: ReturnType<typeof makeFunctionReference<"query">>,
|
||||||
fallback: TData,
|
fallback: TData
|
||||||
args: Record<string, unknown> = {}
|
|
||||||
): Promise<TData> {
|
): Promise<TData> {
|
||||||
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
||||||
const adminClient = getServerConvexClient(true)
|
const adminClient = getServerConvexClient(true)
|
||||||
|
|
||||||
if (adminClient) {
|
if (adminClient) {
|
||||||
try {
|
try {
|
||||||
return (await adminClient.query(queryRef, args)) as TData
|
return (await adminClient.query(queryRef, {})) as TData
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[convex-service] ${label} admin query failed`, error)
|
console.error(`[convex-service] ${label} admin query failed`, error)
|
||||||
if (!adminKey) {
|
if (!adminKey) {
|
||||||
|
|
@ -107,7 +103,7 @@ async function queryManualsWithAuthFallback<TData>(
|
||||||
|
|
||||||
return await safeFetchQuery(
|
return await safeFetchQuery(
|
||||||
`${label}.public`,
|
`${label}.public`,
|
||||||
publicClient.query(queryRef, args),
|
publicClient.query(queryRef, {}),
|
||||||
fallback
|
fallback
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -171,13 +167,6 @@ function mapConvexProduct(product: ConvexProductDoc): Product {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapConvexManual(manual: ConvexManualDoc): Manual {
|
function mapConvexManual(manual: ConvexManualDoc): Manual {
|
||||||
const normalizedThumbnailUrl = normalizeManualAssetValue(
|
|
||||||
manual.thumbnailUrl,
|
|
||||||
"thumbnail"
|
|
||||||
)
|
|
||||||
const thumbnailUrl =
|
|
||||||
normalizedThumbnailUrl || deriveThumbnailPathFromManualPath(manual.path)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename: manual.filename,
|
filename: manual.filename,
|
||||||
path: manual.path,
|
path: manual.path,
|
||||||
|
|
@ -189,7 +178,7 @@ function mapConvexManual(manual: ConvexManualDoc): Manual {
|
||||||
: undefined,
|
: undefined,
|
||||||
searchTerms: manual.searchTerms,
|
searchTerms: manual.searchTerms,
|
||||||
commonNames: manual.commonNames,
|
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
|
return match ? mapConvexProduct(match) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listConvexManuals(domain?: string): Promise<Manual[]> {
|
export async function listConvexManuals(): Promise<Manual[]> {
|
||||||
if (!hasConvexUrl()) {
|
if (!hasConvexUrl()) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantDomain = resolveManualsTenantDomain({
|
|
||||||
requestHost: domain,
|
|
||||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
|
||||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!tenantDomain) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const manuals = await queryManualsWithAuthFallback(
|
const manuals = await queryManualsWithAuthFallback(
|
||||||
"manuals.list",
|
"manuals.list",
|
||||||
LIST_MANUALS,
|
LIST_MANUALS,
|
||||||
[] as ConvexManualDoc[],
|
[] as ConvexManualDoc[]
|
||||||
{ domain: tenantDomain }
|
|
||||||
)
|
)
|
||||||
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
|
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Parts lookup utility for frontend
|
||||||
*
|
*
|
||||||
* Provides functions to fetch parts data by manual filename.
|
* Provides functions to fetch parts data by manual filename.
|
||||||
* Static JSON remains the primary data source, while cached eBay matches
|
* Static JSON remains the primary data source, while live eBay fallback
|
||||||
* are fetched from the server so normal browsing never reaches eBay.
|
* 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 {
|
export interface PartForPage {
|
||||||
partNumber: string
|
partNumber: string
|
||||||
description: 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 {
|
interface ManualPartsLookup {
|
||||||
|
|
@ -32,40 +32,30 @@ interface ManualPagesParts {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CachedPartsResponse {
|
interface EbaySearchResult {
|
||||||
manualFilename: string
|
itemId: string
|
||||||
parts: Array<
|
title: string
|
||||||
ManualPartInput & {
|
price: string
|
||||||
ebayListings: CachedEbayListing[]
|
currency: string
|
||||||
}
|
imageUrl?: string
|
||||||
>
|
viewItemUrl: string
|
||||||
cache: EbayCacheState
|
condition?: string
|
||||||
cacheSource?: "convex" | "fallback"
|
shippingCost?: string
|
||||||
|
affiliateLink: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EbaySearchResponse {
|
||||||
|
results: EbaySearchResult[]
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CachedEbaySearchResponse {
|
// Cache for eBay search results
|
||||||
results: CachedEbayListing[]
|
const ebaySearchCache = new Map<
|
||||||
cache: EbayCacheState
|
|
||||||
cacheSource?: "convex" | "fallback"
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedManualMatchResponses = new Map<
|
|
||||||
string,
|
string,
|
||||||
{ response: CachedPartsResponse; timestamp: number }
|
{ results: EbaySearchResult[]; timestamp: number }
|
||||||
>()
|
>()
|
||||||
const inFlightManualMatchRequests = new Map<string, Promise<CachedPartsResponse>>()
|
const inFlightEbaySearches = new Map<string, Promise<EbaySearchResponse>>()
|
||||||
const MANUAL_MATCH_CACHE_TTL = 5 * 60 * 1000
|
const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
|
||||||
const cachedEbaySearchResponses = new Map<
|
|
||||||
string,
|
|
||||||
{ response: CachedEbaySearchResponse; timestamp: number }
|
|
||||||
>()
|
|
||||||
const inFlightEbaySearches = new Map<
|
|
||||||
string,
|
|
||||||
Promise<CachedEbaySearchResponse>
|
|
||||||
>()
|
|
||||||
const EBAY_SEARCH_CACHE_TTL = 5 * 60 * 1000
|
|
||||||
|
|
||||||
const GENERIC_PART_TERMS = new Set([
|
const GENERIC_PART_TERMS = new Set([
|
||||||
"and",
|
"and",
|
||||||
|
|
@ -139,196 +129,121 @@ async function loadPartsData(): Promise<{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFallbackCacheState(errorMessage?: string): EbayCacheState {
|
/**
|
||||||
return {
|
* Search eBay for parts with caching.
|
||||||
key: "manual-parts",
|
* This calls the server route so the app never needs direct eBay credentials
|
||||||
status: "disabled",
|
* in client code.
|
||||||
lastSuccessfulAt: null,
|
*/
|
||||||
lastAttemptAt: null,
|
|
||||||
nextEligibleAt: null,
|
|
||||||
lastError: errorMessage || "eBay cache unavailable.",
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
queryCount: 0,
|
|
||||||
itemCount: 0,
|
|
||||||
sourceQueries: [],
|
|
||||||
freshnessMs: null,
|
|
||||||
isStale: true,
|
|
||||||
listingCount: 0,
|
|
||||||
activeListingCount: 0,
|
|
||||||
message: errorMessage || "eBay cache unavailable.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchManualPartsMatches(
|
|
||||||
manualFilename: string,
|
|
||||||
parts: ManualPartInput[],
|
|
||||||
limit: number
|
|
||||||
): Promise<CachedPartsResponse> {
|
|
||||||
const cacheKey = [
|
|
||||||
manualFilename.trim().toLowerCase(),
|
|
||||||
String(limit),
|
|
||||||
parts
|
|
||||||
.map((part) =>
|
|
||||||
[
|
|
||||||
part.partNumber.trim().toLowerCase(),
|
|
||||||
part.description.trim().toLowerCase(),
|
|
||||||
part.manufacturer?.trim().toLowerCase() || "",
|
|
||||||
part.category?.trim().toLowerCase() || "",
|
|
||||||
].join(":")
|
|
||||||
)
|
|
||||||
.join("|"),
|
|
||||||
].join("::")
|
|
||||||
|
|
||||||
const cached = cachedManualMatchResponses.get(cacheKey)
|
|
||||||
if (cached && Date.now() - cached.timestamp < MANUAL_MATCH_CACHE_TTL) {
|
|
||||||
return cached.response
|
|
||||||
}
|
|
||||||
|
|
||||||
const inFlight = inFlightManualMatchRequests.get(cacheKey)
|
|
||||||
if (inFlight) {
|
|
||||||
return inFlight
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = (async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/ebay/manual-parts", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
manualFilename,
|
|
||||||
parts,
|
|
||||||
limit,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const body = await response.json().catch(() => null)
|
|
||||||
if (!response.ok || !body || typeof body !== "object") {
|
|
||||||
const message =
|
|
||||||
body && typeof body.error === "string"
|
|
||||||
? body.error
|
|
||||||
: `Failed to load cached parts (${response.status})`
|
|
||||||
return {
|
|
||||||
manualFilename,
|
|
||||||
parts: parts.map((part) => ({
|
|
||||||
...part,
|
|
||||||
ebayListings: [],
|
|
||||||
})),
|
|
||||||
cache: makeFallbackCacheState(message),
|
|
||||||
error: message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const partsResponse = body as CachedPartsResponse
|
|
||||||
return {
|
|
||||||
manualFilename: partsResponse.manualFilename || manualFilename,
|
|
||||||
parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [],
|
|
||||||
cache: partsResponse.cache || makeFallbackCacheState(),
|
|
||||||
error:
|
|
||||||
typeof (partsResponse as CachedPartsResponse).error === "string"
|
|
||||||
? (partsResponse as CachedPartsResponse).error
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Failed to load cached parts"
|
|
||||||
return {
|
|
||||||
manualFilename,
|
|
||||||
parts: parts.map((part) => ({
|
|
||||||
...part,
|
|
||||||
ebayListings: [],
|
|
||||||
})),
|
|
||||||
cache: makeFallbackCacheState(message),
|
|
||||||
error: message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
inFlightManualMatchRequests.set(cacheKey, request)
|
|
||||||
try {
|
|
||||||
const response = await request
|
|
||||||
cachedManualMatchResponses.set(cacheKey, {
|
|
||||||
response,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
return response
|
|
||||||
} finally {
|
|
||||||
inFlightManualMatchRequests.delete(cacheKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchEBayForParts(
|
async function searchEBayForParts(
|
||||||
partNumber: string,
|
partNumber: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
manufacturer?: string
|
manufacturer?: string
|
||||||
): Promise<CachedEbaySearchResponse> {
|
): Promise<EbaySearchResponse> {
|
||||||
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
|
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
|
||||||
|
|
||||||
const cached = cachedEbaySearchResponses.get(cacheKey)
|
// Check cache
|
||||||
if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) {
|
const cached = ebaySearchCache.get(cacheKey)
|
||||||
return cached.response
|
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
|
||||||
|
return { results: cached.results as EbaySearchResult[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const inFlight = inFlightEbaySearches.get(cacheKey)
|
const buildQuery = () => {
|
||||||
|
let query = partNumber
|
||||||
|
|
||||||
|
if (description && description.length > 0 && description.length < 50) {
|
||||||
|
const descWords = description
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((word) => word.length > 3)
|
||||||
|
.slice(0, 3)
|
||||||
|
.join(" ")
|
||||||
|
|
||||||
|
if (descWords) {
|
||||||
|
query += ` ${descWords}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manufacturer) {
|
||||||
|
query += ` ${manufacturer}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${query} vending machine`
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchViaApi = async (
|
||||||
|
categoryId?: string
|
||||||
|
): Promise<EbaySearchResponse> => {
|
||||||
|
const requestKey = `${cacheKey}:${categoryId || "general"}`
|
||||||
|
|
||||||
|
const inFlight = inFlightEbaySearches.get(requestKey)
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
return inFlight
|
return inFlight
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (async () => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
keywords: [partNumber, description, manufacturer, "vending machine"]
|
keywords: buildQuery(),
|
||||||
.filter(Boolean)
|
|
||||||
.join(" "),
|
|
||||||
maxResults: "3",
|
maxResults: "3",
|
||||||
sortOrder: "BestMatch",
|
sortOrder: "BestMatch",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
params.set("categoryId", categoryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
const response = await fetch(`/api/ebay/search?${params.toString()}`)
|
const response = await fetch(`/api/ebay/search?${params.toString()}`)
|
||||||
const body = await response.json().catch(() => null)
|
const body = await response.json().catch(() => null)
|
||||||
|
|
||||||
if (!response.ok || !body || typeof body !== "object") {
|
if (!response.ok) {
|
||||||
const message =
|
const message =
|
||||||
body && typeof body.error === "string"
|
body && typeof body.error === "string"
|
||||||
? body.error
|
? body.error
|
||||||
: `Failed to load cached eBay listings (${response.status})`
|
: `eBay API error: ${response.status}`
|
||||||
return {
|
|
||||||
results: [],
|
return { results: [], error: message }
|
||||||
cache: makeFallbackCacheState(message),
|
|
||||||
error: message,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const results = Array.isArray(body) ? body : []
|
||||||
results: Array.isArray((body as any).results)
|
return { 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) {
|
} catch (error) {
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Failed to load cached eBay listings"
|
|
||||||
return {
|
return {
|
||||||
results: [],
|
results: [],
|
||||||
cache: makeFallbackCacheState(message),
|
error:
|
||||||
error: message,
|
error instanceof Error ? error.message : "Failed to search eBay",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
inFlightEbaySearches.set(cacheKey, request)
|
inFlightEbaySearches.set(requestKey, request)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request
|
return await request
|
||||||
cachedEbaySearchResponses.set(cacheKey, {
|
} finally {
|
||||||
response,
|
inFlightEbaySearches.delete(requestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorySearch = await searchViaApi("11700")
|
||||||
|
if (categorySearch.results.length > 0) {
|
||||||
|
ebaySearchCache.set(cacheKey, {
|
||||||
|
results: categorySearch.results,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
return response
|
return categorySearch
|
||||||
} finally {
|
}
|
||||||
inFlightEbaySearches.delete(cacheKey)
|
|
||||||
|
const generalSearch = await searchViaApi()
|
||||||
|
if (generalSearch.results.length > 0) {
|
||||||
|
ebaySearchCache.set(cacheKey, {
|
||||||
|
results: generalSearch.results,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
return generalSearch
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: [],
|
||||||
|
error: categorySearch.error || generalSearch.error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,6 +251,20 @@ function normalizePartText(value: string): string {
|
||||||
return value.trim().toLowerCase()
|
return value.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSyntheticEbayListing(
|
||||||
|
listing: PartForPage["ebayListings"][number]
|
||||||
|
): boolean {
|
||||||
|
const itemId = listing.itemId?.trim() || ""
|
||||||
|
const viewItemUrl = listing.viewItemUrl?.trim() || ""
|
||||||
|
const imageUrl = listing.imageUrl?.trim() || ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
imageUrl.includes("images.unsplash.com") ||
|
||||||
|
viewItemUrl.includes("123456789") ||
|
||||||
|
itemId.startsWith("123456789")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function hasLiveEbayListings(listings: PartForPage["ebayListings"]): boolean {
|
function hasLiveEbayListings(listings: PartForPage["ebayListings"]): boolean {
|
||||||
return listings.some((listing) => !isSyntheticEbayListing(listing))
|
return listings.some((listing) => !isSyntheticEbayListing(listing))
|
||||||
}
|
}
|
||||||
|
|
@ -533,13 +462,6 @@ async function getPartsForManualWithStatus(manualFilename: string): Promise<{
|
||||||
return { parts }
|
return { parts }
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizePartListings(parts: PartForPage[]): PartForPage[] {
|
|
||||||
return parts.map((part) => ({
|
|
||||||
...part,
|
|
||||||
ebayListings: filterTrustedEbayListings(part.ebayListings || []),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all parts for a manual with enhanced eBay data
|
* Get all parts for a manual with enhanced eBay data
|
||||||
*/
|
*/
|
||||||
|
|
@ -547,7 +469,7 @@ export async function getPartsForManual(
|
||||||
manualFilename: string
|
manualFilename: string
|
||||||
): Promise<PartForPage[]> {
|
): Promise<PartForPage[]> {
|
||||||
const result = await getPartsForManualWithStatus(manualFilename)
|
const result = await getPartsForManualWithStatus(manualFilename)
|
||||||
return sanitizePartListings(result.parts)
|
return result.parts
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -568,17 +490,8 @@ export async function getPartsForPage(
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = await fetchManualPartsMatches(
|
const enhanced = await enhancePartsData(parts)
|
||||||
manualFilename,
|
return enhanced.parts
|
||||||
parts.map((part) => ({
|
|
||||||
partNumber: part.partNumber,
|
|
||||||
description: part.description,
|
|
||||||
manualFilename,
|
|
||||||
})),
|
|
||||||
Math.max(parts.length, 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
return sanitizePartListings(matched.parts as PartForPage[])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -590,7 +503,6 @@ export async function getTopPartsForManual(
|
||||||
): Promise<{
|
): Promise<{
|
||||||
parts: PartForPage[]
|
parts: PartForPage[]
|
||||||
error?: string
|
error?: string
|
||||||
cache?: EbayCacheState
|
|
||||||
}> {
|
}> {
|
||||||
const { parts } = await getPartsForManualWithStatus(manualFilename)
|
const { parts } = await getPartsForManualWithStatus(manualFilename)
|
||||||
|
|
||||||
|
|
@ -602,20 +514,23 @@ export async function getTopPartsForManual(
|
||||||
parts,
|
parts,
|
||||||
Math.max(limit * 2, limit)
|
Math.max(limit * 2, limit)
|
||||||
)
|
)
|
||||||
const matched = await fetchManualPartsMatches(
|
const { parts: enrichedParts, error } =
|
||||||
manualFilename,
|
await enhancePartsData(liveSearchCandidates)
|
||||||
liveSearchCandidates.map((part) => ({
|
|
||||||
partNumber: part.partNumber,
|
const sorted = enrichedParts.sort((a, b) => {
|
||||||
description: part.description,
|
const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0
|
||||||
manualFilename,
|
const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0
|
||||||
})),
|
|
||||||
limit
|
if (aHasLiveListings !== bHasLiveListings) {
|
||||||
)
|
return bHasLiveListings - aHasLiveListings
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.ebayListings.length - a.ebayListings.length
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parts: sanitizePartListings(matched.parts as PartForPage[]),
|
parts: sorted.slice(0, limit),
|
||||||
error: matched.error,
|
error,
|
||||||
cache: matched.cache,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,9 @@ export type AdminPhoneCallDetail = {
|
||||||
id: string
|
id: string
|
||||||
roomName: string
|
roomName: string
|
||||||
participantIdentity: string
|
participantIdentity: string
|
||||||
callerPhone?: string
|
|
||||||
pathname?: string
|
pathname?: string
|
||||||
pageUrl?: string
|
pageUrl?: string
|
||||||
source?: string
|
source?: string
|
||||||
contactProfileId?: string
|
|
||||||
contactDisplayName?: string
|
|
||||||
contactCompany?: string
|
|
||||||
startedAt: number
|
startedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
durationMs: number | null
|
durationMs: number | null
|
||||||
|
|
@ -37,18 +33,6 @@ export type AdminPhoneCallDetail = {
|
||||||
notificationStatus: "pending" | "sent" | "failed" | "disabled"
|
notificationStatus: "pending" | "sent" | "failed" | "disabled"
|
||||||
notificationSentAt?: number
|
notificationSentAt?: number
|
||||||
notificationError?: string
|
notificationError?: string
|
||||||
reminderStatus?: "none" | "scheduled" | "sameDay"
|
|
||||||
reminderRequestedAt?: number
|
|
||||||
reminderStartAt?: number
|
|
||||||
reminderEndAt?: number
|
|
||||||
reminderCalendarEventId?: string
|
|
||||||
reminderCalendarHtmlLink?: string
|
|
||||||
reminderNote?: string
|
|
||||||
warmTransferStatus?: "none" | "attempted" | "connected" | "failed" | "fallback"
|
|
||||||
warmTransferTarget?: string
|
|
||||||
warmTransferAttemptedAt?: number
|
|
||||||
warmTransferConnectedAt?: number
|
|
||||||
warmTransferFailureReason?: string
|
|
||||||
recordingStatus?:
|
recordingStatus?:
|
||||||
| "pending"
|
| "pending"
|
||||||
| "starting"
|
| "starting"
|
||||||
|
|
@ -71,21 +55,6 @@ export type AdminPhoneCallDetail = {
|
||||||
message?: string
|
message?: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
contactProfile: null | {
|
|
||||||
_id: string
|
|
||||||
normalizedPhone: string
|
|
||||||
displayName?: string
|
|
||||||
firstName?: string
|
|
||||||
lastName?: string
|
|
||||||
email?: string
|
|
||||||
company?: string
|
|
||||||
lastIntent?: string
|
|
||||||
lastLeadOutcome?: "none" | "contact" | "requestMachine"
|
|
||||||
lastSummaryText?: string
|
|
||||||
lastCallAt?: number
|
|
||||||
lastReminderAt?: number
|
|
||||||
reminderNotes?: string
|
|
||||||
}
|
|
||||||
turns: AdminPhoneCallTurn[]
|
turns: AdminPhoneCallTurn[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,39 +121,20 @@ export function buildPhoneCallSummary(
|
||||||
""
|
""
|
||||||
|
|
||||||
const callerNumber =
|
const callerNumber =
|
||||||
detail.call.callerPhone ||
|
|
||||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||||
detail.call.participantIdentity
|
detail.call.participantIdentity
|
||||||
const parts = [
|
const parts = [
|
||||||
`Caller: ${detail.call.contactDisplayName || callerNumber || "Unknown caller"}.`,
|
`Caller: ${callerNumber || "Unknown caller"}.`,
|
||||||
answeredLabel,
|
answeredLabel,
|
||||||
leadLabel,
|
leadLabel,
|
||||||
]
|
]
|
||||||
|
|
||||||
if (detail.call.contactCompany) {
|
|
||||||
parts.push(`Company: ${detail.call.contactCompany}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail.call.handoffRequested) {
|
if (detail.call.handoffRequested) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`
|
`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detail.call.reminderStatus === "sameDay") {
|
|
||||||
parts.push("A same-day follow-up reminder was created for Matt.")
|
|
||||||
} else if (detail.call.reminderStatus === "scheduled") {
|
|
||||||
parts.push(
|
|
||||||
`A follow-up reminder was scheduled for ${formatPhoneCallTimestamp(detail.call.reminderStartAt)}.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail.call.warmTransferStatus && detail.call.warmTransferStatus !== "none") {
|
|
||||||
parts.push(
|
|
||||||
`Warm transfer status: ${detail.call.warmTransferStatus}${detail.call.warmTransferFailureReason ? ` (${detail.call.warmTransferFailureReason})` : ""}.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leadMessage) {
|
if (leadMessage) {
|
||||||
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`)
|
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`)
|
||||||
}
|
}
|
||||||
|
|
@ -219,10 +169,8 @@ export async function sendPhoneCallSummaryEmail(args: {
|
||||||
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`
|
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`
|
||||||
const summaryText = buildPhoneCallSummary(args.detail)
|
const summaryText = buildPhoneCallSummary(args.detail)
|
||||||
const callerNumber =
|
const callerNumber =
|
||||||
args.detail.call.callerPhone ||
|
|
||||||
normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
|
normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
|
||||||
"Unknown caller"
|
"Unknown caller"
|
||||||
const callerLabel = args.detail.call.contactDisplayName || callerNumber
|
|
||||||
const statusLabel = args.detail.call.callStatus.toUpperCase()
|
const statusLabel = args.detail.call.callStatus.toUpperCase()
|
||||||
|
|
||||||
const transcriptHtml = args.detail.turns
|
const transcriptHtml = args.detail.turns
|
||||||
|
|
@ -241,24 +189,13 @@ export async function sendPhoneCallSummaryEmail(args: {
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.6;">
|
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.6;">
|
||||||
<h1 style="font-size: 20px; margin-bottom: 16px;">Rocky Mountain Vending phone call summary</h1>
|
<h1 style="font-size: 20px; margin-bottom: 16px;">Rocky Mountain Vending phone call summary</h1>
|
||||||
<p><strong>Caller:</strong> ${callerLabel}</p>
|
<p><strong>Caller:</strong> ${callerNumber}</p>
|
||||||
<p><strong>Caller number:</strong> ${callerNumber}</p>
|
|
||||||
<p><strong>Company:</strong> ${args.detail.call.contactCompany || "Unknown"}</p>
|
|
||||||
<p><strong>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p>
|
<p><strong>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p>
|
||||||
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
|
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
|
||||||
<p><strong>Call status:</strong> ${statusLabel}</p>
|
<p><strong>Call status:</strong> ${statusLabel}</p>
|
||||||
<p><strong>Jessica answered:</strong> ${args.detail.call.answered ? "Yes" : "No"}</p>
|
<p><strong>Jessica answered:</strong> ${args.detail.call.answered ? "Yes" : "No"}</p>
|
||||||
<p><strong>Lead outcome:</strong> ${args.detail.call.leadOutcome}</p>
|
<p><strong>Lead outcome:</strong> ${args.detail.call.leadOutcome}</p>
|
||||||
<p><strong>Handoff requested:</strong> ${args.detail.call.handoffRequested ? "Yes" : "No"}</p>
|
<p><strong>Handoff requested:</strong> ${args.detail.call.handoffRequested ? "Yes" : "No"}</p>
|
||||||
<p><strong>Reminder status:</strong> ${args.detail.call.reminderStatus || "none"}</p>
|
|
||||||
<p><strong>Reminder time:</strong> ${formatPhoneCallTimestamp(args.detail.call.reminderStartAt)}</p>
|
|
||||||
<p><strong>Reminder link:</strong> ${
|
|
||||||
args.detail.call.reminderCalendarHtmlLink
|
|
||||||
? `<a href="${args.detail.call.reminderCalendarHtmlLink}">${args.detail.call.reminderCalendarHtmlLink}</a>`
|
|
||||||
: "No reminder link"
|
|
||||||
}</p>
|
|
||||||
<p><strong>Warm transfer:</strong> ${args.detail.call.warmTransferStatus || "none"}</p>
|
|
||||||
<p><strong>Warm transfer details:</strong> ${args.detail.call.warmTransferFailureReason || "—"}</p>
|
|
||||||
<p><strong>Recording status:</strong> ${args.detail.call.recordingStatus || "Unavailable"}</p>
|
<p><strong>Recording status:</strong> ${args.detail.call.recordingStatus || "Unavailable"}</p>
|
||||||
<p><strong>Recording URL:</strong> ${
|
<p><strong>Recording URL:</strong> ${
|
||||||
args.detail.call.recordingUrl
|
args.detail.call.recordingUrl
|
||||||
|
|
@ -277,7 +214,7 @@ export async function sendPhoneCallSummaryEmail(args: {
|
||||||
await sendTransactionalEmail({
|
await sendTransactionalEmail({
|
||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
to: adminEmail,
|
to: adminEmail,
|
||||||
subject: `[RMV Phone] ${statusLabel} call from ${callerLabel}`,
|
subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`,
|
||||||
html,
|
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 { NextResponse } from "next/server"
|
||||||
import { api } from "@/convex/_generated/api"
|
|
||||||
import { hasConvexUrl } from "@/lib/convex-config"
|
|
||||||
|
|
||||||
export const ADMIN_SESSION_COOKIE = "rmv_admin_session"
|
|
||||||
const ADMIN_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7
|
|
||||||
|
|
||||||
function getProvidedToken(request: Request) {
|
function getProvidedToken(request: Request) {
|
||||||
const authHeader = request.headers.get("authorization") || ""
|
const authHeader = request.headers.get("authorization") || ""
|
||||||
|
|
@ -38,123 +30,3 @@ export function requireAdminToken(request: Request) {
|
||||||
export function isAdminUiEnabled() {
|
export function isAdminUiEnabled() {
|
||||||
return process.env.ADMIN_UI_ENABLED === "true"
|
return process.env.ADMIN_UI_ENABLED === "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfiguredAdminEmail() {
|
|
||||||
return String(process.env.ADMIN_EMAIL || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfiguredAdminPassword() {
|
|
||||||
return String(process.env.ADMIN_PASSWORD || "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashAdminSessionToken(token: string) {
|
|
||||||
return createHash("sha256").update(token).digest("hex")
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCookieFromHeader(cookieHeader: string, name: string) {
|
|
||||||
const cookies = cookieHeader.split(";")
|
|
||||||
for (const entry of cookies) {
|
|
||||||
const [cookieName, ...rest] = entry.trim().split("=")
|
|
||||||
if (cookieName === name) {
|
|
||||||
return rest.join("=")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAdminCredentialLoginConfigured() {
|
|
||||||
return Boolean(
|
|
||||||
isAdminUiEnabled() &&
|
|
||||||
hasConvexUrl() &&
|
|
||||||
getConfiguredAdminEmail() &&
|
|
||||||
getConfiguredAdminPassword()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAdminCredentialMatch(email: string, password: string) {
|
|
||||||
return (
|
|
||||||
email.trim().toLowerCase() === getConfiguredAdminEmail() &&
|
|
||||||
password === getConfiguredAdminPassword()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAdminSession(email: string) {
|
|
||||||
if (!hasConvexUrl()) {
|
|
||||||
throw new Error("Convex is not configured for admin sessions.")
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEmail = email.trim().toLowerCase()
|
|
||||||
const rawToken = randomBytes(32).toString("hex")
|
|
||||||
const tokenHash = hashAdminSessionToken(rawToken)
|
|
||||||
const expiresAt = Date.now() + ADMIN_SESSION_TTL_MS
|
|
||||||
|
|
||||||
await fetchMutation(api.admin.ensureAdminUser, {
|
|
||||||
email: normalizedEmail,
|
|
||||||
name: normalizedEmail.split("@")[0],
|
|
||||||
})
|
|
||||||
|
|
||||||
await fetchMutation(api.admin.createSession, {
|
|
||||||
email: normalizedEmail,
|
|
||||||
tokenHash,
|
|
||||||
expiresAt,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
token: rawToken,
|
|
||||||
expiresAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function destroyAdminSession(rawToken?: string | null) {
|
|
||||||
if (!rawToken || !hasConvexUrl()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetchMutation(api.admin.destroySession, {
|
|
||||||
tokenHash: hashAdminSessionToken(rawToken),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to destroy admin session:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateAdminSession(rawToken?: string | null) {
|
|
||||||
if (!rawToken || !hasConvexUrl()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await fetchQuery(api.admin.validateSession, {
|
|
||||||
tokenHash: hashAdminSessionToken(rawToken),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to validate admin session:", error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAdminSession(request: Request) {
|
|
||||||
const rawToken = readCookieFromHeader(
|
|
||||||
request.headers.get("cookie") || "",
|
|
||||||
ADMIN_SESSION_COOKIE
|
|
||||||
)
|
|
||||||
const session = await validateAdminSession(rawToken || null)
|
|
||||||
if (!session?.user) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return session.user
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminUserFromCookies() {
|
|
||||||
if (!isAdminUiEnabled()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value
|
|
||||||
const session = await validateAdminSession(rawToken)
|
|
||||||
return session?.user || null
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -143,16 +143,12 @@ function getConfiguredTenantDomains() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultDeps(): LeadSubmissionDeps {
|
function defaultDeps(): LeadSubmissionDeps {
|
||||||
const ghlSyncEnabled = String(process.env.ENABLE_GHL_SYNC || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase() === "true"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storageConfigured: isConvexConfigured(),
|
storageConfigured: isConvexConfigured(),
|
||||||
emailConfigured: isEmailConfigured(),
|
emailConfigured: isEmailConfigured(),
|
||||||
ghlConfigured:
|
ghlConfigured: Boolean(
|
||||||
ghlSyncEnabled &&
|
process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID
|
||||||
Boolean(process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID),
|
),
|
||||||
ingest: ingestLead,
|
ingest: ingestLead,
|
||||||
updateLeadStatus: updateLeadSyncStatus,
|
updateLeadStatus: updateLeadSyncStatus,
|
||||||
sendEmail: (to, subject, html, replyTo) =>
|
sendEmail: (to, subject, html, replyTo) =>
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"copy:check": "node scripts/check-public-copy.mjs",
|
"copy:check": "node scripts/check-public-copy.mjs",
|
||||||
"diagnose:ebay": "node scripts/staging-smoke.mjs",
|
|
||||||
"deploy:staging:convex-gate": "node scripts/check-convex-manuals-gate.mjs",
|
|
||||||
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
|
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
|
||||||
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build && pnpm deploy:staging:convex-gate",
|
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
|
||||||
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
|
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||||
"manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts",
|
|
||||||
"manuals:qdrant:eval": "tsx scripts/evaluate-manuals-qdrant-corpus.ts",
|
|
||||||
"manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
|
"manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
|
||||||
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
|
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
|
||||||
"manuals:backfill:tenant": "tsx scripts/backfill-convex-manuals-tenant.ts",
|
|
||||||
"ghl:export:call-transcripts": "tsx scripts/export-ghl-call-transcripts.ts",
|
|
||||||
"ghl:export:outbound-call-transcripts": "tsx scripts/export-ghl-outbound-call-transcripts.ts",
|
|
||||||
"convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"",
|
"convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|
@ -37,7 +30,6 @@
|
||||||
"lighthouse:ci": "lighthouse-ci autorun",
|
"lighthouse:ci": "lighthouse-ci autorun",
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
"generate:links": "node scripts/generate-internal-links.js",
|
"generate:links": "node scripts/generate-internal-links.js",
|
||||||
"contacts:import:ghl": "tsx scripts/import-ghl-contacts-to-contact-profiles.ts",
|
|
||||||
"links": "node scripts/generate-internal-links.js",
|
"links": "node scripts/generate-internal-links.js",
|
||||||
"mcp": "pnpm dlx shadcn@latest mcp",
|
"mcp": "pnpm dlx shadcn@latest mcp",
|
||||||
"seo:sitemap": "node scripts/seo-internal-link-tool.js sitemap",
|
"seo:sitemap": "node scripts/seo-internal-link-tool.js sitemap",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
return String(process.env[name] ?? "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeDomain(input) {
|
|
||||||
return String(input || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^https?:\/\//, "")
|
|
||||||
.replace(/^\/\//, "")
|
|
||||||
.replace(/\/.*$/, "")
|
|
||||||
.replace(/:\d+$/, "")
|
|
||||||
.replace(/\.$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidHttpUrl(value) {
|
|
||||||
if (!value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(value)
|
|
||||||
return url.protocol === "https:" || url.protocol === "http:"
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidHostname(value) {
|
|
||||||
const host = canonicalizeDomain(value)
|
|
||||||
return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasVoiceRecordingConfig() {
|
function hasVoiceRecordingConfig() {
|
||||||
return [
|
return [
|
||||||
readValue("VOICE_RECORDING_ACCESS_KEY_ID") ||
|
readValue("VOICE_RECORDING_ACCESS_KEY_ID") ||
|
||||||
|
|
@ -263,21 +234,6 @@ function main() {
|
||||||
console.log(`${group.label}: fallback in use`)
|
console.log(`${group.label}: fallback in use`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const convexUrl = readValue("NEXT_PUBLIC_CONVEX_URL")
|
|
||||||
if (!isValidHttpUrl(convexUrl)) {
|
|
||||||
failures.push(
|
|
||||||
"NEXT_PUBLIC_CONVEX_URL is malformed. It must be a full http(s) URL."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const siteDomain =
|
|
||||||
readValue("MANUALS_TENANT_DOMAIN") || readValue("NEXT_PUBLIC_SITE_DOMAIN")
|
|
||||||
if (!isValidHostname(siteDomain)) {
|
|
||||||
failures.push(
|
|
||||||
"NEXT_PUBLIC_SITE_DOMAIN (or MANUALS_TENANT_DOMAIN) is malformed. It must be a valid hostname."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasManualStorageCredentials()) {
|
if (!hasManualStorageCredentials()) {
|
||||||
failures.push(
|
failures.push(
|
||||||
"Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release."
|
"Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release."
|
||||||
|
|
|
||||||
|
|
@ -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 { businessConfig } from "../lib/seo-config"
|
||||||
import { getSiteDomain } from "../lib/site-config"
|
import { getSiteDomain } from "../lib/site-config"
|
||||||
import { selectManualsForSite } from "../lib/manuals-site-selection"
|
import { selectManualsForSite } from "../lib/manuals-site-selection"
|
||||||
import {
|
|
||||||
canonicalizeTenantDomain,
|
|
||||||
resolveManualsTenantDomain,
|
|
||||||
tenantDomainVariants,
|
|
||||||
} from "../lib/manuals-tenant"
|
|
||||||
import {
|
import {
|
||||||
getManualsFilesRoot,
|
getManualsFilesRoot,
|
||||||
getManualsThumbnailsRoot,
|
getManualsThumbnailsRoot,
|
||||||
|
|
@ -78,15 +73,8 @@ function readConvexUrl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalSiteVisibility(siteDomain: string) {
|
function canonicalSiteVisibility(siteDomain: string) {
|
||||||
const canonicalHost = canonicalizeTenantDomain(
|
const canonicalHost = new URL(businessConfig.website).hostname
|
||||||
new URL(businessConfig.website).hostname
|
return Array.from(new Set([siteDomain, canonicalHost]))
|
||||||
)
|
|
||||||
return Array.from(
|
|
||||||
new Set([
|
|
||||||
...tenantDomainVariants(siteDomain),
|
|
||||||
...tenantDomainVariants(canonicalHost),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadSelectedAssets(
|
async function uploadSelectedAssets(
|
||||||
|
|
@ -181,17 +169,7 @@ function normalizeManualForConvex(
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = parseArgs(process.argv.slice(2))
|
const args = parseArgs(process.argv.slice(2))
|
||||||
const siteDomain = resolveManualsTenantDomain({
|
const siteDomain = getSiteDomain()
|
||||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
|
||||||
envSiteDomain: getSiteDomain(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!siteDomain) {
|
|
||||||
throw new Error(
|
|
||||||
"Could not resolve manuals tenant domain. Set MANUALS_TENANT_DOMAIN or NEXT_PUBLIC_SITE_DOMAIN."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const allManuals = await scanManuals()
|
const allManuals = await scanManuals()
|
||||||
const selection = selectManualsForSite(allManuals, siteDomain)
|
const selection = selectManualsForSite(allManuals, siteDomain)
|
||||||
const selectedManuals = args.limit
|
const selectedManuals = args.limit
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue