Compare commits

..

32 commits

Author SHA1 Message Date
656b78bf8e
deploy: polish public page formatting 2026-04-17 12:27:34 -06:00
7144aa4943
fix: keep tenant thumbnail paths in production manuals render 2026-04-16 16:36:14 -06:00
508a8bbe5e
fix: add shared manual asset path normalization utility 2026-04-16 16:24:06 -06:00
5d3ee2c4d7
fix: add missing ebay parts visibility helper module 2026-04-16 16:18:31 -06:00
23f1ed6297
fix: restore manual thumbnails and hide empty ebay parts UI 2026-04-16 16:07:42 -06:00
f077966bb2
fix: enforce tenant-scoped manuals and deployment gates 2026-04-16 15:09:50 -06:00
c5e40c5caf
fix: improve ghl conversation sync mapping 2026-04-16 14:23:25 -06:00
013a908d92
feat: improve ghl conversation sync and inbox actions 2026-04-16 14:05:12 -06:00
e294117e6e
feat: rebuild CRM inbox and contact mapping 2026-04-16 13:30:18 -06:00
14cb8ce1fc
deploy: polish public marketing pages 2026-04-16 13:03:12 -06:00
9dfee33e49
fix: normalize GHL CRM sync statuses 2026-04-16 12:08:47 -06:00
7786336cfb
fix: simplify CRM sync status messaging 2026-04-16 11:40:59 -06:00
133ed6d6f3
feat: add GHL CRM sync status and runner 2026-04-16 11:40:19 -06:00
a1799715c6
fix: prefer public origin for admin auth redirects 2026-04-16 11:14:15 -06:00
4828f044fa
fix: use public admin auth redirects 2026-04-16 11:11:29 -06:00
e326cc6bba
feat: ship CRM admin and staging sign-in 2026-04-16 11:02:22 -06:00
c0914c92b4
fix: cap rate-limit backoff to daily ebay poll window 2026-04-10 16:19:30 -06:00
e2953a382b
fix: point eBay refresh cron at public action 2026-04-10 16:17:35 -06:00
bcc39664de
fix: make ebay refresh action callable from admin API 2026-04-10 16:08:21 -06:00
5b6ad66c24
fix: restore admin ebay refresh via public convex action wrapper 2026-04-10 16:01:14 -06:00
1f46c2b390
fix: enforce trusted ebay cache listings for manuals affiliate flow 2026-04-10 15:21:00 -06:00
b67bb1e183
fix: degrade phone followups without calendar creds 2026-04-10 13:20:41 -06:00
bc2edc04f2
feat: add local RMV tool stack for phone agent 2026-04-10 13:17:34 -06:00
8fff380b24
deploy: stabilize manuals eBay cache flow and smoke diagnostics 2026-04-08 11:27:59 -06:00
087fda7ce6
Add manuals knowledge retrieval and corpus tooling 2026-04-07 15:38:55 -06:00
96ad13d6a9
deploy: publish unpublished site updates 2026-04-06 13:45:46 -06:00
d496a58935
deploy: restore registry seo helpers 2026-04-04 07:53:52 -06:00
0be731e474
deploy: unify public UI and mobile chat 2026-04-04 07:46:46 -06:00
1c1c01069c
deploy: ship 2026 local SEO overhaul 2026-04-02 16:28:15 -06:00
1948fd564e
deploy: fix manuals eBay live search 2026-04-01 15:23:47 -06:00
60b70e46ab
tune: make jessica intake feel more natural 2026-04-01 15:08:31 -06:00
975fc06136
deploy: add ebay marketplace notifications 2026-04-01 14:42:41 -06:00
436 changed files with 38039 additions and 13379 deletions

View file

@ -73,8 +73,11 @@ jspm_packages/
tmp/ tmp/
temp/ temp/
.pnpm-store/ .pnpm-store/
.formatting-backups/
.cursor/ .cursor/
.playwright-cli/
output/
docs/
artifacts/
# Logs # Logs
logs logs

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View file

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

View file

@ -1,28 +1,81 @@
NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app # Current Rocky Mountain Vending staging env contract.
NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app # Fill these in through Coolify-managed environment variables only.
CONVEX_URL= # Core site
CONVEX_SELF_HOSTED_URL= NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
CONVEX_SELF_HOSTED_ADMIN_KEY= NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
CONVEX_TENANT_SLUG=rocky_mountain_vending NEXT_PUBLIC_CONVEX_URL=
CONVEX_TENANT_NAME=Rocky Mountain Vending
ADMIN_UI_ENABLED=true
ADMIN_API_TOKEN=
ADMIN_EMAIL=
PHONE_AGENT_INTERNAL_TOKEN=
PHONE_CALL_SUMMARY_FROM_EMAIL=
USESEND_API_KEY=
USESEND_BASE_URL=
USESEND_FROM_EMAIL=info@rockymountainvending.com
CONTACT_FORM_TO_EMAIL=info@rockymountainvending.com
GHL_API_TOKEN=
GHL_LOCATION_ID=
# Voice and chat
LIVEKIT_URL= LIVEKIT_URL=
LIVEKIT_API_KEY= LIVEKIT_API_KEY=
LIVEKIT_API_SECRET= LIVEKIT_API_SECRET=
VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app XAI_API_KEY=
XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning
VOICE_ASSISTANT_SITE_URL=https://rockymountainvending.com
PHONE_AGENT_INTERNAL_TOKEN=
NEXT_PUBLIC_CALL_PHONE_DISPLAY=(435) 233-9668
NEXT_PUBLIC_CALL_PHONE_E164=+14352339668
NEXT_PUBLIC_SMS_PHONE_DISPLAY=(435) 233-9668
NEXT_PUBLIC_SMS_PHONE_E164=+14352339668
NEXT_PUBLIC_MANUALS_BASE_URL=
NEXT_PUBLIC_THUMBNAILS_BASE_URL=
VOICE_RECORDING_ENABLED=false
VOICE_RECORDING_BUCKET=
VOICE_RECORDING_ENDPOINT=
VOICE_RECORDING_PUBLIC_BASE_URL=
VOICE_RECORDING_ACCESS_KEY_ID=
VOICE_RECORDING_SECRET_ACCESS_KEY=
VOICE_RECORDING_REGION=auto
# Admin and auth
ADMIN_EMAIL=
ADMIN_PASSWORD=
RESEND_API_KEY=
PHONE_CALL_SUMMARY_FROM_EMAIL=
ENABLE_GHL_SYNC=false
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
GOOGLE_CALENDAR_REFRESH_TOKEN=
GOOGLE_CALENDAR_ID=
GOOGLE_CALENDAR_TIMEZONE=America/Denver
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Stripe
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
# GHL handoff + sync (API-only mode)
GHL_PRIVATE_INTEGRATION_TOKEN=
GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG
GHL_API_VERSION=2021-07-28
GHL_API_BASE_URL=https://services.leadconnectorhq.com
GHL_SYNC_CRON_TOKEN=
GHL_SYNC_INTERVAL_MINUTES=15
GHL_SYNC_PAGE_SIZE=100
# Optional/deprecated in API-only mode
GHL_WEBHOOK_SHARED_SECRET=
GHL_CONTACT_WEBHOOK_URL=
GHL_REQUEST_MACHINE_WEBHOOK_URL=
# eBay API credentials for manuals-side fallback
EBAY_APP_ID=
EBAY_DEV_ID=
EBAY_CERT_ID=
EBAY_SANDBOX_TOKEN=
EBAY_AFFILIATE_CAMPAIGN_ID=
# eBay marketplace account deletion notifications
# Use the exact public HTTPS endpoint that eBay validates in the developer portal.
EBAY_NOTIFICATION_ENDPOINT=https://rmv.abundancepartners.app/api/ebay/notifications
EBAY_NOTIFICATION_VERIFICATION_TOKEN=
EBAY_NOTIFICATION_APP_ID=
EBAY_NOTIFICATION_CERT_ID=
EBAY_NOTIFICATION_API_BASE_URL=https://api.ebay.com
EBAY_NOTIFICATION_SCOPE=https://api.ebay.com/oauth/api_scope

12
.gitignore vendored
View file

@ -1,11 +1,10 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules /node_modules
# next.js # next.js
/.next/ /.next/
/out/ /out/
/output/
# production # production
/build /build
@ -15,10 +14,12 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
/dev.log
# env files # env files
.env* .env*
!.env.example !.env.example
!.env.staging.example
# vercel # vercel
.vercel .vercel
@ -26,3 +27,10 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# local tooling caches
/.playwright-cli/
/.pnpm-store/
# package manager drift
/package-lock.json

View file

@ -2,45 +2,42 @@
module.exports = { module.exports = {
ci: { ci: {
collect: { collect: {
url: ['http://localhost:3000'], url: ["http://localhost:3000"],
numberOfRuns: 3, numberOfRuns: 3,
startServerCommand: 'npm run start', startServerCommand: "npm run start",
startServerReadyPattern: 'ready', startServerReadyPattern: "ready",
startServerReadyTimeout: 30000, startServerReadyTimeout: 30000,
}, },
assert: { assert: {
assertions: { assertions: {
'categories:performance': ['error', { minScore: 1 }], "categories:performance": ["error", { minScore: 1 }],
'categories:accessibility': ['error', { minScore: 1 }], "categories:accessibility": ["error", { minScore: 1 }],
'categories:best-practices': ['error', { minScore: 1 }], "categories:best-practices": ["error", { minScore: 1 }],
'categories:seo': ['error', { minScore: 1 }], "categories:seo": ["error", { minScore: 1 }],
// Core Web Vitals // Core Web Vitals
'first-contentful-paint': ['error', { maxNumericValue: 1800 }], "first-contentful-paint": ["error", { maxNumericValue: 1800 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }], "largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }], "total-blocking-time": ["error", { maxNumericValue: 200 }],
'speed-index': ['error', { maxNumericValue: 3400 }], "speed-index": ["error", { maxNumericValue: 3400 }],
// Performance metrics // Performance metrics
'interactive': ['error', { maxNumericValue: 3800 }], interactive: ["error", { maxNumericValue: 3800 }],
'uses-optimized-images': 'error', "uses-optimized-images": "error",
'uses-text-compression': 'error', "uses-text-compression": "error",
'uses-responsive-images': 'error', "uses-responsive-images": "error",
'modern-image-formats': 'error', "modern-image-formats": "error",
'offscreen-images': 'error', "offscreen-images": "error",
'render-blocking-resources': 'error', "render-blocking-resources": "error",
'unused-css-rules': 'error', "unused-css-rules": "error",
'unused-javascript': 'error', "unused-javascript": "error",
'efficient-animated-content': 'error', "efficient-animated-content": "error",
'preload-lcp-image': 'error', "preload-lcp-image": "error",
'uses-long-cache-ttl': 'error', "uses-long-cache-ttl": "error",
'total-byte-weight': ['error', { maxNumericValue: 1600000 }], "total-byte-weight": ["error", { maxNumericValue: 1600000 }],
}, },
}, },
upload: { upload: {
target: 'temporary-public-storage', target: "temporary-public-storage",
}, },
}, },
}; }

15
.prettierignore Normal file
View file

@ -0,0 +1,15 @@
.cursor
.next
.playwright-cli
.pnpm-store
artifacts
node_modules
out
output
pnpm-lock.yaml
public/json-ld
public/manual_inventory.json
public/manual_pages_full.json
public/manual_pages_parts.json
public/manual_pages_text.json
public/manual_parts_lookup.json

5
.prettierrc.json Normal file
View file

@ -0,0 +1,5 @@
{
"semi": false,
"singleQuote": false,
"trailingComma": "es5"
}

File diff suppressed because it is too large Load diff

View file

@ -1,58 +1,39 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { getPageBySlug } from '@/lib/wordpress-data-loader'; import { AboutPage } from "@/components/about-page"
import { AboutPage } from '@/components/about-page'; import type { Metadata } from "next"
import type { Metadata } from 'next';
const WORDPRESS_SLUG = 'about-us'; const WORDPRESS_SLUG = "about-us"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("aboutUs", {
title: page.title || 'About Us',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function AboutUsPage() { export default async function AboutUsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let structuredData; const structuredData = generateRegistryStructuredData("aboutUs", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'About Us', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/about-us/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'About Us',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/about-us/`,
};
}
return ( return (
<> <>
@ -62,19 +43,11 @@ export default async function AboutUsPage() {
/> />
<AboutPage /> <AboutPage />
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering About Us page:', error); console.error("Error rendering About Us page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,83 +1,74 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { buildAbsoluteUrl } from "@/lib/seo-registry"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { getPageBySlug } from '@/lib/wordpress-data-loader'; import { Breadcrumbs } from "@/components/breadcrumbs"
import { FAQSchema } from '@/components/faq-schema'; import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { FAQSection } from '@/components/faq-section'; import { FAQSchema } from "@/components/faq-schema"
import type { Metadata } from 'next'; import { FAQSection } from "@/components/faq-section"
import type { Metadata } from "next"
const WORDPRESS_SLUG = 'faqs'; const WORDPRESS_SLUG = "faqs"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("faqs", {
title: page.title || 'FAQs',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function FAQsPage() { export default async function FAQsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
// Extract FAQs from content // Extract FAQs from content
const faqs: Array<{ question: string; answer: string }> = []; const faqs: Array<{ question: string; answer: string }> = []
if (page.content) { if (page.content) {
const contentStr = String(page.content); const contentStr = String(page.content)
// Extract FAQ items from accordion structure // Extract FAQ items from accordion structure
const questionMatches = contentStr.matchAll(/<span class="ekit-accordion-title">([^<]+)<\/span>/g); const questionMatches = contentStr.matchAll(
/<span class="ekit-accordion-title">([^<]+)<\/span>/g
)
// Extract full answer content // Extract full answer content
const answerMatches = contentStr.matchAll(/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/g); const answerMatches = contentStr.matchAll(
/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/g
)
const questions = Array.from(questionMatches).map(m => m[1].trim()); const questions = Array.from(questionMatches).map((m) => m[1].trim())
const answers = Array.from(answerMatches).map(m => { const answers = Array.from(answerMatches).map((m) => {
let answer = m[1].trim(); let answer = m[1].trim()
answer = answer.replace(/\n\s*\n/g, '\n').replace(/>\s+</g, '><').trim(); answer = answer
return answer; .replace(/\n\s*\n/g, "\n")
}); .replace(/>\s+</g, "><")
.trim()
return answer
})
// Match questions with answers // Match questions with answers
questions.forEach((question, index) => { questions.forEach((question, index) => {
if (answers[index]) { if (answers[index]) {
faqs.push({ question, answer: answers[index] }); faqs.push({ question, answer: answers[index] })
} }
}); })
} }
let structuredData; const pageUrl = buildAbsoluteUrl("/about/faqs")
try { const structuredData = generateRegistryStructuredData("faqs", {
structuredData = generateStructuredData({ datePublished: page.date,
title: page.title || 'FAQs', dateModified: page.modified || page.date,
description: page.seoDescription || page.excerpt || '', })
url: page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'FAQs',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/about/faqs/`,
};
}
return ( return (
<> <>
@ -86,28 +77,27 @@ export default async function FAQsPage() {
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
{faqs.length > 0 && ( {faqs.length > 0 && (
<> <div className="public-page">
<Breadcrumbs
className="mb-6"
items={[
{ label: "About", href: "/about" },
{ label: "FAQs", href: "/about/faqs" },
]}
/>
<FAQSchema <FAQSchema
faqs={faqs} faqs={faqs}
pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`} pageUrl={pageUrl}
/> />
<FAQSection faqs={faqs} /> <FAQSection faqs={faqs} className="pt-0" />
</> </div>
)} )}
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering FAQs page:', error); console.error("Error rendering FAQs page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,21 +1,23 @@
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { AboutPage } from '@/components/about-page'; import { AboutPage } from "@/components/about-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
import { businessConfig } from "@/lib/seo-config"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({ return {
title: 'About Us | Rocky Mountain Vending', ...generateRegistryMetadata("aboutLegacy"),
description: 'Learn more about Rocky Mountain Vending, a family-owned business dedicated to providing exceptional vending services across Utah', alternates: {
}); canonical: `${businessConfig.website}/about-us`,
},
robots: {
index: false,
follow: true,
},
}
} }
export default function About() { export default function About() {
const structuredData = generateStructuredData({ const structuredData = generateRegistryStructuredData("aboutUs")
title: 'About Us',
description: 'Learn more about Rocky Mountain Vending, a family-owned business dedicated to providing exceptional vending services across Utah',
url: 'https://rockymountainvending.com/about/',
type: 'WebPage',
});
return ( return (
<> <>
@ -25,5 +27,5 @@ export default function About() {
/> />
<AboutPage /> <AboutPage />
</> </>
); )
} }

View file

@ -1,31 +1,37 @@
import Link from "next/link"; import Link from "next/link"
import { notFound } from "next/navigation"; import { notFound } from "next/navigation"
import { fetchQuery } from "convex/nextjs"; import { fetchQuery } from "convex/nextjs"
import { ArrowLeft, ExternalLink, Phone } from "lucide-react"; import { ArrowLeft, ExternalLink, Phone } from "lucide-react"
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { import {
formatPhoneCallDuration, formatPhoneCallDuration,
formatPhoneCallTimestamp, formatPhoneCallTimestamp,
normalizePhoneFromIdentity, normalizePhoneFromIdentity,
} from "@/lib/phone-calls"; } from "@/lib/phone-calls"
type PageProps = { type PageProps = {
params: Promise<{ params: Promise<{
id: string; id: string
}>; }>
}; }
export default async function AdminCallDetailPage({ params }: PageProps) { export default async function AdminCallDetailPage({ params }: PageProps) {
const { id } = await params; const { id } = await params
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, { const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
callId: id, callId: id,
}); })
if (!detail) { if (!detail) {
notFound(); notFound()
} }
return ( return (
@ -33,13 +39,20 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2"> <div className="space-y-2">
<Link href="/admin/calls" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"> <Link
href="/admin/calls"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to calls Back to calls
</Link> </Link>
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Call Detail</h1> <h1 className="text-4xl font-bold tracking-tight text-balance">
Phone Call Detail
</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity} {detail.call.contactDisplayName ||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity}
</p> </p>
</div> </div>
</div> </div>
@ -51,58 +64,159 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<Phone className="h-5 w-5" /> <Phone className="h-5 w-5" />
Call Status Call Status
</CardTitle> </CardTitle>
<CardDescription>Operational detail for this direct phone session.</CardDescription> <CardDescription>
Operational detail for this direct phone session.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Started</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="font-medium">{formatPhoneCallTimestamp(detail.call.startedAt)}</p> Started
</p>
<p className="font-medium">
{formatPhoneCallTimestamp(detail.call.startedAt)}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Room</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Room
</p>
<p className="font-medium break-all">{detail.call.roomName}</p> <p className="font-medium break-all">{detail.call.roomName}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Duration</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="font-medium">{formatPhoneCallDuration(detail.call.durationMs)}</p> Duration
</p>
<p className="font-medium">
{formatPhoneCallDuration(detail.call.durationMs)}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Participant Identity</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="font-medium break-all">{detail.call.participantIdentity || "Unknown"}</p> Participant Identity
</p>
<p className="font-medium break-all">
{detail.call.participantIdentity || "Unknown"}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Call Status</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<Badge className="mt-1" variant={detail.call.callStatus === "failed" ? "destructive" : detail.call.callStatus === "started" ? "secondary" : "default"}> Caller Phone
</p>
<p className="font-medium">
{detail.call.callerPhone ||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
"Unknown"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Company
</p>
<p className="font-medium">{detail.call.contactCompany || "—"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Call Status
</p>
<Badge
className="mt-1"
variant={
detail.call.callStatus === "failed"
? "destructive"
: detail.call.callStatus === "started"
? "secondary"
: "default"
}
>
{detail.call.callStatus} {detail.call.callStatus}
</Badge> </Badge>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Jessica Answered</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="font-medium">{detail.call.answered ? "Yes" : "No"}</p> Jessica Answered
</p>
<p className="font-medium">
{detail.call.answered ? "Yes" : "No"}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Outcome</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Lead Outcome
</p>
<p className="font-medium">{detail.call.leadOutcome}</p> <p className="font-medium">{detail.call.leadOutcome}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Summary</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Email Summary
</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">Summary</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="text-sm whitespace-pre-wrap">{detail.call.summaryText || "No summary available yet."}</p> Summary
</p>
<p className="text-sm whitespace-pre-wrap">
{detail.call.summaryText || "No summary available yet."}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Recording Status</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="font-medium">{detail.call.recordingStatus || "Unavailable"}</p> Recording Status
</p>
<p className="font-medium">
{detail.call.recordingStatus || "Unavailable"}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Transcript Turns</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Transcript Turns
</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 href={detail.call.recordingUrl} target="_blank" className="inline-flex items-center gap-2 text-sm text-primary hover:underline"> <Link
href={detail.call.recordingUrl}
target="_blank"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
Open recording Open recording
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</Link> </Link>
@ -110,8 +224,24 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
) : null} ) : null}
{detail.call.notificationError ? ( {detail.call.notificationError ? (
<div className="md:col-span-2"> <div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Error</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="text-sm text-destructive">{detail.call.notificationError}</p> Email Error
</p>
<p className="text-sm text-destructive">
{detail.call.notificationError}
</p>
</div>
) : null}
{detail.call.reminderCalendarHtmlLink ? (
<div className="md:col-span-2">
<Link
href={detail.call.reminderCalendarHtmlLink}
target="_blank"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
Open reminder
<ExternalLink className="h-4 w-4" />
</Link>
</div> </div>
) : null} ) : null}
</CardContent> </CardContent>
@ -121,38 +251,74 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<CardHeader> <CardHeader>
<CardTitle>Linked Lead</CardTitle> <CardTitle>Linked Lead</CardTitle>
<CardDescription> <CardDescription>
{detail.linkedLead ? "Lead created from this phone call." : "No lead was created from this call."} {detail.linkedLead
? "Lead created from this phone call."
: "No lead was created from this call."}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{detail.linkedLead ? ( {detail.linkedLead ? (
<> <>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Contact</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Contact
</p>
<p className="font-medium"> <p className="font-medium">
{detail.linkedLead.firstName} {detail.linkedLead.lastName} {detail.linkedLead.firstName} {detail.linkedLead.lastName}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Type</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Lead Type
</p>
<p className="font-medium">{detail.linkedLead.type}</p> <p className="font-medium">{detail.linkedLead.type}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="font-medium break-all">{detail.linkedLead.email}</p> Email
</p>
<p className="font-medium break-all">
{detail.linkedLead.email}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Phone</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Phone
</p>
<p className="font-medium">{detail.linkedLead.phone}</p> <p className="font-medium">{detail.linkedLead.phone}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Message</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">
<p className="text-sm whitespace-pre-wrap">{detail.linkedLead.message || "—"}</p> Message
</p>
<p className="text-sm whitespace-pre-wrap">
{detail.linkedLead.message || "—"}
</p>
</div> </div>
</> </>
) : ( ) : (
<p className="text-sm text-muted-foreground">Jessica handled the call, but it did not result in a submitted lead.</p> <p className="text-sm text-muted-foreground">
Jessica handled the call, but it did not result in a submitted
lead.
</p>
)} )}
{detail.contactProfile ? (
<div className="border-t pt-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Contact Profile
</p>
<p className="font-medium">
{detail.contactProfile.displayName ||
[detail.contactProfile.firstName, detail.contactProfile.lastName]
.filter(Boolean)
.join(" ") ||
"Known caller"}
</p>
<p className="text-sm text-muted-foreground">
{detail.contactProfile.company || detail.contactProfile.email || "No company or email yet"}
</p>
</div>
) : null}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -160,11 +326,15 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Transcript</CardTitle> <CardTitle>Transcript</CardTitle>
<CardDescription>Complete mirrored transcript for this phone call.</CardDescription> <CardDescription>
Complete mirrored transcript for this phone call.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{detail.turns.length === 0 ? ( {detail.turns.length === 0 ? (
<p className="text-sm text-muted-foreground">No transcript turns were captured for this call.</p> <p className="text-sm text-muted-foreground">
No transcript turns were captured for this call.
</p>
) : ( ) : (
detail.turns.map((turn: any) => ( detail.turns.map((turn: any) => (
<div key={turn.id} className="rounded-lg border p-3"> <div key={turn.id} className="rounded-lg border p-3">
@ -180,10 +350,11 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</Card> </Card>
</div> </div>
</div> </div>
); )
} }
export const metadata = { export const metadata = {
title: "Phone Call Detail | Admin", title: "Phone Call Detail | Admin",
description: "Review a mirrored direct phone call transcript and linked lead details", description:
}; "Review a mirrored direct phone call transcript and linked lead details",
}

View file

@ -1,58 +1,67 @@
import Link from "next/link"; import Link from "next/link"
import { fetchQuery } from "convex/nextjs"; import { fetchQuery } from "convex/nextjs"
import { Phone, Search } from "lucide-react"; import { Phone, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {
import { Input } from "@/components/ui/input"; Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { import {
formatPhoneCallDuration, formatPhoneCallDuration,
formatPhoneCallTimestamp, formatPhoneCallTimestamp,
normalizePhoneFromIdentity, normalizePhoneFromIdentity,
} from "@/lib/phone-calls"; } from "@/lib/phone-calls"
type PageProps = { type PageProps = {
searchParams: Promise<{ searchParams: Promise<{
search?: string; search?: string
status?: "started" | "completed" | "failed"; status?: "started" | "completed" | "failed"
page?: string; page?: string
}>; }>
}; }
function getStatusVariant(status: "started" | "completed" | "failed") { function getStatusVariant(status: "started" | "completed" | "failed") {
if (status === "failed") { if (status === "failed") {
return "destructive" as const; return "destructive" as const
} }
if (status === "started") { if (status === "started") {
return "secondary" as const; return "secondary" as const
} }
return "default" as const; return "default" as const
} }
export default async function AdminCallsPage({ searchParams }: PageProps) { export default async function AdminCallsPage({ searchParams }: PageProps) {
const params = await searchParams; const params = await searchParams
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1); const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
const status = params.status; const status = params.status
const search = params.search?.trim() || undefined; const search = params.search?.trim() || undefined
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, { const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
search, search,
status, status,
page, page,
limit: 25, limit: 25,
}); })
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div>
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Calls</h1> <h1 className="text-4xl font-bold tracking-tight text-balance">
Phone Calls
</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Every direct LiveKit phone call mirrored into RMV admin, including partial and non-lead calls. Every direct LiveKit phone call mirrored into RMV admin, including
partial and non-lead calls.
</p> </p>
</div> </div>
<Link href="/admin"> <Link href="/admin">
@ -66,13 +75,20 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<Phone className="h-5 w-5" /> <Phone className="h-5 w-5" />
Call Inbox Call Inbox
</CardTitle> </CardTitle>
<CardDescription>Search by caller number, room, summary, or linked lead ID.</CardDescription> <CardDescription>
Search by caller number, room, summary, or linked lead ID.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_auto]"> <form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_auto]">
<div className="relative"> <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" /> <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 calls" className="pl-9" /> <Input
name="search"
defaultValue={search || ""}
placeholder="Search calls"
className="pl-9"
/>
</div> </div>
<select <select
name="status" name="status"
@ -88,7 +104,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
</form> </form>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[1050px] text-sm"> <table className="w-full min-w-[1240px] text-sm">
<thead> <thead>
<tr className="border-b text-left text-muted-foreground"> <tr className="border-b text-left text-muted-foreground">
<th className="py-3 pr-4 font-medium">Caller</th> <th className="py-3 pr-4 font-medium">Caller</th>
@ -100,6 +116,8 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<th className="py-3 pr-4 font-medium">Recording</th> <th className="py-3 pr-4 font-medium">Recording</th>
<th className="py-3 pr-4 font-medium">Lead</th> <th className="py-3 pr-4 font-medium">Lead</th>
<th className="py-3 pr-4 font-medium">Email</th> <th className="py-3 pr-4 font-medium">Email</th>
<th className="py-3 pr-4 font-medium">Reminder</th>
<th className="py-3 pr-4 font-medium">Transfer</th>
<th className="py-3 pr-4 font-medium">Summary</th> <th className="py-3 pr-4 font-medium">Summary</th>
<th className="py-3 font-medium">Open</th> <th className="py-3 font-medium">Open</th>
</tr> </tr>
@ -107,35 +125,81 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<tbody> <tbody>
{data.items.length === 0 ? ( {data.items.length === 0 ? (
<tr> <tr>
<td colSpan={11} className="py-8 text-center text-muted-foreground"> <td
colSpan={13}
className="py-8 text-center text-muted-foreground"
>
No phone calls matched this filter. No phone calls matched this filter.
</td> </td>
</tr> </tr>
) : ( ) : (
data.items.map((call: any) => ( data.items.map((call: any) => (
<tr key={call.id} className="border-b align-top last:border-b-0"> <tr
key={call.id}
className="border-b align-top last:border-b-0"
>
<td className="py-3 pr-4 font-medium"> <td className="py-3 pr-4 font-medium">
<div>{normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}</div> <div>
<div className="text-xs text-muted-foreground">{call.roomName}</div> {call.contactDisplayName ||
normalizePhoneFromIdentity(
call.participantIdentity
) ||
call.participantIdentity}
</div>
<div className="text-xs text-muted-foreground">
{call.contactCompany ||
normalizePhoneFromIdentity(
call.participantIdentity
) ||
call.roomName}
</div>
</td> </td>
<td className="py-3 pr-4">{formatPhoneCallTimestamp(call.startedAt)}</td>
<td className="py-3 pr-4">{formatPhoneCallDuration(call.durationMs)}</td>
<td className="py-3 pr-4"> <td className="py-3 pr-4">
<Badge variant={getStatusVariant(call.callStatus)}>{call.callStatus}</Badge> {formatPhoneCallTimestamp(call.startedAt)}
</td> </td>
<td className="py-3 pr-4">{call.answered ? "Yes" : "No"}</td>
<td className="py-3 pr-4"> <td className="py-3 pr-4">
{call.transcriptTurnCount > 0 ? `${call.transcriptTurnCount} turns` : "No transcript"} {formatPhoneCallDuration(call.durationMs)}
</td>
<td className="py-3 pr-4">
<Badge variant={getStatusVariant(call.callStatus)}>
{call.callStatus}
</Badge>
</td>
<td className="py-3 pr-4">
{call.answered ? "Yes" : "No"}
</td>
<td className="py-3 pr-4">
{call.transcriptTurnCount > 0
? `${call.transcriptTurnCount} turns`
: "No transcript"}
</td>
<td className="py-3 pr-4">
{call.recordingStatus || "Unavailable"}
</td>
<td className="py-3 pr-4">
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
</td> </td>
<td className="py-3 pr-4">{call.recordingStatus || "Unavailable"}</td>
<td className="py-3 pr-4">{call.leadOutcome === "none" ? "—" : call.leadOutcome}</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">{call.summaryText || "No summary yet"}</span> <span className="line-clamp-2">
{call.summaryText || "No summary yet"}
</span>
</td> </td>
<td className="py-3"> <td className="py-3">
<Link href={`/admin/calls/${call.id}`}> <Link href={`/admin/calls/${call.id}`}>
<Button size="sm" variant="outline">View</Button> <Button size="sm" variant="outline">
View
</Button>
</Link> </Link>
</td> </td>
</tr> </tr>
@ -147,7 +211,8 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Showing page {data.pagination.page} of {data.pagination.totalPages} ({data.pagination.total} calls) Showing page {data.pagination.page} of{" "}
{data.pagination.totalPages} ({data.pagination.total} calls)
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
{data.pagination.page > 1 ? ( {data.pagination.page > 1 ? (
@ -158,7 +223,9 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
page: String(data.pagination.page - 1), page: String(data.pagination.page - 1),
}).toString()}`} }).toString()}`}
> >
<Button variant="outline" size="sm">Previous</Button> <Button variant="outline" size="sm">
Previous
</Button>
</Link> </Link>
) : null} ) : null}
{data.pagination.page < data.pagination.totalPages ? ( {data.pagination.page < data.pagination.totalPages ? (
@ -169,7 +236,9 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
page: String(data.pagination.page + 1), page: String(data.pagination.page + 1),
}).toString()}`} }).toString()}`}
> >
<Button variant="outline" size="sm">Next</Button> <Button variant="outline" size="sm">
Next
</Button>
</Link> </Link>
) : null} ) : null}
</div> </div>
@ -178,10 +247,10 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
</Card> </Card>
</div> </div>
</div> </div>
); )
} }
export const metadata = { export const metadata = {
title: "Phone Calls | Admin", title: "Phone Calls | Admin",
description: "View direct phone calls, transcript history, and lead outcomes", description: "View direct phone calls, transcript history, and lead outcomes",
}; }

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { OrderManagement } from '@/components/order-management' import { OrderManagement } from "@/components/order-management"
export default function AdminOrdersPage() { export default function AdminOrdersPage() {
return ( return (
@ -9,6 +9,6 @@ export default function AdminOrdersPage() {
} }
export const metadata = { export const metadata = {
title: 'Order Management | Admin', title: "Order Management | Admin",
description: 'View and manage customer orders', description: "View and manage customer orders",
} }

View file

@ -1,7 +1,15 @@
import Link from 'next/link' import Link from "next/link"
import { Button } from '@/components/ui/button' import { fetchQuery } from "convex/nextjs"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from "@/components/ui/button"
import { Badge } from '@/components/ui/badge' import { api } from "@/convex/_generated/api"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { import {
ShoppingCart, ShoppingCart,
Package, Package,
@ -14,9 +22,11 @@ import {
AlertTriangle, AlertTriangle,
Settings, Settings,
BarChart3, BarChart3,
Phone Phone,
} from 'lucide-react' MessageSquare,
import { fetchAllProducts } from '@/lib/stripe/products' ContactRound,
} from "lucide-react"
import { fetchAllProducts } from "@/lib/stripe/products"
// Mock analytics data for demo // Mock analytics data for demo
const mockAnalytics = { const mockAnalytics = {
@ -27,7 +37,7 @@ const mockAnalytics = {
lowStockProducts: 3, lowStockProducts: 3,
avgOrderValue: 311.46, avgOrderValue: 311.46,
conversionRate: 2.8, conversionRate: 2.8,
monthlyGrowth: 15.2 monthlyGrowth: 15.2,
} }
async function getProductsCount() { async function getProductsCount() {
@ -41,7 +51,7 @@ async function getProductsCount() {
async function getOrdersCount() { async function getOrdersCount() {
try { try {
const response = await fetch('/api/orders') const response = await fetch("/api/orders")
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
return data.pagination.total || 0 return data.pagination.total || 0
@ -50,130 +60,145 @@ async function getOrdersCount() {
return mockAnalytics.totalOrders return mockAnalytics.totalOrders
} }
function formatTimestamp(value?: number | null) {
if (!value) {
return "—"
}
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export default async function AdminDashboard() { export default async function AdminDashboard() {
const [productsCount, ordersCount] = await Promise.all([ const [productsCount, ordersCount, sync] = await Promise.all([
getProductsCount(), getProductsCount(),
getOrdersCount() getOrdersCount(),
fetchQuery(api.crm.getAdminSyncOverview, {}),
]) ])
const dashboardCards = [ const dashboardCards = [
{ {
title: 'Total Revenue', title: "Total Revenue",
value: `$${mockAnalytics.totalRevenue.toLocaleString()}`, value: `$${mockAnalytics.totalRevenue.toLocaleString()}`,
description: 'Total revenue from all orders', description: "Total revenue from all orders",
icon: DollarSign, icon: DollarSign,
trend: '+15.2%', trend: "+15.2%",
trendPositive: true, trendPositive: true,
color: 'text-green-600' color: "text-green-600",
}, },
{ {
title: 'Total Orders', title: "Total Orders",
value: mockAnalytics.totalOrders.toString(), value: mockAnalytics.totalOrders.toString(),
description: 'Total number of orders', description: "Total number of orders",
icon: ShoppingCart, icon: ShoppingCart,
trend: '+12.8%', trend: "+12.8%",
trendPositive: true, trendPositive: true,
color: 'text-blue-600' color: "text-blue-600",
}, },
{ {
title: 'Products', title: "Products",
value: productsCount.toString(), value: productsCount.toString(),
description: 'Active products in inventory', description: "Active products in inventory",
icon: Package, icon: Package,
trend: '+5', trend: "+5",
trendPositive: true, trendPositive: true,
color: 'text-purple-600' color: "text-purple-600",
}, },
{ {
title: 'Pending Orders', title: "Pending Orders",
value: mockAnalytics.pendingOrders.toString(), value: mockAnalytics.pendingOrders.toString(),
description: 'Orders awaiting processing', description: "Orders awaiting processing",
icon: Clock, icon: Clock,
trend: '-3', trend: "-3",
trendPositive: false, trendPositive: false,
color: 'text-orange-600' color: "text-orange-600",
} },
] ]
const quickStats = [ const quickStats = [
{ {
title: 'Average Order Value', title: "Average Order Value",
value: `$${mockAnalytics.avgOrderValue.toFixed(2)}`, value: `$${mockAnalytics.avgOrderValue.toFixed(2)}`,
description: 'Average value per order', description: "Average value per order",
icon: TrendingUp icon: TrendingUp,
}, },
{ {
title: 'Conversion Rate', title: "Conversion Rate",
value: `${mockAnalytics.conversionRate}%`, value: `${mockAnalytics.conversionRate}%`,
description: 'Visitors to orders ratio', description: "Visitors to orders ratio",
icon: Users icon: Users,
}, },
{ {
title: 'Monthly Growth', title: "Monthly Growth",
value: `${mockAnalytics.monthlyGrowth}%`, value: `${mockAnalytics.monthlyGrowth}%`,
description: 'Revenue growth this month', description: "Revenue growth this month",
icon: BarChart3 icon: BarChart3,
}, },
{ {
title: 'Low Stock Alert', title: "Low Stock Alert",
value: mockAnalytics.lowStockProducts.toString(), value: mockAnalytics.lowStockProducts.toString(),
description: 'Products need restocking', description: "Products need restocking",
icon: AlertTriangle icon: AlertTriangle,
} },
] ]
const recentOrders = [ const recentOrders = [
{ {
id: 'ORD-001234', id: "ORD-001234",
customer: 'john.doe@email.com', customer: "john.doe@email.com",
amount: 2799.98, amount: 2799.98,
status: 'paid', status: "paid",
date: '2024-01-15 10:30' date: "2024-01-15 10:30",
}, },
{ {
id: 'ORD-001233', id: "ORD-001233",
customer: 'jane.smith@email.com', customer: "jane.smith@email.com",
amount: 1499.99, amount: 1499.99,
status: 'fulfilled', status: "fulfilled",
date: '2024-01-15 09:45' date: "2024-01-15 09:45",
}, },
{ {
id: 'ORD-001232', id: "ORD-001232",
customer: 'bob.johnson@email.com', customer: "bob.johnson@email.com",
amount: 899.97, amount: 899.97,
status: 'pending', status: "pending",
date: '2024-01-15 08:20' date: "2024-01-15 08:20",
}, },
{ {
id: 'ORD-001231', id: "ORD-001231",
customer: 'alice.wilson@email.com', customer: "alice.wilson@email.com",
amount: 3499.99, amount: 3499.99,
status: 'cancelled', status: "cancelled",
date: '2024-01-14 16:15' date: "2024-01-14 16:15",
} },
] ]
const popularProducts = [ const popularProducts = [
{ {
name: 'SEAGA HY900 Vending Machine', name: "SEAGA HY900 Vending Machine",
orders: 45, orders: 45,
revenue: 112499.55 revenue: 112499.55,
}, },
{ {
name: 'Vending Machine Stand', name: "Vending Machine Stand",
orders: 38, orders: 38,
revenue: 11399.62 revenue: 11399.62,
}, },
{ {
name: 'Snack Vending Machine Combo', name: "Snack Vending Machine Combo",
orders: 23, orders: 23,
revenue: 45999.77 revenue: 45999.77,
}, },
{ {
name: 'Drink Vending Machine', name: "Drink Vending Machine",
orders: 19, orders: 19,
revenue: 37999.81 revenue: 37999.81,
} },
] ]
return ( return (
@ -182,12 +207,26 @@ export default async function AdminDashboard() {
{/* Header */} {/* Header */}
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance">Admin Dashboard</h1> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance">
Admin Dashboard
</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Overview of your store performance and management tools Manage orders, contacts, conversations, and calls
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link href="/admin/contacts">
<Button variant="outline">
<ContactRound className="h-4 w-4 mr-2" />
Contacts
</Button>
</Link>
<Link href="/admin/conversations">
<Button variant="outline">
<MessageSquare className="h-4 w-4 mr-2" />
Conversations
</Button>
</Link>
<Link href="/admin/calls"> <Link href="/admin/calls">
<Button variant="outline"> <Button variant="outline">
<Phone className="h-4 w-4 mr-2" /> <Phone className="h-4 w-4 mr-2" />
@ -204,6 +243,25 @@ export default async function AdminDashboard() {
</div> </div>
</div> </div>
<Card>
<CardHeader>
<CardTitle>CRM Sync Status</CardTitle>
<CardDescription>
{!sync.ghlConfigured
? "Connect GHL to load contacts and conversations."
: "Customer data is mirrored here from GHL and your call flows."}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<Badge variant="outline">{sync.overallStatus}</Badge>
<span>Last sync: {formatTimestamp(sync.latestSyncAt)}</span>
{!sync.ghlConfigured ? <span>GHL is not connected.</span> : null}
{!sync.livekitConfigured ? (
<span>LiveKit recordings are not connected yet.</span>
) : null}
</CardContent>
</Card>
{/* Main Stats */} {/* Main Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{dashboardCards.map((card, index) => { {dashboardCards.map((card, index) => {
@ -220,7 +278,7 @@ export default async function AdminDashboard() {
<div className="text-2xl font-bold">{card.value}</div> <div className="text-2xl font-bold">{card.value}</div>
<div className="flex items-center gap-1 mt-2"> <div className="flex items-center gap-1 mt-2">
<span className={`text-sm ${card.color}`}> <span className={`text-sm ${card.color}`}>
{card.trend} {card.trendPositive ? '↑' : '↓'} {card.trend} {card.trendPositive ? "↑" : "↓"}
</span> </span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
from last month from last month
@ -266,7 +324,9 @@ export default async function AdminDashboard() {
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
Recent Orders Recent Orders
<Link href="/admin/orders"> <Link href="/admin/orders">
<Button variant="outline" size="sm">View All</Button> <Button variant="outline" size="sm">
View All
</Button>
</Link> </Link>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@ -276,7 +336,10 @@ export default async function AdminDashboard() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{recentOrders.map((order) => ( {recentOrders.map((order) => (
<div key={order.id} className="flex items-center justify-between py-3 border-b last:border-b-0"> <div
key={order.id}
className="flex items-center justify-between py-3 border-b last:border-b-0"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium">{order.id}</div> <div className="font-medium">{order.id}</div>
@ -289,16 +352,23 @@ export default async function AdminDashboard() {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="font-medium">${order.amount.toFixed(2)}</div> <div className="font-medium">
${order.amount.toFixed(2)}
</div>
<Badge <Badge
variant={ variant={
order.status === 'paid' ? 'default' : order.status === "paid"
order.status === 'fulfilled' ? 'default' : ? "default"
order.status === 'pending' ? 'secondary' : 'destructive' : order.status === "fulfilled"
? "default"
: order.status === "pending"
? "secondary"
: "destructive"
} }
className="mt-1" className="mt-1"
> >
{order.status.charAt(0).toUpperCase() + order.status.slice(1)} {order.status.charAt(0).toUpperCase() +
order.status.slice(1)}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -313,17 +383,20 @@ export default async function AdminDashboard() {
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
Popular Products Popular Products
<Link href="/admin/products"> <Link href="/admin/products">
<Button variant="outline" size="sm">View All</Button> <Button variant="outline" size="sm">
View All
</Button>
</Link> </Link>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Top-selling products this month</CardDescription>
Top-selling products this month
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{popularProducts.map((product, index) => ( {popularProducts.map((product, index) => (
<div key={index} className="flex items-center justify-between py-3 border-b last:border-b-0"> <div
key={index}
className="flex items-center justify-between py-3 border-b last:border-b-0"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground"> <div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground">
{index + 1} {index + 1}
@ -338,7 +411,9 @@ export default async function AdminDashboard() {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="font-medium">${product.revenue.toLocaleString()}</div> <div className="font-medium">
${product.revenue.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
${(product.revenue / product.orders).toFixed(2)} avg ${(product.revenue / product.orders).toFixed(2)} avg
</div> </div>
@ -414,6 +489,7 @@ export default async function AdminDashboard() {
} }
export const metadata = { export const metadata = {
title: 'Admin Dashboard | Rocky Mountain Vending', title: "Admin Dashboard | Rocky Mountain Vending",
description: 'Administrative dashboard for managing your vending machine business', description:
"Administrative dashboard for managing your vending machine business",
} }

View file

@ -1,4 +1,4 @@
import { ProductAdmin } from '@/components/product-admin' import { ProductAdmin } from "@/components/product-admin"
export default function AdminProductsPage() { export default function AdminProductsPage() {
return ( return (
@ -9,6 +9,6 @@ export default function AdminProductsPage() {
} }
export const metadata = { export const metadata = {
title: 'Product Management | Admin', title: "Product Management | Admin",
description: 'Manage your Stripe products and inventory', description: "Manage your Stripe products and inventory",
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,48 @@
import assert from "node:assert/strict"
import test from "node:test"
import { GET } from "@/app/api/admin/manuals-knowledge/route"
const ORIGINAL_ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN
test.afterEach(() => {
if (typeof ORIGINAL_ADMIN_API_TOKEN === "string") {
process.env.ADMIN_API_TOKEN = ORIGINAL_ADMIN_API_TOKEN
} else {
delete process.env.ADMIN_API_TOKEN
}
})
test("manuals knowledge admin route requires admin auth", async () => {
process.env.ADMIN_API_TOKEN = "secret-token"
const response = await GET(
new Request("http://localhost/api/admin/manuals-knowledge?query=rvv+660")
)
assert.equal(response.status, 401)
})
test("manuals knowledge admin route returns retrieval details for authorized queries", async () => {
process.env.ADMIN_API_TOKEN = "secret-token"
const response = await GET(
new Request(
"http://localhost/api/admin/manuals-knowledge?query=RVV+660+service+manual",
{
headers: {
"x-admin-token": "secret-token",
},
}
)
)
assert.equal(response.status, 200)
const body = await response.json()
assert.equal(body.summary.ran, true)
assert.equal(Array.isArray(body.result.manualCandidates), true)
assert.equal(body.result.manualCandidates.length > 0, true)
assert.equal(Array.isArray(body.result.topChunks), true)
assert.equal(Array.isArray(body.summary.topChunkCitations), true)
})

View file

@ -0,0 +1,79 @@
import { NextResponse } from "next/server"
import {
getManualCitationContext,
retrieveManualContext,
summarizeManualRetrieval,
} from "@/lib/manuals-knowledge"
import { requireAdminToken } from "@/lib/server/admin-auth"
function normalizeQuery(value: string | null) {
return (value || "").trim().slice(0, 400)
}
export async function GET(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { searchParams } = new URL(request.url)
const query = normalizeQuery(searchParams.get("query"))
const manufacturer = normalizeQuery(searchParams.get("manufacturer")) || null
const model = normalizeQuery(searchParams.get("model")) || null
const manualId = normalizeQuery(searchParams.get("manualId")) || null
const pageParam = searchParams.get("page")
const pageNumber =
pageParam && Number.isFinite(Number(pageParam))
? Number.parseInt(pageParam, 10)
: undefined
if (!query) {
return NextResponse.json(
{ error: "A query parameter is required." },
{ status: 400 }
)
}
const result = await retrieveManualContext(query, {
manufacturer,
model,
manualId,
})
const citationContext =
manualId || result.bestManual?.manualId
? await getManualCitationContext(
manualId || result.bestManual?.manualId || "",
pageNumber
)
: null
return NextResponse.json({
query,
filters: {
manufacturer,
model,
manualId,
pageNumber: pageNumber ?? null,
},
summary: summarizeManualRetrieval({
ran: true,
query,
result,
}),
result,
citationContext,
})
} catch (error) {
console.error("Failed to inspect manuals knowledge:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to inspect manuals knowledge",
},
{ status: 500 }
)
}
}

181
app/api/chat/route.test.ts Normal file
View file

@ -0,0 +1,181 @@
import assert from "node:assert/strict"
import test from "node:test"
import { NextRequest } from "next/server"
import { POST } from "@/app/api/chat/route"
type CapturedPayload = {
model: string
messages: Array<{ role: string; content: string }>
}
const ORIGINAL_FETCH = globalThis.fetch
const ORIGINAL_XAI_KEY = process.env.XAI_API_KEY
function buildVisitor(intent: string) {
return {
name: "Taylor",
phone: "(801) 555-1000",
email: "taylor@example.com",
intent,
serviceTextConsent: true,
marketingTextConsent: false,
consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/contact-us",
}
}
function buildRequest(message: string, intent = "Manuals") {
return new NextRequest("http://localhost/api/chat", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
pathname: "/manuals",
sessionId: "test-session",
visitor: buildVisitor(intent),
messages: [{ role: "user", content: message }],
}),
})
}
async function runChatRouteWithSpy(
message: string,
intent = "Manuals"
): Promise<{ response: Response; payload: CapturedPayload }> {
process.env.XAI_API_KEY = "test-xai-key"
let capturedPayload: CapturedPayload | null = null
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
capturedPayload = JSON.parse(String(init?.body || "{}")) as CapturedPayload
return new Response(
JSON.stringify({
choices: [
{
message: {
content: "Mock Jessica reply.",
},
},
],
}),
{
status: 200,
headers: {
"content-type": "application/json",
},
}
)
}) as typeof fetch
const response = await POST(buildRequest(message, intent))
assert.ok(capturedPayload)
return { response, payload: capturedPayload }
}
test.afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH
if (typeof ORIGINAL_XAI_KEY === "string") {
process.env.XAI_API_KEY = ORIGINAL_XAI_KEY
} else {
delete process.env.XAI_API_KEY
}
})
test("chat route includes grounded manual context for RVV alias lookups", async () => {
const { response, payload } = await runChatRouteWithSpy(
"RVV 660 service manual"
)
assert.equal(response.status, 200)
assert.equal(
payload.messages.some(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
),
true
)
assert.equal(
payload.messages.some(
(message) =>
message.role === "system" &&
/Royal Vendors|660/i.test(message.content)
),
true
)
})
test("chat route resolves Narco alias lookups into manual context", async () => {
const { payload } = await runChatRouteWithSpy("Narco bevmax not cooling")
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.ok(manualContext)
assert.match(manualContext.content, /Dixie-Narco|Narco/i)
})
test("chat route low-confidence manual queries instruct Jessica to ask for brand model or photo", async () => {
const { payload } = await runChatRouteWithSpy(
"manual for flibbertigibbet machine"
)
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.ok(manualContext)
assert.match(
manualContext.content,
/brand on the front|model sticker|photo\/video/i
)
})
test("chat route risky technical manual queries inject conservative safety context", async () => {
const { payload } = await runChatRouteWithSpy(
"Royal wiring diagram voltage manual",
"Repairs"
)
const systemPrompt = payload.messages[0]?.content || ""
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.match(
systemPrompt,
/Do not provide step-by-step repair procedures, wiring guidance, voltage guidance/i
)
assert.ok(manualContext)
assert.match(manualContext.content, /technical or risky/i)
})
test("chat route skips manuals retrieval for non-manual conversations", async () => {
const { payload } = await runChatRouteWithSpy(
"Can someone call me back about free placement?",
"Free Placement"
)
const systemMessages = payload.messages.filter(
(message) => message.role === "system"
)
assert.equal(systemMessages.length, 1)
assert.equal(
systemMessages.some((message) =>
message.content.includes("Manual knowledge context:")
),
false
)
})

View file

@ -17,8 +17,18 @@ import {
SITE_CHAT_TEMPERATURE, SITE_CHAT_TEMPERATURE,
isSiteChatSuppressedRoute, isSiteChatSuppressedRoute,
} from "@/lib/site-chat/config" } from "@/lib/site-chat/config"
import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt" import { buildSiteChatSystemPrompt } from "@/lib/site-chat/prompt"
import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus } from "@/lib/site-chat/rate-limit" import {
consumeChatOutput,
consumeChatRequest,
getChatRateLimitStatus,
} from "@/lib/site-chat/rate-limit"
import {
formatManualContextForPrompt,
retrieveManualContext,
shouldUseManualKnowledgeForChat,
summarizeManualRetrieval,
} from "@/lib/manuals-knowledge"
import { createSmsConsentPayload } from "@/lib/sms-compliance" import { createSmsConsentPayload } from "@/lib/sms-compliance"
type ChatRole = "user" | "assistant" type ChatRole = "user" | "assistant"
@ -81,7 +91,10 @@ function normalizeSessionId(rawSessionId: string | undefined | null) {
} }
function normalizePathname(rawPathname: string | undefined) { function normalizePathname(rawPathname: string | undefined) {
const pathname = typeof rawPathname === "string" && rawPathname.trim() ? rawPathname.trim() : "/" const pathname =
typeof rawPathname === "string" && rawPathname.trim()
? rawPathname.trim()
: "/"
return pathname.startsWith("/") ? pathname : `/${pathname}` return pathname.startsWith("/") ? pathname : `/${pathname}`
} }
@ -89,24 +102,46 @@ function normalizeMessages(messages: ChatMessage[] | undefined) {
const safeMessages = Array.isArray(messages) ? messages : [] const safeMessages = Array.isArray(messages) ? messages : []
return safeMessages return safeMessages
.filter((message) => message && (message.role === "user" || message.role === "assistant")) .filter(
(message) =>
message && (message.role === "user" || message.role === "assistant")
)
.map((message) => ({ .map((message) => ({
role: message.role, role: message.role,
content: String(message.content || "").replace(/\s+/g, " ").trim().slice(0, SITE_CHAT_MAX_MESSAGE_CHARS), content: String(message.content || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
})) }))
.filter((message) => message.content.length > 0) .filter((message) => message.content.length > 0)
.slice(-SITE_CHAT_MAX_HISTORY_MESSAGES) .slice(-SITE_CHAT_MAX_HISTORY_MESSAGES)
} }
function normalizeVisitorProfile(rawVisitor: ChatRequestBody["visitor"], pathname: string): ChatVisitorProfile | null { function normalizeVisitorProfile(
rawVisitor: ChatRequestBody["visitor"],
pathname: string
): ChatVisitorProfile | null {
if (!rawVisitor) { if (!rawVisitor) {
return null return null
} }
const name = String(rawVisitor.name || "").replace(/\s+/g, " ").trim().slice(0, 80) const name = String(rawVisitor.name || "")
const phone = String(rawVisitor.phone || "").replace(/\s+/g, " ").trim().slice(0, 40) .replace(/\s+/g, " ")
const email = String(rawVisitor.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase() .trim()
const intent = String(rawVisitor.intent || "").replace(/\s+/g, " ").trim().slice(0, 80) .slice(0, 80)
const phone = String(rawVisitor.phone || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 40)
const email = String(rawVisitor.email || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120)
.toLowerCase()
const intent = String(rawVisitor.intent || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80)
if (!name || !phone || !email || !intent) { if (!name || !phone || !email || !intent) {
return null return null
@ -179,6 +214,15 @@ function extractAssistantText(data: any) {
return "" return ""
} }
function buildManualKnowledgeQuery(messages: ChatMessage[]) {
return messages
.filter((message) => message.role === "user")
.slice(-3)
.map((message) => message.content.trim())
.filter(Boolean)
.join(" ")
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const responseHeaders: Record<string, string> = { const responseHeaders: Record<string, string> = {
"Cache-Control": "no-store", "Cache-Control": "no-store",
@ -190,25 +234,36 @@ export async function POST(request: NextRequest) {
const visitor = normalizeVisitorProfile(body.visitor, pathname) const visitor = normalizeVisitorProfile(body.visitor, pathname)
if (isSiteChatSuppressedRoute(pathname)) { if (isSiteChatSuppressedRoute(pathname)) {
return NextResponse.json({ error: "Chat is not available on this route." }, { status: 403, headers: responseHeaders }) return NextResponse.json(
{ error: "Chat is not available on this route." },
{ status: 403, headers: responseHeaders }
)
} }
if (!visitor) { if (!visitor) {
return NextResponse.json( return NextResponse.json(
{ {
error: "Name, phone, email, intent, and required service SMS consent are needed to start chat.", error:
"Name, phone, email, intent, and required service SMS consent are needed to start chat.",
}, },
{ status: 400, headers: responseHeaders }, { status: 400, headers: responseHeaders }
) )
} }
const sessionId = normalizeSessionId(body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value) const sessionId = normalizeSessionId(
body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value
)
const ip = getClientIp(request) const ip = getClientIp(request)
const messages = normalizeMessages(body.messages) const messages = normalizeMessages(body.messages)
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user") const latestUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user")
if (!latestUserMessage) { if (!latestUserMessage) {
return NextResponse.json({ error: "A user message is required.", sessionId }, { status: 400, headers: responseHeaders }) return NextResponse.json(
{ error: "A user message is required.", sessionId },
{ status: 400, headers: responseHeaders }
)
} }
if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) { if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) {
@ -217,7 +272,7 @@ export async function POST(request: NextRequest) {
error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`, error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`,
sessionId, sessionId,
}, },
{ status: 400, headers: responseHeaders }, { status: 400, headers: responseHeaders }
) )
} }
@ -234,11 +289,12 @@ export async function POST(request: NextRequest) {
if (limitStatus.blocked) { if (limitStatus.blocked) {
const blockedResponse = NextResponse.json( const blockedResponse = NextResponse.json(
{ {
error: "Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.", error:
"Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
sessionId, sessionId,
limits: limitStatus, limits: limitStatus,
}, },
{ status: 429, headers: responseHeaders }, { status: 429, headers: responseHeaders }
) )
blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, { blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
@ -252,7 +308,41 @@ export async function POST(request: NextRequest) {
return blockedResponse return blockedResponse
} }
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId }) consumeChatRequest({
ip,
requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS,
sessionId,
})
const manualKnowledgeQuery = buildManualKnowledgeQuery(messages)
const shouldUseManualKnowledge = shouldUseManualKnowledgeForChat(
visitor.intent,
manualKnowledgeQuery
)
let manualKnowledge = null
let manualKnowledgeError: unknown = null
if (shouldUseManualKnowledge) {
try {
manualKnowledge = await retrieveManualContext(manualKnowledgeQuery)
} catch (error) {
manualKnowledgeError = error
console.error("[site-chat] manuals knowledge lookup failed", {
pathname,
sessionId,
error,
})
}
}
console.info(
"[site-chat] manuals retrieval",
summarizeManualRetrieval({
ran: shouldUseManualKnowledge,
query: manualKnowledgeQuery,
result: manualKnowledge,
error: manualKnowledgeError,
})
)
const systemPrompt = buildSiteChatSystemPrompt()
const xaiApiKey = getOptionalEnv("XAI_API_KEY") const xaiApiKey = getOptionalEnv("XAI_API_KEY")
if (!xaiApiKey) { if (!xaiApiKey) {
@ -263,32 +353,46 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.", error:
"Jessica is temporarily unavailable right now. Please call us or use the contact form.",
sessionId, sessionId,
}, },
{ status: 503, headers: responseHeaders }, { status: 503, headers: responseHeaders }
) )
} }
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", { const completionResponse = await fetch(
method: "POST", "https://api.x.ai/v1/chat/completions",
headers: { {
Authorization: `Bearer ${xaiApiKey}`, method: "POST",
"Content-Type": "application/json", headers: {
}, Authorization: `Bearer ${xaiApiKey}`,
body: JSON.stringify({ "Content-Type": "application/json",
model: SITE_CHAT_MODEL, },
temperature: SITE_CHAT_TEMPERATURE, body: JSON.stringify({
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS, model: SITE_CHAT_MODEL,
messages: [ temperature: SITE_CHAT_TEMPERATURE,
{ max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
role: "system", messages: [
content: `${SITE_CHAT_SYSTEM_PROMPT}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`, {
}, role: "system",
...messages, content: `${systemPrompt}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
], },
}), ...(shouldUseManualKnowledge
}) ? [
{
role: "system" as const,
content: manualKnowledge
? formatManualContextForPrompt(manualKnowledge)
: "Manual knowledge context:\n- A manual lookup was attempted, but no reliable manual context is available.\n- Do not guess. Ask for the brand, model sticker, or a clear photo/video that can be texted in.",
},
]
: []),
...messages,
],
}),
}
)
const completionData = await completionResponse.json().catch(() => ({})) const completionData = await completionResponse.json().catch(() => ({}))
@ -302,10 +406,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
error: "Jessica is having trouble replying right now. Please try again or call us directly.", error:
"Jessica is having trouble replying right now. Please try again or call us directly.",
sessionId, sessionId,
}, },
{ status: 502, headers: responseHeaders }, { status: 502, headers: responseHeaders }
) )
} }
@ -317,11 +422,15 @@ export async function POST(request: NextRequest) {
error: "Jessica did not return a usable reply. Please try again.", error: "Jessica did not return a usable reply. Please try again.",
sessionId, sessionId,
}, },
{ status: 502, headers: responseHeaders }, { status: 502, headers: responseHeaders }
) )
} }
consumeChatOutput({ chars: assistantReply.length, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, sessionId }) consumeChatOutput({
chars: assistantReply.length,
outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS,
sessionId,
})
const nextLimitStatus = getChatRateLimitStatus({ const nextLimitStatus = getChatRateLimitStatus({
ip, ip,
@ -339,7 +448,7 @@ export async function POST(request: NextRequest) {
sessionId, sessionId,
limits: nextLimitStatus, limits: nextLimitStatus,
}, },
{ headers: responseHeaders }, { headers: responseHeaders }
) )
response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, { response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
@ -355,7 +464,10 @@ export async function POST(request: NextRequest) {
console.error("[site-chat] request failed", error) console.error("[site-chat] request failed", error)
const safeError = const safeError =
error instanceof Error && error.message.startsWith("Missing required site chat environment variable:") error instanceof Error &&
error.message.startsWith(
"Missing required site chat environment variable:"
)
? "Jessica is temporarily unavailable right now. Please call us or use the contact form." ? "Jessica is temporarily unavailable right now. Please call us or use the contact form."
: error instanceof Error : error instanceof Error
? error.message ? error.message
@ -365,7 +477,7 @@ export async function POST(request: NextRequest) {
{ {
error: safeError, error: safeError,
}, },
{ status: 500, headers: responseHeaders }, { status: 500, headers: responseHeaders }
) )
} }
} }

View file

@ -1,13 +1,13 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict"
import test from "node:test"; import test from "node:test"
import { import {
processLeadSubmission, processLeadSubmission,
type ContactLeadPayload, type ContactLeadPayload,
type RequestMachineLeadPayload, type RequestMachineLeadPayload,
} from "@/lib/server/contact-submission"; } from "@/lib/server/contact-submission"
test("processLeadSubmission stores and syncs a contact lead", async () => { test("processLeadSubmission stores and syncs a contact lead", async () => {
const calls: string[] = []; const calls: string[] = []
const payload: ContactLeadPayload = { const payload: ContactLeadPayload = {
kind: "contact", kind: "contact",
firstName: "John", firstName: "John",
@ -26,7 +26,7 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
page: "/contact", page: "/contact",
timestamp: "2026-03-25T00:00:00.000Z", timestamp: "2026-03-25T00:00:00.000Z",
url: "https://rmv.example/contact", url: "https://rmv.example/contact",
}; }
const result = await processLeadSubmission(payload, "rmv.example", { const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: true, storageConfigured: true,
@ -36,37 +36,37 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
tenantName: "Rocky Mountain Vending", tenantName: "Rocky Mountain Vending",
tenantDomains: ["rockymountainvending.com"], tenantDomains: ["rockymountainvending.com"],
ingest: async () => { ingest: async () => {
calls.push("ingest"); calls.push("ingest")
return { return {
inserted: true, inserted: true,
leadId: "lead_123", leadId: "lead_123",
idempotencyKey: "abc", idempotencyKey: "abc",
tenantId: "tenant_123", tenantId: "tenant_123",
}; }
}, },
updateLeadStatus: async () => { updateLeadStatus: async () => {
calls.push("update"); calls.push("update")
return { ok: true }; return { ok: true }
}, },
sendEmail: async () => { sendEmail: async () => {
calls.push("email"); calls.push("email")
return {}; return {}
}, },
createContact: async () => { createContact: async () => {
calls.push("ghl"); calls.push("ghl")
return { contact: { id: "ghl_123" } }; return { contact: { id: "ghl_123" } }
}, },
logger: console, logger: console,
}); })
assert.equal(result.status, 200); assert.equal(result.status, 200)
assert.equal(result.body.success, true); assert.equal(result.body.success, true)
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"]); assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"])
assert.equal(calls.filter((call) => call === "email").length, 2); assert.equal(calls.filter((call) => call === "email").length, 2)
assert.ok(calls.includes("ingest")); assert.ok(calls.includes("ingest"))
assert.ok(calls.includes("update")); assert.ok(calls.includes("update"))
assert.ok(calls.includes("ghl")); assert.ok(calls.includes("ghl"))
}); })
test("processLeadSubmission validates request-machine submissions", async () => { test("processLeadSubmission validates request-machine submissions", async () => {
const payload: RequestMachineLeadPayload = { const payload: RequestMachineLeadPayload = {
@ -84,7 +84,7 @@ test("processLeadSubmission validates request-machine submissions", async () =>
consentVersion: "sms-consent-v1-2026-03-26", consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z", consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/", consentSourcePage: "/",
}; }
const result = await processLeadSubmission(payload, "rmv.example", { const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: false, storageConfigured: false,
@ -94,24 +94,24 @@ test("processLeadSubmission validates request-machine submissions", async () =>
tenantName: "Rocky Mountain Vending", tenantName: "Rocky Mountain Vending",
tenantDomains: [], tenantDomains: [],
ingest: async () => { ingest: async () => {
throw new Error("should not run"); throw new Error("should not run")
}, },
updateLeadStatus: async () => { updateLeadStatus: async () => {
throw new Error("should not run"); throw new Error("should not run")
}, },
sendEmail: async () => { sendEmail: async () => {
throw new Error("should not run"); throw new Error("should not run")
}, },
createContact: async () => { createContact: async () => {
throw new Error("should not run"); throw new Error("should not run")
}, },
logger: console, logger: console,
}); })
assert.equal(result.status, 400); assert.equal(result.status, 400)
assert.equal(result.body.success, false); assert.equal(result.body.success, false)
assert.match(result.body.error || "", /Invalid number of employees/); assert.match(result.body.error || "", /Invalid number of employees/)
}); })
test("processLeadSubmission returns deduped success when Convex already has the lead", async () => { test("processLeadSubmission returns deduped success when Convex already has the lead", async () => {
const payload: ContactLeadPayload = { const payload: ContactLeadPayload = {
@ -126,7 +126,7 @@ test("processLeadSubmission returns deduped success when Convex already has the
consentVersion: "sms-consent-v1-2026-03-26", consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z", consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/contact-us", consentSourcePage: "/contact-us",
}; }
const result = await processLeadSubmission(payload, "rmv.example", { const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: true, storageConfigured: true,
@ -145,10 +145,10 @@ test("processLeadSubmission returns deduped success when Convex already has the
sendEmail: async () => ({}), sendEmail: async () => ({}),
createContact: async () => null, createContact: async () => null,
logger: console, logger: console,
}); })
assert.equal(result.status, 200); assert.equal(result.status, 200)
assert.equal(result.body.success, true); assert.equal(result.body.success, true)
assert.equal(result.body.deduped, true); assert.equal(result.body.deduped, true)
assert.deepEqual(result.body.deliveredVia, ["convex"]); assert.deepEqual(result.body.deliveredVia, ["convex"])
}); })

View file

@ -1,5 +1,5 @@
import { handleLeadRequest } from "@/lib/server/contact-submission"; import { handleLeadRequest } from "@/lib/server/contact-submission"
export async function POST(request: Request) { export async function POST(request: Request) {
return handleLeadRequest(request); return handleLeadRequest(request)
} }

View file

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

View file

@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from "next/server"
import {
computeEbayChallengeResponse,
getEbayNotificationEndpoint,
getEbayNotificationVerificationToken,
verifyEbayNotificationSignature,
} from "@/lib/ebay-notifications"
export const runtime = "nodejs"
type EbayNotificationPayload = {
metadata?: {
topic?: string
schemaVersion?: string
deprecated?: boolean
}
notification?: {
notificationId?: string
eventDate?: string
publishDate?: string
publishAttemptCount?: number
data?: {
username?: string
userId?: string
eiasToken?: string
}
}
}
function parseNotificationBody(rawBody: string) {
if (!rawBody.trim()) {
return null
}
try {
return JSON.parse(rawBody) as EbayNotificationPayload
} catch {
return null
}
}
function getChallengeCode(request: NextRequest) {
return (
request.nextUrl.searchParams.get("challenge_code") ||
request.nextUrl.searchParams.get("challengeCode") ||
""
).trim()
}
export async function GET(request: NextRequest) {
const challengeCode = getChallengeCode(request)
if (!challengeCode) {
return NextResponse.json(
{ error: "Missing challenge_code query parameter." },
{ status: 400 }
)
}
const verificationToken = getEbayNotificationVerificationToken()
if (!verificationToken) {
return NextResponse.json(
{ error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." },
{ status: 500 }
)
}
const endpoint = getEbayNotificationEndpoint(request.url)
if (!endpoint) {
return NextResponse.json(
{ error: "EBAY_NOTIFICATION_ENDPOINT is not configured." },
{ status: 500 }
)
}
const challengeResponse = computeEbayChallengeResponse({
challengeCode,
endpoint,
verificationToken,
})
return NextResponse.json(
{ challengeResponse },
{
headers: {
"Cache-Control": "no-store",
},
}
)
}
export async function POST(request: NextRequest) {
const body = await request.text()
const signatureHeader = request.headers.get("x-ebay-signature")
try {
const verification = await verifyEbayNotificationSignature({
body,
signatureHeader,
})
if (!verification.verified) {
if (
verification.reason ===
"Notification verification credentials are not configured."
) {
console.warn(
"[ebay/notifications] accepted notification without signature verification",
{
reason: verification.reason,
}
)
const payload = parseNotificationBody(body)
const notification = payload?.notification
console.info(
"[ebay/notifications] accepted notification without verification",
{
topic: payload?.metadata?.topic || "unknown",
notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
}
)
return new NextResponse(null, { status: 204 })
}
console.warn("[ebay/notifications] signature rejected", {
reason: verification.reason,
})
return NextResponse.json({ error: verification.reason }, { status: 412 })
}
const payload = parseNotificationBody(body)
const notification = payload?.notification
console.info("[ebay/notifications] accepted notification", {
keyId: verification.keyId,
topic: payload?.metadata?.topic || "unknown",
notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
})
return new NextResponse(null, { status: 204 })
} catch (error) {
console.error("[ebay/notifications] failed to process notification", {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to verify eBay notification.",
},
{ status: 500 }
)
}
}

View file

@ -0,0 +1,120 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import {
filterTrustedEbayListings,
rankListingsForQuery,
type CachedEbayListing,
type EbayCacheState,
} from "@/lib/ebay-parts-match"
type CacheSource = "convex" | "fallback"
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,
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const keywords = searchParams.get("keywords")?.trim() || ""
const maxResults = Math.min(
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
20
)
if (!keywords) {
return NextResponse.json(
{ error: "Keywords parameter is required" },
{ status: 400 }
)
}
if (!hasConvexUrl()) {
const message =
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
return NextResponse.json({
query: keywords,
results: [],
cache: getDisabledCacheState(message),
cacheSource: "fallback" satisfies CacheSource,
error: message,
})
}
try {
const [overview, listings] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
])
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,
})
} catch (error) {
console.error("Failed to load cached eBay listings:", error)
const message =
error instanceof Error
? `Cached eBay listings are unavailable: ${error.message}`
: "Cached eBay listings are unavailable."
return NextResponse.json(
{
query: keywords,
results: [],
cache: getErrorCacheState(message),
cacheSource: "fallback" satisfies CacheSource,
error: message,
},
{ status: 200 }
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,32 +1,41 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server"
import { fetchMutation, fetchQuery } from "convex/nextjs"; import { fetchMutation, fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"; import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { buildPhoneCallSummary, sendPhoneCallSummaryEmail } from "@/lib/phone-calls"; import {
buildPhoneCallSummary,
sendPhoneCallSummaryEmail,
} from "@/lib/phone-calls"
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request); const authError = await requirePhoneAgentInternalAuth(request)
if (authError) { if (authError) {
return authError; return authError
} }
try { try {
const body = await request.json(); const body = await request.json()
const callId = String(body.sessionId || body.roomName || ""); const callId = String(body.sessionId || body.roomName || "")
if (!callId) { if (!callId) {
return NextResponse.json({ error: "sessionId or roomName is required" }, { status: 400 }); return NextResponse.json(
{ error: "sessionId or roomName is required" },
{ status: 400 }
)
} }
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, { const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
callId, callId,
}); })
if (!detail) { if (!detail) {
return NextResponse.json({ error: "Phone call not found" }, { status: 404 }); return NextResponse.json(
{ error: "Phone call not found" },
{ status: 404 }
)
} }
const url = new URL(request.url); const url = new URL(request.url)
const summaryText = buildPhoneCallSummary(detail); const summaryText = buildPhoneCallSummary(detail)
const notificationResult = await sendPhoneCallSummaryEmail({ const notificationResult = await sendPhoneCallSummaryEmail({
detail: { detail: {
...detail, ...detail,
@ -36,7 +45,7 @@ export async function POST(request: Request) {
}, },
}, },
adminUrl: url.origin, adminUrl: url.origin,
}); })
const result = await fetchMutation(api.voiceSessions.completeSession, { const result = await fetchMutation(api.voiceSessions.completeSession, {
sessionId: detail.call.id, sessionId: detail.call.id,
@ -45,20 +54,26 @@ export async function POST(request: Request) {
recordingStatus: body.recordingStatus, recordingStatus: body.recordingStatus,
recordingId: body.recordingId ? String(body.recordingId) : undefined, recordingId: body.recordingId ? String(body.recordingId) : undefined,
recordingUrl: body.recordingUrl ? String(body.recordingUrl) : undefined, recordingUrl: body.recordingUrl ? String(body.recordingUrl) : undefined,
recordingError: body.recordingError ? String(body.recordingError) : undefined, recordingError: body.recordingError
? String(body.recordingError)
: undefined,
summaryText, summaryText,
notificationStatus: notificationResult.status, notificationStatus: notificationResult.status,
notificationSentAt: notificationResult.status === "sent" ? Date.now() : undefined, notificationSentAt:
notificationResult.status === "sent" ? Date.now() : undefined,
notificationError: notificationResult.error, notificationError: notificationResult.error,
}); })
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
call: result, call: result,
notification: notificationResult, notification: notificationResult,
}); })
} catch (error) { } catch (error) {
console.error("Failed to complete phone call sync:", error); console.error("Failed to complete phone call sync:", error)
return NextResponse.json({ error: "Failed to complete phone call sync" }, { status: 500 }); return NextResponse.json(
{ error: "Failed to complete phone call sync" },
{ status: 500 }
)
} }
} }

View file

@ -1,27 +1,77 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server"
import { fetchMutation } 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"
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request); const authError = await requirePhoneAgentInternalAuth(request)
if (authError) { if (authError) {
return authError; return authError
} }
try { try {
const body = await request.json(); const body = await request.json()
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: typeof body.handoffRequested === "boolean" ? body.handoffRequested : undefined, handoffRequested:
handoffReason: body.handoffReason ? String(body.handoffReason) : undefined, typeof body.handoffRequested === "boolean"
}); ? body.handoffRequested
: undefined,
handoffReason: body.handoffReason
? String(body.handoffReason)
: undefined,
reminderStatus: body.reminderStatus || undefined,
reminderRequestedAt:
typeof body.reminderRequestedAt === "number"
? body.reminderRequestedAt
: undefined,
reminderStartAt:
typeof body.reminderStartAt === "number"
? body.reminderStartAt
: undefined,
reminderEndAt:
typeof body.reminderEndAt === "number"
? body.reminderEndAt
: undefined,
reminderCalendarEventId: body.reminderCalendarEventId
? String(body.reminderCalendarEventId)
: undefined,
reminderCalendarHtmlLink: body.reminderCalendarHtmlLink
? String(body.reminderCalendarHtmlLink)
: undefined,
reminderNote: body.reminderNote ? String(body.reminderNote) : undefined,
warmTransferStatus: body.warmTransferStatus || undefined,
warmTransferTarget: body.warmTransferTarget
? String(body.warmTransferTarget)
: undefined,
warmTransferAttemptedAt:
typeof body.warmTransferAttemptedAt === "number"
? body.warmTransferAttemptedAt
: undefined,
warmTransferConnectedAt:
typeof body.warmTransferConnectedAt === "number"
? body.warmTransferConnectedAt
: undefined,
warmTransferFailureReason: body.warmTransferFailureReason
? String(body.warmTransferFailureReason)
: undefined,
})
return NextResponse.json({ success: true, call: result }); return NextResponse.json({ success: true, call: result })
} catch (error) { } catch (error) {
console.error("Failed to link phone call lead:", error); console.error("Failed to link phone call lead:", error)
return NextResponse.json({ error: "Failed to link phone call lead" }, { status: 500 }); return NextResponse.json(
{ error: "Failed to link phone call lead" },
{ status: 500 }
)
} }
} }

View file

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

View file

@ -1,37 +1,79 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"; import { fetchMutation, fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"; import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { normalizePhoneE164 } from "@/lib/phone-normalization"
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request); const authError = await requirePhoneAgentInternalAuth(request)
if (authError) { if (authError) {
return authError; return authError
} }
try { try {
const body = await request.json(); const body = await request.json()
const result = await fetchMutation(api.voiceSessions.upsertPhoneCallSession, { let metadata: Record<string, unknown> = {}
roomName: String(body.roomName || ""), if (typeof body.metadata === "string" && body.metadata.trim()) {
participantIdentity: String(body.participantIdentity || ""), try {
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined, metadata = JSON.parse(body.metadata)
pathname: body.pathname ? String(body.pathname) : undefined, } catch {
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined, metadata = {}
source: "phone-agent", }
metadata: body.metadata ? String(body.metadata) : undefined, }
startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined, const callerPhone = normalizePhoneE164(
recordingDisclosureAt: metadata.participantPhone || body.participantIdentity
typeof body.recordingDisclosureAt === "number" ? body.recordingDisclosureAt : undefined, )
recordingStatus: body.recordingStatus || "pending", const contactContext = callerPhone
}); ? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
normalizedPhone: callerPhone,
})
: null
const result = await fetchMutation(
api.voiceSessions.upsertPhoneCallSession,
{
roomName: String(body.roomName || ""),
participantIdentity: String(body.participantIdentity || ""),
callerPhone: callerPhone || undefined,
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
pathname: body.pathname ? String(body.pathname) : undefined,
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
source: "phone-agent",
metadata: body.metadata ? String(body.metadata) : undefined,
contactProfileId: contactContext?.contactProfile?.id,
contactDisplayName:
contactContext?.contactProfile?.displayName ||
(contactContext?.recentLead
? `${contactContext.recentLead.firstName} ${contactContext.recentLead.lastName}`.trim()
: undefined),
contactCompany:
contactContext?.contactProfile?.company ||
contactContext?.recentLead?.company ||
undefined,
startedAt:
typeof body.startedAt === "number" ? body.startedAt : undefined,
recordingDisclosureAt:
typeof body.recordingDisclosureAt === "number"
? body.recordingDisclosureAt
: undefined,
recordingStatus: body.recordingStatus || "pending",
}
)
return NextResponse.json({ return NextResponse.json({
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)
return NextResponse.json({ error: "Failed to start phone call sync" }, { status: 500 }); return NextResponse.json(
{ error: "Failed to start phone call sync" },
{ status: 500 }
)
} }
} }

View file

@ -1,16 +1,16 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server"
import { fetchMutation } 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"
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request); const authError = await requirePhoneAgentInternalAuth(request)
if (authError) { if (authError) {
return authError; return authError
} }
try { try {
const body = await request.json(); const body = await request.json()
await fetchMutation(api.voiceSessions.addTranscriptTurn, { await fetchMutation(api.voiceSessions.addTranscriptTurn, {
sessionId: body.sessionId, sessionId: body.sessionId,
roomName: String(body.roomName || ""), roomName: String(body.roomName || ""),
@ -21,12 +21,16 @@ export async function POST(request: Request) {
isFinal: typeof body.isFinal === "boolean" ? body.isFinal : undefined, isFinal: typeof body.isFinal === "boolean" ? body.isFinal : undefined,
language: body.language ? String(body.language) : undefined, language: body.language ? String(body.language) : undefined,
source: "phone-agent", source: "phone-agent",
createdAt: typeof body.createdAt === "number" ? body.createdAt : undefined, createdAt:
}); typeof body.createdAt === "number" ? body.createdAt : undefined,
})
return NextResponse.json({ success: true }); return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Failed to append phone call turn:", error); console.error("Failed to append phone call turn:", error)
return NextResponse.json({ error: "Failed to append phone call turn" }, { status: 500 }); return NextResponse.json(
{ error: "Failed to append phone call turn" },
{ status: 500 }
)
} }
} }

View file

@ -11,11 +11,19 @@ type TokenRequestBody = {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = (await request.json().catch(() => ({}))) as TokenRequestBody const body = (await request.json().catch(() => ({}))) as TokenRequestBody
const pathname = typeof body.pathname === "string" && body.pathname.trim() ? body.pathname.trim() : "/" const pathname =
typeof body.pathname === "string" && body.pathname.trim()
? body.pathname.trim()
: "/"
if (isVoiceAssistantSuppressedRoute(pathname)) { if (isVoiceAssistantSuppressedRoute(pathname)) {
console.info("[voice-assistant/token] blocked on suppressed route", { pathname }) console.info("[voice-assistant/token] blocked on suppressed route", {
return NextResponse.json({ error: "Voice assistant is not available on this route." }, { status: 403 }) pathname,
})
return NextResponse.json(
{ error: "Voice assistant is not available on this route." },
{ status: 403 }
)
} }
const tokenResponse = await createVoiceAssistantTokenResponse(pathname) const tokenResponse = await createVoiceAssistantTokenResponse(pathname)
@ -30,9 +38,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
error: error instanceof Error ? error.message : "Failed to create voice assistant token", error:
error instanceof Error
? error.message
: "Failed to create voice assistant token",
}, },
{ status: 500 }, { status: 500 }
) )
} }
} }

View file

@ -27,7 +27,7 @@ function invalidPath(pathValue: string) {
export async function GET( export async function GET(
_request: Request, _request: Request,
{ params }: { params: Promise<{ path: string[] }> }, { params }: { params: Promise<{ path: string[] }> }
) { ) {
try { try {
const { path: pathArray } = await params const { path: pathArray } = await params
@ -59,7 +59,9 @@ export async function GET(
return new NextResponse("File not found", { status: 404 }) return new NextResponse("File not found", { status: 404 })
} }
const fileToRead = existsSync(normalizedFullPath) ? normalizedFullPath : fullPath const fileToRead = existsSync(normalizedFullPath)
? normalizedFullPath
: fullPath
const resolvedPath = fileToRead.replace(/\\/g, "/") const resolvedPath = fileToRead.replace(/\\/g, "/")
if (!resolvedPath.startsWith(normalizedManualsDir)) { if (!resolvedPath.startsWith(normalizedManualsDir)) {
return new NextResponse("Invalid path", { status: 400 }) return new NextResponse("Invalid path", { status: 400 })

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from "next/server"
import { requireAdminToken } from '@/lib/server/admin-auth' import { requireAdminToken } from "@/lib/server/admin-auth"
// Order types // Order types
interface OrderItem { interface OrderItem {
@ -17,7 +17,7 @@ interface Order {
items: OrderItem[] items: OrderItem[]
totalAmount: number totalAmount: number
currency: string currency: string
status: 'pending' | 'paid' | 'fulfilled' | 'cancelled' | 'refunded' status: "pending" | "paid" | "fulfilled" | "cancelled" | "refunded"
paymentIntentId: string | null paymentIntentId: string | null
stripeSessionId: string | null stripeSessionId: string | null
createdAt: string createdAt: string
@ -38,7 +38,7 @@ let orders: Order[] = []
// Generate a simple ID for demo // Generate a simple ID for demo
function generateOrderId(): string { function generateOrderId(): string {
return 'ORD-' + Date.now() + '-' + Math.floor(Math.random() * 1000) return "ORD-" + Date.now() + "-" + Math.floor(Math.random() * 1000)
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@ -49,26 +49,29 @@ export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1') const page = parseInt(searchParams.get("page") || "1")
const limit = parseInt(searchParams.get('limit') || '10') const limit = parseInt(searchParams.get("limit") || "10")
const status = searchParams.get('status') || undefined const status = searchParams.get("status") || undefined
const customerEmail = searchParams.get('customerEmail') || undefined const customerEmail = searchParams.get("customerEmail") || undefined
// Filter orders // Filter orders
let filteredOrders = [...orders] let filteredOrders = [...orders]
if (status) { if (status) {
filteredOrders = filteredOrders.filter(order => order.status === status) filteredOrders = filteredOrders.filter((order) => order.status === status)
} }
if (customerEmail) { if (customerEmail) {
filteredOrders = filteredOrders.filter(order => filteredOrders = filteredOrders.filter((order) =>
order.customerEmail.toLowerCase().includes(customerEmail.toLowerCase()) order.customerEmail.toLowerCase().includes(customerEmail.toLowerCase())
) )
} }
// Sort by creation date (newest first) // Sort by creation date (newest first)
filteredOrders.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) filteredOrders.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
// Pagination // Pagination
const startIndex = (page - 1) * limit const startIndex = (page - 1) * limit
@ -81,13 +84,13 @@ export async function GET(request: NextRequest) {
page, page,
limit, limit,
total: filteredOrders.length, total: filteredOrders.length,
totalPages: Math.ceil(filteredOrders.length / limit) totalPages: Math.ceil(filteredOrders.length / limit),
} },
}) })
} catch (error) { } catch (error) {
console.error('Error fetching orders:', error) console.error("Error fetching orders:", error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch orders' }, { error: "Failed to fetch orders" },
{ status: 500 } { status: 500 }
) )
} }
@ -101,40 +104,43 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { items, customerEmail, paymentIntentId, stripeSessionId, shippingAddress } = body const {
items,
customerEmail,
paymentIntentId,
stripeSessionId,
shippingAddress,
} = body
// Validate required fields // Validate required fields
if (!items || !Array.isArray(items) || items.length === 0) { if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json( return NextResponse.json({ error: "Items are required" }, { status: 400 })
{ error: 'Items are required' },
{ status: 400 }
)
} }
if (!customerEmail) { if (!customerEmail) {
return NextResponse.json( return NextResponse.json(
{ error: 'Customer email is required' }, { error: "Customer email is required" },
{ status: 400 } { status: 400 }
) )
} }
if (!paymentIntentId) { if (!paymentIntentId) {
return NextResponse.json( return NextResponse.json(
{ error: 'Payment intent ID is required' }, { error: "Payment intent ID is required" },
{ status: 400 } { status: 400 }
) )
} }
if (!stripeSessionId) { if (!stripeSessionId) {
return NextResponse.json( return NextResponse.json(
{ error: 'Stripe session ID is required' }, { error: "Stripe session ID is required" },
{ status: 400 } { status: 400 }
) )
} }
// Calculate total // Calculate total
const totalAmount = items.reduce((total: number, item: OrderItem) => { const totalAmount = items.reduce((total: number, item: OrderItem) => {
return total + (item.price * item.quantity) return total + item.price * item.quantity
}, 0) }, 0)
// Create order // Create order
@ -144,22 +150,22 @@ export async function POST(request: NextRequest) {
customerEmail, customerEmail,
items, items,
totalAmount, totalAmount,
currency: 'usd', currency: "usd",
status: 'paid', // Assume payment was successful since webhook was triggered status: "paid", // Assume payment was successful since webhook was triggered
paymentIntentId, paymentIntentId,
stripeSessionId, stripeSessionId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
shippingAddress shippingAddress,
} }
orders.unshift(newOrder) // Add to beginning of array orders.unshift(newOrder) // Add to beginning of array
return NextResponse.json(newOrder, { status: 201 }) return NextResponse.json(newOrder, { status: 201 })
} catch (error) { } catch (error) {
console.error('Error creating order:', error) console.error("Error creating order:", error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to create order' }, { error: "Failed to create order" },
{ status: 500 } { status: 500 }
) )
} }

View file

@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from "next/server"
import { getStripeClient } from '@/lib/stripe/client' import { getStripeClient } from "@/lib/stripe/client"
import { requireAdminToken } from '@/lib/server/admin-auth' import { requireAdminToken } from "@/lib/server/admin-auth"
import { import {
fetchAllProducts, fetchAllProducts,
fetchProductById, fetchProductById,
createProductInStripe, createProductInStripe,
updateProductInStripe, updateProductInStripe,
deactivateProductInStripe deactivateProductInStripe,
} from '@/lib/stripe/products' } from "@/lib/stripe/products"
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const authError = requireAdminToken(request) const authError = requireAdminToken(request)
@ -17,10 +17,10 @@ export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1') const page = parseInt(searchParams.get("page") || "1")
const limit = parseInt(searchParams.get('limit') || '20') const limit = parseInt(searchParams.get("limit") || "20")
const search = searchParams.get('search') || undefined const search = searchParams.get("search") || undefined
const category = searchParams.get('category') || undefined const category = searchParams.get("category") || undefined
// Get all products from Stripe // Get all products from Stripe
const products = await fetchAllProducts() const products = await fetchAllProducts()
@ -30,9 +30,10 @@ export async function GET(request: NextRequest) {
if (search) { if (search) {
const searchTerm = search.toLowerCase() const searchTerm = search.toLowerCase()
filteredProducts = filteredProducts.filter(product => filteredProducts = filteredProducts.filter(
product.name.toLowerCase().includes(searchTerm) || (product) =>
product.description?.toLowerCase().includes(searchTerm) product.name.toLowerCase().includes(searchTerm) ||
product.description?.toLowerCase().includes(searchTerm)
) )
} }
@ -57,13 +58,13 @@ export async function GET(request: NextRequest) {
page, page,
limit, limit,
total: filteredProducts.length, total: filteredProducts.length,
totalPages: Math.ceil(filteredProducts.length / limit) totalPages: Math.ceil(filteredProducts.length / limit),
} },
}) })
} catch (error) { } catch (error) {
console.error('Error fetching admin products:', error) console.error("Error fetching admin products:", error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch products' }, { error: "Failed to fetch products" },
{ status: 500 } { status: 500 }
) )
} }
@ -82,14 +83,14 @@ export async function POST(request: NextRequest) {
// Validate required fields // Validate required fields
if (!name || !price) { if (!name || !price) {
return NextResponse.json( return NextResponse.json(
{ error: 'Name and price are required' }, { error: "Name and price are required" },
{ status: 400 } { status: 400 }
) )
} }
if (typeof price !== 'number' || price <= 0) { if (typeof price !== "number" || price <= 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'Price must be a positive number' }, { error: "Price must be a positive number" },
{ status: 400 } { status: 400 }
) )
} }
@ -97,25 +98,25 @@ export async function POST(request: NextRequest) {
// Create product in Stripe // Create product in Stripe
const result = await createProductInStripe({ const result = await createProductInStripe({
name, name,
description: description || '', description: description || "",
price, price,
currency: currency || 'usd', currency: currency || "usd",
images: images || [], images: images || [],
metadata: metadata || {} metadata: metadata || {},
}) })
if (!result) { if (!result) {
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to create product in Stripe' }, { error: "Failed to create product in Stripe" },
{ status: 500 } { status: 500 }
) )
} }
return NextResponse.json(result, { status: 201 }) return NextResponse.json(result, { status: 201 })
} catch (error) { } catch (error) {
console.error('Error creating product:', error) console.error("Error creating product:", error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to create product' }, { error: "Failed to create product" },
{ status: 500 } { status: 500 }
) )
} }
@ -134,7 +135,7 @@ export async function PUT(request: NextRequest) {
if (!action || !Array.isArray(productIds) || productIds.length === 0) { if (!action || !Array.isArray(productIds) || productIds.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'Action and product IDs are required' }, { error: "Action and product IDs are required" },
{ status: 400 } { status: 400 }
) )
} }
@ -143,10 +144,10 @@ export async function PUT(request: NextRequest) {
const results = [] const results = []
switch (action) { switch (action) {
case 'update': case "update":
if (!updates) { if (!updates) {
return NextResponse.json( return NextResponse.json(
{ error: 'Updates are required for action: update' }, { error: "Updates are required for action: update" },
{ status: 400 } { status: 400 }
) )
} }
@ -157,31 +158,31 @@ export async function PUT(request: NextRequest) {
results.push({ results.push({
productId, productId,
success: true, success: true,
data: result data: result,
}) })
} catch (error) { } catch (error) {
results.push({ results.push({
productId, productId,
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
}) })
} }
} }
break break
case 'deactivate': case "deactivate":
for (const productId of productIds) { for (const productId of productIds) {
try { try {
const success = await deactivateProductInStripe(productId) const success = await deactivateProductInStripe(productId)
results.push({ results.push({
productId, productId,
success success,
}) })
} catch (error) { } catch (error) {
results.push({ results.push({
productId, productId,
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
}) })
} }
} }
@ -199,14 +200,14 @@ export async function PUT(request: NextRequest) {
results, results,
summary: { summary: {
total: productIds.length, total: productIds.length,
successful: results.filter(r => r.success).length, successful: results.filter((r) => r.success).length,
failed: results.filter(r => !r.success).length failed: results.filter((r) => !r.success).length,
} },
}) })
} catch (error) { } catch (error) {
console.error('Error bulk updating products:', error) console.error("Error bulk updating products:", error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to bulk update products' }, { error: "Failed to bulk update products" },
{ status: 500 } { status: 500 }
) )
} }

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
import { getGSCConfig } from "@/lib/google-search-console" import { getGSCConfig } from "@/lib/google-search-console"
// API routes are not supported in static export (GHL hosting) // API routes are not supported in static export (GHL hosting)
export const dynamic = 'force-static' export const dynamic = "force-static"
/** /**
* API Route for requesting URL indexing in Google Search Console * API Route for requesting URL indexing in Google Search Console
@ -68,13 +68,14 @@ export async function POST(request: NextRequest) {
success: true, success: true,
message: "Indexing request endpoint ready", message: "Indexing request endpoint ready",
url, url,
note: "See SEO_SETUP.md for complete Google Search Console API setup instructions", note: "See docs/operations/SEO_SETUP.md for complete Google Search Console API setup instructions",
}) })
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : "Unknown error occurred", error:
error instanceof Error ? error.message : "Unknown error occurred",
}, },
{ status: 500 } { status: 500 }
) )
@ -90,9 +91,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
message: "Google Search Console URL Indexing Request API", message: "Google Search Console URL Indexing Request API",
configured: !!(config.serviceAccountEmail && config.privateKey), configured: !!(config.serviceAccountEmail && config.privateKey),
instructions: "POST to this endpoint with { url: 'https://example.com/page' } in body", instructions:
"POST to this endpoint with { url: 'https://example.com/page' } in body",
}) })
} }

View file

@ -1,5 +1,5 @@
import { handleLeadRequest } from "@/lib/server/contact-submission"; import { handleLeadRequest } from "@/lib/server/contact-submission"
export async function POST(request: Request) { export async function POST(request: Request) {
return handleLeadRequest(request, "request-machine"); return handleLeadRequest(request, "request-machine")
} }

View file

@ -3,7 +3,7 @@ import { getGSCConfig } from "@/lib/google-search-console"
import { businessConfig } from "@/lib/seo-config" import { businessConfig } from "@/lib/seo-config"
// API routes are not supported in static export (GHL hosting) // API routes are not supported in static export (GHL hosting)
export const dynamic = 'force-static' export const dynamic = "force-static"
/** /**
* API Route for submitting sitemap to Google Search Console * API Route for submitting sitemap to Google Search Console
@ -21,7 +21,8 @@ export async function generateStaticParams() {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const sitemapUrl = body.sitemapUrl || `${businessConfig.website}/sitemap.xml` const sitemapUrl =
body.sitemapUrl || `${businessConfig.website}/sitemap.xml`
const siteUrl = body.siteUrl || businessConfig.website const siteUrl = body.siteUrl || businessConfig.website
const config = getGSCConfig() const config = getGSCConfig()
@ -60,13 +61,14 @@ export async function POST(request: NextRequest) {
message: "Sitemap submission endpoint ready", message: "Sitemap submission endpoint ready",
sitemapUrl, sitemapUrl,
siteUrl, siteUrl,
note: "See SEO_SETUP.md for complete Google Search Console API setup instructions", note: "See docs/operations/SEO_SETUP.md for complete Google Search Console API setup instructions",
}) })
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : "Unknown error occurred", error:
error instanceof Error ? error.message : "Unknown error occurred",
}, },
{ status: 500 } { status: 500 }
) )
@ -84,9 +86,7 @@ export async function GET() {
message: "Google Search Console Sitemap Submission API", message: "Google Search Console Sitemap Submission API",
sitemapUrl, sitemapUrl,
configured: !!(config.serviceAccountEmail && config.privateKey), configured: !!(config.serviceAccountEmail && config.privateKey),
instructions: "POST to this endpoint with optional sitemapUrl and siteUrl in body", instructions:
"POST to this endpoint with optional sitemapUrl and siteUrl in body",
}) })
} }

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from "next/server"
import { getStripeClient } from '@/lib/stripe/client' import { getStripeClient } from "@/lib/stripe/client"
/** /**
* POST /api/stripe/checkout * POST /api/stripe/checkout
@ -13,24 +13,25 @@ export async function POST(request: NextRequest) {
const { items, successUrl, cancelUrl } = body const { items, successUrl, cancelUrl } = body
if (!items || !Array.isArray(items) || items.length === 0) { if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json( return NextResponse.json({ error: "Items are required" }, { status: 400 })
{ error: 'Items are required' },
{ status: 400 }
)
} }
// Build line items for Stripe Checkout // Build line items for Stripe Checkout
const lineItems = items.map((item: { priceId: string; quantity: number }) => ({ const lineItems = items.map(
price: item.priceId, (item: { priceId: string; quantity: number }) => ({
quantity: item.quantity, price: item.priceId,
})) quantity: item.quantity,
})
)
// Create Stripe Checkout session // Create Stripe Checkout session
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'], payment_method_types: ["card"],
line_items: lineItems, line_items: lineItems,
mode: 'payment', mode: "payment",
success_url: successUrl || `${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, success_url:
successUrl ||
`${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: cancelUrl || `${request.nextUrl.origin}/checkout/cancel`, cancel_url: cancelUrl || `${request.nextUrl.origin}/checkout/cancel`,
metadata: { metadata: {
// Add any additional metadata here // Add any additional metadata here
@ -39,12 +40,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ sessionId: session.id, url: session.url }) return NextResponse.json({ sessionId: session.id, url: session.url })
} catch (error) { } catch (error) {
console.error('Error creating checkout session:', error) console.error("Error creating checkout session:", error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to create checkout session' }, { error: "Failed to create checkout session" },
{ status: 500 } { status: 500 }
) )
} }
} }

View file

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server' import { NextResponse } from "next/server"
import { fetchAllProducts } from '@/lib/stripe/products' import { fetchAllProducts } from "@/lib/stripe/products"
/** /**
* GET /api/stripe/products * GET /api/stripe/products
@ -10,12 +10,10 @@ export async function GET() {
const products = await fetchAllProducts() const products = await fetchAllProducts()
return NextResponse.json({ products }, { status: 200 }) return NextResponse.json({ products }, { status: 200 })
} catch (error) { } catch (error) {
console.error('Error in /api/stripe/products:', error) console.error("Error in /api/stripe/products:", error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch products' }, { error: "Failed to fetch products" },
{ status: 500 } { status: 500 }
) )
} }
} }

View file

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from "next/server"
import { getStripeClient } from '@/lib/stripe/client' import { getStripeClient, STRIPE_API_VERSION } from "@/lib/stripe/client"
import { requireAdminToken } from '@/lib/server/admin-auth' import { requireAdminToken } from "@/lib/server/admin-auth"
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = requireAdminToken(request) const authError = requireAdminToken(request)
@ -17,13 +17,13 @@ export async function POST(request: Request) {
// Get products and prices // Get products and prices
const products = await stripe.products.list({ const products = await stripe.products.list({
active: true, active: true,
expand: ['data.default_price'], expand: ["data.default_price"],
limit: 50, limit: 50,
}) })
// Get payment methods available // Get payment methods available
const paymentMethods = await stripe.paymentMethods.list({ const paymentMethods = await stripe.paymentMethods.list({
type: 'card', type: "card",
}) })
// Get upcoming invoices (to test webhook functionality) // Get upcoming invoices (to test webhook functionality)
@ -36,7 +36,7 @@ export async function POST(request: Request) {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
account: { account: {
id: account.id, id: account.id,
name: account.business_profile?.name || 'N/A', name: account.business_profile?.name || "N/A",
email: account.email, email: account.email,
country: account.country, country: account.country,
charges_enabled: account.charges_enabled, charges_enabled: account.charges_enabled,
@ -44,33 +44,37 @@ export async function POST(request: Request) {
}, },
products: { products: {
total: products.data.length, total: products.data.length,
active: products.data.filter(p => p.active).length, active: products.data.filter((p) => p.active).length,
sample: products.data.slice(0, 3).map(p => ({ sample: products.data.slice(0, 3).map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
description: p.description, description: p.description,
price: p.default_price ? { price: p.default_price
id: (p.default_price as any).id, ? {
unit_amount: (p.default_price as any).unit_amount, id: (p.default_price as any).id,
currency: (p.default_price as any).currency, unit_amount: (p.default_price as any).unit_amount,
} : null, currency: (p.default_price as any).currency,
}
: null,
})), })),
}, },
paymentMethods: { paymentMethods: {
total: paymentMethods.data.length, total: paymentMethods.data.length,
types: [...new Set(paymentMethods.data.map(pm => pm.type))], types: [...new Set(paymentMethods.data.map((pm) => pm.type))],
}, },
recentInvoices: invoices.data.length, recentInvoices: invoices.data.length,
environment: process.env.NODE_ENV, environment: process.env.NODE_ENV,
apiVersion: stripe.version, apiVersion: STRIPE_API_VERSION,
}) })
} catch (error) { } catch (error) {
console.error('Stripe test error:', error) console.error("Stripe test error:", error)
return NextResponse.json({ return NextResponse.json(
success: false, {
error: error instanceof Error ? error.message : 'Unknown error', success: false,
timestamp: new Date().toISOString(), error: error instanceof Error ? error.message : "Unknown error",
}, { status: 500 }) timestamp: new Date().toISOString(),
},
{ status: 500 }
)
} }
} }

View file

@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from "next/server"
import { getStripeClient } from '@/lib/stripe/client' import { getStripeClient } from "@/lib/stripe/client"
import Stripe from 'stripe' import Stripe from "stripe"
// This is your Stripe CLI webhook secret // This is your Stripe CLI webhook secret
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
async function handler(req: NextRequest) { async function handler(req: NextRequest) {
const body = await req.text() const body = await req.text()
const signature = req.headers.get('stripe-signature') || '' const signature = req.headers.get("stripe-signature") || ""
const stripe = getStripeClient() const stripe = getStripeClient()
@ -16,49 +16,44 @@ async function handler(req: NextRequest) {
try { try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret!) event = stripe.webhooks.constructEvent(body, signature, webhookSecret!)
} catch (err) { } catch (err) {
console.error('Webhook signature verification failed:', err) console.error("Webhook signature verification failed:", err)
return NextResponse.json( return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
{ error: 'Invalid signature' },
{ status: 400 }
)
} }
// Handle the event // Handle the event
switch (event.type) { switch (event.type) {
case 'checkout.session.completed': case "checkout.session.completed":
const checkoutSession = event.data.object as Stripe.Checkout.Session const checkoutSession = event.data.object as Stripe.Checkout.Session
console.log('Payment received for session:', checkoutSession.id) console.log("Payment received for session:", checkoutSession.id)
console.log('Customer email:', checkoutSession.customer_email) console.log("Customer email:", checkoutSession.customer_email)
console.log('Amount:', checkoutSession.amount_total) console.log("Amount:", checkoutSession.amount_total)
console.log('Items:', checkoutSession.display_items)
// TODO: Create order record in database // TODO: Create order record in database
// TODO: Send confirmation email to customer // TODO: Send confirmation email to customer
// TODO: Update inventory for physical products // TODO: Update inventory for physical products
break break
case 'payment_intent.succeeded': case "payment_intent.succeeded":
const paymentIntent = event.data.object as Stripe.PaymentIntent const paymentIntent = event.data.object as Stripe.PaymentIntent
console.log('Payment succeeded for intent:', paymentIntent.id) console.log("Payment succeeded for intent:", paymentIntent.id)
// TODO: Handle any post-payment logic // TODO: Handle any post-payment logic
break break
case 'payment_intent.payment_failed': case "payment_intent.payment_failed":
const failedPayment = event.data.object as Stripe.PaymentIntent const failedPayment = event.data.object as Stripe.PaymentIntent
console.log('Payment failed for intent:', failedPayment.id) console.log("Payment failed for intent:", failedPayment.id)
console.log('Failure reason:', failedPayment.last_payment_error?.message) console.log("Failure reason:", failedPayment.last_payment_error?.message)
// TODO: Handle failed payment (notify customer, etc.) // TODO: Handle failed payment (notify customer, etc.)
break break
case 'invoice.payment_succeeded': case "invoice.payment_succeeded":
const invoice = event.data.object as Stripe.Invoice const invoice = event.data.object as Stripe.Invoice
console.log('Invoice payment succeeded for invoice:', invoice.id) console.log("Invoice payment succeeded for invoice:", invoice.id)
// TODO: Handle recurring payment success // TODO: Handle recurring payment success
break break
case 'invoice.payment_failed': case "invoice.payment_failed":
const failedInvoice = event.data.object as Stripe.Invoice const failedInvoice = event.data.object as Stripe.Invoice
console.log('Invoice payment failed for invoice:', failedInvoice.id) console.log("Invoice payment failed for invoice:", failedInvoice.id)
// TODO: Handle recurring payment failure // TODO: Handle recurring payment failure
break break

View file

@ -66,7 +66,7 @@ function getContentType(pathValue: string) {
export async function GET( export async function GET(
_request: Request, _request: Request,
{ params }: { params: Promise<{ path: string[] }> }, { params }: { params: Promise<{ path: string[] }> }
) { ) {
try { try {
const { path: pathArray } = await params const { path: pathArray } = await params
@ -76,12 +76,18 @@ export async function GET(
if ( if (
invalidPath(filePath) || invalidPath(filePath) ||
(!lowerPath.endsWith(".jpg") && !lowerPath.endsWith(".jpeg") && !lowerPath.endsWith(".png") && !lowerPath.endsWith(".webp")) (!lowerPath.endsWith(".jpg") &&
!lowerPath.endsWith(".jpeg") &&
!lowerPath.endsWith(".png") &&
!lowerPath.endsWith(".webp"))
) { ) {
return new NextResponse("Invalid path", { status: 400 }) return new NextResponse("Invalid path", { status: 400 })
} }
const storageObject = await getManualAssetFromStorage("thumbnails", filePath) const storageObject = await getManualAssetFromStorage(
"thumbnails",
filePath
)
if (storageObject) { if (storageObject) {
return new NextResponse(Buffer.from(storageObject.body), { return new NextResponse(Buffer.from(storageObject.body), {
headers: { headers: {
@ -99,7 +105,9 @@ export async function GET(
const normalizedThumbnailsDir = thumbnailsDir.replace(/\\/g, "/") const normalizedThumbnailsDir = thumbnailsDir.replace(/\\/g, "/")
if (!existsSync(normalizedFullPath) && !existsSync(fullPath)) { if (!existsSync(normalizedFullPath) && !existsSync(fullPath)) {
const label = decodedPath.at(-1)?.replace(/\.(jpg|jpeg|png|webp)$/i, "") || "Rocky Mountain Vending" const label =
decodedPath.at(-1)?.replace(/\.(jpg|jpeg|png|webp)$/i, "") ||
"Rocky Mountain Vending"
return new NextResponse(buildPlaceholderSvg(label), { return new NextResponse(buildPlaceholderSvg(label), {
headers: { headers: {
"Content-Type": "image/svg+xml; charset=utf-8", "Content-Type": "image/svg+xml; charset=utf-8",
@ -109,7 +117,9 @@ export async function GET(
}) })
} }
const fileToRead = existsSync(normalizedFullPath) ? normalizedFullPath : fullPath const fileToRead = existsSync(normalizedFullPath)
? normalizedFullPath
: fullPath
const resolvedPath = fileToRead.replace(/\\/g, "/") const resolvedPath = fileToRead.replace(/\\/g, "/")
if (!resolvedPath.startsWith(normalizedThumbnailsDir)) { if (!resolvedPath.startsWith(normalizedThumbnailsDir)) {
return new NextResponse("Invalid path", { status: 400 }) return new NextResponse("Invalid path", { status: 400 })

View file

@ -1,77 +1,60 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } 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 { WhoWeServePage } from '@/components/who-we-serve-page'; import { WhoWeServePage } from "@/components/who-we-serve-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
const WORDPRESS_SLUG = 'enhancing-auto-repair-facilities-with-convenient-vending-solutions'; const WORDPRESS_SLUG =
"enhancing-auto-repair-facilities-with-convenient-vending-solutions"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("autoRepair", {
title: page.title || 'Auto Repair Vending Solutions',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function AutoRepairPage() { export default async function AutoRepairPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; const structuredData = generateRegistryStructuredData("autoRepair", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Auto Repair Vending Solutions', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/auto-repair/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Auto Repair Vending Solutions',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/auto-repair/`,
};
}
return ( return (
<> <>
@ -79,14 +62,17 @@ export default async function AutoRepairPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<WhoWeServePage title={page.title || 'Auto Repair Vending Solutions'} content={content} /> <WhoWeServePage
title={page.title || "Auto Repair Vending Solutions"}
description={page.seoDescription || page.excerpt || undefined}
content={content}
/>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Auto Repair page:', error); console.error("Error rendering Auto Repair page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,76 +1,85 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { buildAbsoluteUrl } from "@/lib/seo-registry"
import { getPageBySlug } from '@/lib/wordpress-data-loader'; import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { cleanWordPressContent } from '@/lib/clean-wordPress-content'; import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { Breadcrumbs } from '@/components/breadcrumbs'; import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import type { Metadata } from 'next'; import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicProse,
PublicSurface,
} from "@/components/public-surface"
import type { Metadata } from "next"
import Link from "next/link"
const WORDPRESS_SLUG = 'abandoned-vending-machines'; const WORDPRESS_SLUG = "abandoned-vending-machines"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateSEOMetadata({
title: page.title || 'Abandoned Vending Machines', title: page.title || "Abandoned Vending Machines",
description: page.seoDescription || page.excerpt || '', description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt, excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); path: "/blog/abandoned-vending-machines",
})
} }
export default async function AbandonedVendingMachinesPage() { export default async function AbandonedVendingMachinesPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; let structuredData
try { try {
structuredData = generateStructuredData({ structuredData = generateStructuredData({
title: page.title || 'Abandoned Vending Machines', title: page.title || "Abandoned Vending Machines",
description: page.seoDescription || page.excerpt || '', description: page.seoDescription || page.excerpt || "",
url: page.link || page.urlPath || `https://rockymountainvending.com/abandoned-vending-machines/`, url: buildAbsoluteUrl("/blog/abandoned-vending-machines"),
datePublished: page.date, datePublished: page.date,
dateModified: page.modified || page.date, dateModified: page.modified || page.date,
type: 'WebPage', type: "WebPage",
}); })
} catch (e) { } catch (e) {
structuredData = { structuredData = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'WebPage', "@type": "WebPage",
headline: page.title || 'Abandoned Vending Machines', headline: page.title || "Abandoned Vending Machines",
description: page.seoDescription || '', description: page.seoDescription || "",
url: `https://rockymountainvending.com/abandoned-vending-machines/`, url: buildAbsoluteUrl("/blog/abandoned-vending-machines"),
}; }
} }
return ( return (
@ -79,21 +88,68 @@ export default async function AbandonedVendingMachinesPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<Breadcrumbs <article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
items={[ <Breadcrumbs
{ label: 'Blog', href: '/blog' }, className="mb-6"
{ label: page.title || 'Abandoned Vending Machines', href: '/blog/abandoned-vending-machines' }, items={[
]} { label: "Blog", href: "/blog" },
/> {
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl"> label: page.title || "Abandoned Vending Machines",
{content} href: "/blog/abandoned-vending-machines",
},
]}
/>
<PublicPageHeader
eyebrow="Article"
title={page.title || "Abandoned Vending Machines"}
description={
page.seoDescription ||
page.excerpt ||
"Guidance, next steps, and practical considerations from Rocky Mountain Vending."
}
align="center"
className="mx-auto mb-10 max-w-3xl"
/>
<PublicSurface className="p-5 md:p-7 lg:p-9">
<PublicProse className="mx-auto max-w-3xl">{content}</PublicProse>
</PublicSurface>
<PublicInset className="mx-auto mt-8 max-w-4xl border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.84))] p-5 md:p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Need Help With A Machine Situation?
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
Get the right kind of support quickly
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Reach out if you need help with abandoned machines, service questions,
moving help, or figuring out the right next step for your location.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Link
href="/contact-us#contact-form"
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
Talk to Our Team
</Link>
<Link
href="/services/repairs"
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
>
Explore Repair Help
</Link>
</div>
</div>
</PublicInset>
</article> </article>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Abandoned Vending Machines page:', error); console.error("Error rendering Abandoned Vending Machines page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,53 +1,56 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { buildAbsoluteUrl } from "@/lib/seo-registry"
import { getPageBySlug } from '@/lib/wordpress-data-loader'; import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { cleanWordPressContent } from '@/lib/clean-wordPress-content'; import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { Breadcrumbs } from '@/components/breadcrumbs'; import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { PublicPageHeader, PublicSurface } from '@/components/public-surface'; import { Breadcrumbs } from "@/components/breadcrumbs"
import type { Metadata } from 'next'; import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
import type { Metadata } from "next"
const WORDPRESS_SLUG = 'best-vending-machine-supplier-in-salt-lake-city-utah'; const WORDPRESS_SLUG = "best-vending-machine-supplier-in-salt-lake-city-utah"
const DISPLAY_TITLE = 'The Best Vending Machine Supplier in Salt Lake City, Utah'; const DISPLAY_TITLE =
"The Best Vending Machine Supplier in Salt Lake City, Utah"
const DISPLAY_DESCRIPTION = const DISPLAY_DESCRIPTION =
'A closer look at how Rocky Mountain Vending supports Utah businesses with free placement, machine sales, repairs, manuals, and responsive local service.'; "A closer look at how Rocky Mountain Vending supports Utah businesses with free placement, machine sales, repairs, manuals, and responsive local service."
function stripLeadingH1(html: string) { function stripLeadingH1(html: string) {
return html.replace(/<h1[^>]*>[\s\S]*?<\/h1>/i, ''); return html.replace(/<h1[^>]*>[\s\S]*?<\/h1>/i, "")
} }
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateSEOMetadata({
title: DISPLAY_TITLE, title: DISPLAY_TITLE,
description: page.seoDescription || page.excerpt || '', description: DISPLAY_DESCRIPTION,
excerpt: page.excerpt, excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); path: "/blog/best-vending-machine-supplier-in-salt-lake-city-utah",
})
} }
export default async function BestVendingMachineSupplierPage() { export default async function BestVendingMachineSupplierPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
@ -60,26 +63,30 @@ export default async function BestVendingMachineSupplierPage() {
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; let structuredData
try { try {
structuredData = generateStructuredData({ structuredData = generateStructuredData({
title: DISPLAY_TITLE, title: DISPLAY_TITLE,
description: page.seoDescription || page.excerpt || '', description: DISPLAY_DESCRIPTION,
url: page.link || page.urlPath || `https://rockymountainvending.com/best-vending-machine-supplier-in-salt-lake-city-utah/`, url: buildAbsoluteUrl(
"/blog/best-vending-machine-supplier-in-salt-lake-city-utah"
),
datePublished: page.date, datePublished: page.date,
dateModified: page.modified || page.date, dateModified: page.modified || page.date,
type: 'WebPage', type: "WebPage",
}); })
} catch (e) { } catch (e) {
structuredData = { structuredData = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'WebPage', "@type": "WebPage",
headline: DISPLAY_TITLE, headline: DISPLAY_TITLE,
description: page.seoDescription || '', description: DISPLAY_DESCRIPTION,
url: `https://rockymountainvending.com/best-vending-machine-supplier-in-salt-lake-city-utah/`, url: buildAbsoluteUrl(
}; "/blog/best-vending-machine-supplier-in-salt-lake-city-utah"
),
}
} }
return ( return (
@ -91,8 +98,11 @@ export default async function BestVendingMachineSupplierPage() {
<Breadcrumbs <Breadcrumbs
className="container mx-auto max-w-6xl px-4 pt-6" className="container mx-auto max-w-6xl px-4 pt-6"
items={[ items={[
{ label: 'Blog', href: '/blog' }, { label: "Blog", href: "/blog" },
{ label: DISPLAY_TITLE, href: '/blog/best-vending-machine-supplier-in-salt-lake-city-utah' }, {
label: DISPLAY_TITLE,
href: "/blog/best-vending-machine-supplier-in-salt-lake-city-utah",
},
]} ]}
/> />
<article className="container mx-auto max-w-6xl px-4 py-8 md:py-12"> <article className="container mx-auto max-w-6xl px-4 py-8 md:py-12">
@ -108,11 +118,14 @@ export default async function BestVendingMachineSupplierPage() {
</PublicSurface> </PublicSurface>
</article> </article>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Best Vending Machine Supplier page:', error); console.error(
"Error rendering Best Vending Machine Supplier page:",
error
)
} }
notFound(); notFound()
} }
} }

View file

@ -1,52 +1,60 @@
import Link from 'next/link' import Link from "next/link"
import Image from 'next/image' import Image from "next/image"
import { Breadcrumbs } from '@/components/breadcrumbs' import { Breadcrumbs } from "@/components/breadcrumbs"
import { PublicInset, PublicPageHeader, PublicSurface } from '@/components/public-surface' import {
import type { Metadata } from 'next' PublicInset,
PublicPageHeader,
PublicSurface,
} from "@/components/public-surface"
import type { Metadata } from "next"
import { generateSEOMetadata } from "@/lib/seo"
export const metadata: Metadata = { export const metadata: Metadata = generateSEOMetadata({
title: 'Blog | Rocky Mountain Vending', title: "Utah Vending Blog | Rocky Mountain Vending",
description: 'Read our latest blog posts about vending machines, services, and industry insights from Rocky Mountain Vending.', description:
alternates: { "Read Rocky Mountain Vending guides, reviews, and Utah-focused vending insights for businesses and property managers.",
canonical: 'https://rockymountainvending.com/blog/', path: "/blog",
}, keywords: [
} "Utah vending blog",
"vending machine guides Utah",
"Rocky Mountain Vending blog",
],
})
const blogPosts = [ const blogPosts = [
{ {
title: 'How to Remove an Abandoned Vending Machine in Utah', title: "How to Remove an Abandoned Vending Machine in Utah",
description: 'A comprehensive guide for Utah businesses dealing with unwanted vending machines on their property.', description:
slug: 'abandoned-vending-machines', "A comprehensive guide for Utah businesses dealing with unwanted vending machines on their property.",
date: 'March 20, 2025', slug: "abandoned-vending-machines",
image: '/images/abandoned-vending-machine-guide.jpg', date: "March 20, 2025",
imageAlt: 'Abandoned vending machine guide', image: "/images/abandoned-vending-machine-guide.jpg",
imageAlt: "Abandoned vending machine guide",
}, },
{ {
title: 'Rocky Mountain Vending Reviews & Testimonials', title: "Rocky Mountain Vending Reviews & Testimonials",
description: 'Read customer reviews and testimonials about our vending machine services in Utah.', description:
slug: 'reviews', "Read customer reviews and testimonials about our vending machine services in Utah.",
date: 'March 20, 2025', slug: "reviews",
image: '/images/customer-reviews.jpg', date: "March 20, 2025",
imageAlt: 'Customer reviews and testimonials', image: "/images/customer-reviews.jpg",
imageAlt: "Customer reviews and testimonials",
}, },
{ {
title: 'The Best Vending Machine Supplier in Salt Lake City, Utah', title: "The Best Vending Machine Supplier in Salt Lake City, Utah",
description: 'Why Rocky Mountain Vending is the top choice for vending machine services in Salt Lake City.', description:
slug: 'best-vending-machine-supplier-in-salt-lake-city-utah', "Why Rocky Mountain Vending is the top choice for vending machine services in Salt Lake City.",
date: 'March 20, 2025', slug: "best-vending-machine-supplier-in-salt-lake-city-utah",
image: '/images/salt-lake-city-vending.jpg', date: "March 20, 2025",
imageAlt: 'Vending machine supplier in Salt Lake City', image: "/images/salt-lake-city-vending.jpg",
imageAlt: "Vending machine supplier in Salt Lake City",
}, },
] ]
export default function BlogPage() { export default function BlogPage() {
return ( return (
<> <>
<Breadcrumbs <Breadcrumbs items={[{ label: "Blog", href: "/blog" }]} />
items={[
{ label: 'Blog', href: '/blog' },
]}
/>
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14"> <article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
<PublicPageHeader <PublicPageHeader
align="center" align="center"
@ -74,7 +82,10 @@ export default function BlogPage() {
</div> </div>
<div className="md:w-2/3"> <div className="md:w-2/3">
<h2 className="text-2xl md:text-3xl font-semibold mb-2 tracking-tight text-balance"> <h2 className="text-2xl md:text-3xl font-semibold mb-2 tracking-tight text-balance">
<Link href={`/blog/${post.slug}`} className="transition-colors hover:text-primary"> <Link
href={`/blog/${post.slug}`}
className="transition-colors hover:text-primary"
>
{post.title} {post.title}
</Link> </Link>
</h2> </h2>

View file

@ -1,76 +1,80 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from '@/lib/wordpress-data-loader'; import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from '@/lib/clean-wordPress-content'; import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { Breadcrumbs } from '@/components/breadcrumbs'; import { DropdownPageShell } from "@/components/dropdown-page-shell"
import type { Metadata } from 'next'; import type { Metadata } from "next"
const WORDPRESS_SLUG = 'reviews'; const WORDPRESS_SLUG = "reviews"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateSEOMetadata({
title: page.title || 'Reviews', title: page.title || "Reviews",
description: page.seoDescription || page.excerpt || '', description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt, excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); path: "/blog/reviews",
})
} }
export default async function ReviewsPage() { export default async function ReviewsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; let structuredData
try { try {
structuredData = generateStructuredData({ structuredData = generateStructuredData({
title: page.title || 'Reviews', title: page.title || "Reviews",
description: page.seoDescription || page.excerpt || '', description: page.seoDescription || page.excerpt || "",
url: page.link || page.urlPath || `https://rockymountainvending.com/reviews/`, url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/reviews/`,
datePublished: page.date, datePublished: page.date,
dateModified: page.modified || page.date, dateModified: page.modified || page.date,
type: 'WebPage', type: "WebPage",
}); })
} catch (e) { } catch (e) {
structuredData = { structuredData = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'WebPage', "@type": "WebPage",
headline: page.title || 'Reviews', headline: page.title || "Reviews",
description: page.seoDescription || '', description: page.seoDescription || "",
url: `https://rockymountainvending.com/reviews/`, url: `https://rockymountainvending.com/reviews/`,
}; }
} }
return ( return (
@ -79,21 +83,41 @@ export default async function ReviewsPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<Breadcrumbs <DropdownPageShell
items={[ breadcrumbs={[
{ label: 'Blog', href: '/blog' }, { label: "Blog", href: "/blog" },
{ label: page.title || 'Reviews', href: '/blog/reviews' }, { label: page.title || "Reviews", href: "/blog/reviews" },
]} ]}
eyebrow="Blog Posts"
title={page.title || "Reviews"}
description={
page.seoDescription ||
page.excerpt ||
"Read Rocky Mountain Vending reviews, testimonials, and customer feedback gathered from businesses across Utah."
}
content={content}
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"
cta={{
eyebrow: "Live Review Feed",
title: "Want the current public review stream instead?",
description:
"For the live Google review feed and the latest customer feedback, head to the main reviews page.",
actions: [
{ label: "View Live Reviews", href: "/reviews" },
{
label: "Talk to Our Team",
href: "/contact-us#contact-form",
variant: "outline",
},
],
}}
/> />
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
{content}
</article>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Reviews page:', error); console.error("Error rendering Reviews page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,77 +1,59 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } 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 { WhoWeServePage } from '@/components/who-we-serve-page'; import { WhoWeServePage } from "@/components/who-we-serve-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
const WORDPRESS_SLUG = 'vending-machines-for-your-car-wash'; const WORDPRESS_SLUG = "vending-machines-for-your-car-wash"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("carWashes", {
title: page.title || 'Car Wash Vending Solutions',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function CarWashesPage() { export default async function CarWashesPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; const structuredData = generateRegistryStructuredData("carWashes", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Car Wash Vending Solutions', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/car-washes/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Car Wash Vending Solutions',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/car-washes/`,
};
}
return ( return (
<> <>
@ -79,14 +61,17 @@ export default async function CarWashesPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<WhoWeServePage title={page.title || 'Car Wash Vending Solutions'} content={content} /> <WhoWeServePage
title={page.title || "Car Wash Vending Solutions"}
description={page.seoDescription || page.excerpt || undefined}
content={content}
/>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Car Washes page:', error); console.error("Error rendering Car Washes page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,10 +1,10 @@
import Link from 'next/link' import Link from "next/link"
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button"
import { XCircle } from 'lucide-react' import { XCircle } from "lucide-react"
export const metadata = { export const metadata = {
title: 'Checkout Cancelled | Rocky Mountain Vending', title: "Checkout Cancelled | Rocky Mountain Vending",
description: 'Your checkout was cancelled', description: "Your checkout was cancelled",
} }
export default function CheckoutCancelPage() { export default function CheckoutCancelPage() {
@ -12,7 +12,9 @@ export default function CheckoutCancelPage() {
<div className="container mx-auto px-4 py-16"> <div className="container mx-auto px-4 py-16">
<div className="max-w-2xl mx-auto text-center"> <div className="max-w-2xl mx-auto text-center">
<XCircle className="h-16 w-16 text-muted-foreground mx-auto mb-6" /> <XCircle className="h-16 w-16 text-muted-foreground mx-auto mb-6" />
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">Checkout Cancelled</h1> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
Checkout Cancelled
</h1>
<p className="text-lg text-muted-foreground mb-8"> <p className="text-lg text-muted-foreground mb-8">
Your checkout was cancelled. No charges were made. Your checkout was cancelled. No charges were made.
</p> </p>
@ -28,5 +30,3 @@ export default function CheckoutCancelPage() {
</div> </div>
) )
} }

View file

@ -1,10 +1,10 @@
import Link from 'next/link' import Link from "next/link"
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button"
import { CheckCircle } from 'lucide-react' import { CheckCircle } from "lucide-react"
export const metadata = { export const metadata = {
title: 'Checkout Success | Rocky Mountain Vending', title: "Checkout Success | Rocky Mountain Vending",
description: 'Your order has been placed successfully', description: "Your order has been placed successfully",
} }
export default function CheckoutSuccessPage() { export default function CheckoutSuccessPage() {
@ -12,10 +12,12 @@ export default function CheckoutSuccessPage() {
<div className="container mx-auto px-4 py-16"> <div className="container mx-auto px-4 py-16">
<div className="max-w-2xl mx-auto text-center"> <div className="max-w-2xl mx-auto text-center">
<CheckCircle className="h-16 w-16 text-green-500 mx-auto mb-6" /> <CheckCircle className="h-16 w-16 text-green-500 mx-auto mb-6" />
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">Order Confirmed!</h1> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
Order Confirmed!
</h1>
<p className="text-lg text-muted-foreground mb-8"> <p className="text-lg text-muted-foreground mb-8">
Thank you for your purchase. Your order has been received and we'll Thank you for your purchase. Your order has been received and we'll be
be in touch soon regarding shipping and delivery details. in touch soon regarding shipping and delivery details.
</p> </p>
<div className="flex gap-4 justify-center"> <div className="flex gap-4 justify-center">
<Button asChild variant="brand"> <Button asChild variant="brand">
@ -29,5 +31,3 @@ export default function CheckoutSuccessPage() {
</div> </div>
) )
} }

View file

@ -1,77 +1,59 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } 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 { WhoWeServePage } from '@/components/who-we-serve-page'; import { WhoWeServePage } from "@/components/who-we-serve-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
const WORDPRESS_SLUG = 'vending-for-your-community-centers'; const WORDPRESS_SLUG = "vending-for-your-community-centers"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("communityCenters", {
title: page.title || 'Community Center Vending Solutions',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function CommunityCentersPage() { export default async function CommunityCentersPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; const structuredData = generateRegistryStructuredData("communityCenters", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Community Center Vending Solutions', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/community-centers/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Community Center Vending Solutions',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/community-centers/`,
};
}
return ( return (
<> <>
@ -79,14 +61,17 @@ export default async function CommunityCentersPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<WhoWeServePage title={page.title || 'Community Center Vending Solutions'} content={content} /> <WhoWeServePage
title={page.title || "Community Center Vending Solutions"}
description={page.seoDescription || page.excerpt || undefined}
content={content}
/>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Community Centers page:', error); console.error("Error rendering Community Centers page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,57 +1,39 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { getPageBySlug } from '@/lib/wordpress-data-loader'; import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { ContactPage } from '@/components/contact-page'; import { ContactPage } from "@/components/contact-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
const WORDPRESS_SLUG = 'contact-us'; const WORDPRESS_SLUG = "contact-us"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("contactUs", {
title: page.title || 'Contact Us',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function ContactUsPage() { export default async function ContactUsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let structuredData; const structuredData = generateRegistryStructuredData("contactUs", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Contact Us', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/contact-us/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Contact Us',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/contact-us/`,
};
}
return ( return (
<> <>
@ -61,19 +43,11 @@ export default async function ContactUsPage() {
/> />
<ContactPage /> <ContactPage />
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Contact Us page:', error); console.error("Error rendering Contact Us page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,77 +1,59 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } 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 { WhoWeServePage } from '@/components/who-we-serve-page'; import { WhoWeServePage } from "@/components/who-we-serve-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
const WORDPRESS_SLUG = 'vending-machine-for-your-dance-studio'; const WORDPRESS_SLUG = "vending-machine-for-your-dance-studio"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("danceStudios", {
title: page.title || 'Dance Studio Vending Solutions',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function DanceStudiosPage() { export default async function DanceStudiosPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; const structuredData = generateRegistryStructuredData("danceStudios", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Dance Studio Vending Solutions', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/dance-studios/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Dance Studio Vending Solutions',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/dance-studios/`,
};
}
return ( return (
<> <>
@ -79,14 +61,17 @@ export default async function DanceStudiosPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<WhoWeServePage title={page.title || 'Dance Studio Vending Solutions'} content={content} /> <WhoWeServePage
title={page.title || "Dance Studio Vending Solutions"}
description={page.seoDescription || page.excerpt || undefined}
content={content}
/>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Dance Studios page:', error); console.error("Error rendering Dance Studios page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,76 +1,59 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } 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 type { Metadata } from 'next'; import { DropdownPageShell } from "@/components/dropdown-page-shell"
import type { Metadata } from "next"
const WORDPRESS_SLUG = 'healthy-vending'; const WORDPRESS_SLUG = "healthy-vending"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("healthyOptions", {
title: page.title || 'Healthy Vending Options',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function HealthyOptionsPage() { export default async function HealthyOptionsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; const structuredData = generateRegistryStructuredData("healthyOptions", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Healthy Vending Options', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/food-and-beverage/healthy-options/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Healthy Vending Options',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/food-and-beverage/healthy-options/`,
};
}
return ( return (
<> <>
@ -78,26 +61,41 @@ export default async function HealthyOptionsPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl"> <DropdownPageShell
<header className="mb-8"> breadcrumbs={[
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Healthy Vending Options'}</h1> { label: "Food & Beverage" },
</header> { label: "Healthy Options" },
{content} ]}
</article> eyebrow="Food & Beverage"
title={page.title || "Healthy Vending Options"}
description={
page.seoDescription ||
page.excerpt ||
"Explore healthier snack and beverage options, how we balance product variety, and what a better-for-you vending mix can look like at your location."
}
content={content}
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"
cta={{
eyebrow: "Next Step",
title: "Need help planning the right mix?",
description:
"We can help you balance healthier products, traditional favorites, and machine type so the lineup works for your traffic instead of just looking good on paper.",
actions: [
{ label: "Talk to Our Team", href: "/contact-us#contact-form" },
{
label: "See If You Qualify",
href: "/#request-machine",
variant: "outline",
},
],
}}
/>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Healthy Options page:', error); console.error("Error rendering Healthy Options page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,76 +1,60 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } 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 type { Metadata } from 'next'; import { DropdownPageShell } from "@/components/dropdown-page-shell"
import type { Metadata } from "next"
const WORDPRESS_SLUG = 'diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts'; const WORDPRESS_SLUG =
"diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("suppliers", {
title: page.title || 'Food & Beverage Suppliers',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function SuppliersPage() { export default async function SuppliersPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; const structuredData = generateRegistryStructuredData("suppliers", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Food & Beverage Suppliers', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/food-and-beverage/suppliers/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Food & Beverage Suppliers',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/food-and-beverage/suppliers/`,
};
}
return ( return (
<> <>
@ -78,26 +62,41 @@ export default async function SuppliersPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl"> <DropdownPageShell
<header className="mb-8"> breadcrumbs={[
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Food & Beverage Suppliers'}</h1> { label: "Food & Beverage" },
</header> { label: "Suppliers" },
{content} ]}
</article> eyebrow="Food & Beverage"
title={page.title || "Food & Beverage Suppliers"}
description={
page.seoDescription ||
page.excerpt ||
"Learn how Rocky Mountain Vending sources products, keeps variety available, and builds a mix around both customer demand and dependable supply."
}
content={content}
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"
cta={{
eyebrow: "Need a Mix Review?",
title: "Talk through product options for your location",
description:
"If you want help choosing a better snack and beverage mix, we can help with audience fit, healthier options, and machine planning.",
actions: [
{ label: "Contact Us", href: "/contact-us#contact-form" },
{
label: "View Traditional Options",
href: "/food-and-beverage/traditional-options",
variant: "outline",
},
],
}}
/>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Suppliers page:', error); console.error("Error rendering Suppliers page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -1,76 +1,59 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } 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 type { Metadata } from 'next'; import { DropdownPageShell } from "@/components/dropdown-page-shell"
import type { Metadata } from "next"
const WORDPRESS_SLUG = 'traditional-vending'; const WORDPRESS_SLUG = "traditional-vending"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("traditionalOptions", {
title: page.title || 'Traditional Vending Options',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function TraditionalOptionsPage() { export default async function TraditionalOptionsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; const structuredData = generateRegistryStructuredData("traditionalOptions", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Traditional Vending Options', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/food-and-beverage/traditional-options/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Traditional Vending Options',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/food-and-beverage/traditional-options/`,
};
}
return ( return (
<> <>
@ -78,26 +61,41 @@ export default async function TraditionalOptionsPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl"> <DropdownPageShell
<header className="mb-8"> breadcrumbs={[
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Traditional Vending Options'}</h1> { label: "Food & Beverage" },
</header> { label: "Traditional Options" },
{content} ]}
</article> eyebrow="Food & Beverage"
title={page.title || "Traditional Vending Options"}
description={
page.seoDescription ||
page.excerpt ||
"See the traditional snack and beverage options Rocky Mountain Vending can stock, and how we shape a lineup around the people using the location."
}
content={content}
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"
cta={{
eyebrow: "Product Planning",
title: "Want a lineup that actually fits your location?",
description:
"We can help you mix traditional favorites, healthier options, drinks, and pricing tiers in a way that makes sense for your audience and traffic.",
actions: [
{ label: "Talk to Our Team", href: "/contact-us#contact-form" },
{
label: "View Healthy Options",
href: "/food-and-beverage/healthy-options",
variant: "outline",
},
],
}}
/>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Traditional Options page:', error); console.error("Error rendering Traditional Options page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -10,20 +10,20 @@
--card-foreground: oklch(0.178 0.014 275.627); --card-foreground: oklch(0.178 0.014 275.627);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.178 0.014 275.627); --popover-foreground: oklch(0.178 0.014 275.627);
--primary: #29A047; /* Primary brand color (green from logo) */ --primary: #29a047; /* Primary brand color (green from logo) */
--primary-foreground: oklch(0.989 0.003 106.423); --primary-foreground: oklch(0.989 0.003 106.423);
--primary-dark: #1d7a35; /* Darker primary for gradients and hover states */ --primary-dark: #1d7a35; /* Darker primary for gradients and hover states */
--secondary: #54595F; /* Secondary color (gray) */ --secondary: #54595f; /* Secondary color (gray) */
--secondary-foreground: oklch(1 0 0); --secondary-foreground: oklch(1 0 0);
--muted: oklch(0.961 0.004 106.423); --muted: oklch(0.961 0.004 106.423);
--muted-foreground: oklch(0.556 0.014 275.627); --muted-foreground: oklch(0.556 0.014 275.627);
--accent: #C4142C; /* Accent color (red - matches link hover) */ --accent: #c4142c; /* Accent color (red - matches link hover) */
--accent-foreground: oklch(1 0 0); --accent-foreground: oklch(1 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0.004 106.423); --border: oklch(0.922 0.004 106.423);
--input: oklch(0.922 0.004 106.423); --input: oklch(0.922 0.004 106.423);
--ring: #29A047; /* Primary color for focus rings */ --ring: #29a047; /* Primary color for focus rings */
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@ -45,19 +45,26 @@
--link-hover-color-dark: #a01020; /* Darker red for gradients and hover states */ --link-hover-color-dark: #a01020; /* Darker red for gradients and hover states */
--link-hover-bg: rgba(196, 20, 44, 0.1); --link-hover-bg: rgba(196, 20, 44, 0.1);
--header-bg: #ffffff; --header-bg: #ffffff;
--footer-bg: #fef3e0; --footer-bg: #fef3e0;
--shadow: 0 2px 4px rgba(0,0,0,0.05); --shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
/* Custom brand colors */ /* Custom brand colors */
--yellow: #FCBA09; --yellow: #fcba09;
--orange: #F79611; --orange: #f79611;
--mountain-bubbles: #FCBA0924; /* Yellow with transparency */ --mountain-bubbles: #fcba0924; /* Yellow with transparency */
--public-shell-max: 80rem;
--public-section-space: clamp(4.5rem, 7vw, 7rem);
--public-surface-radius: 2rem;
--public-inset-radius: 1.5rem;
--public-surface-shadow: 0 22px 56px rgba(15, 23, 42, 0.08);
--public-surface-shadow-hover: 0 28px 72px rgba(15, 23, 42, 0.12);
--header-height: 5.25rem;
/* Increased spacing variables */ /* Increased spacing variables */
--spacing-xs: 0.75rem; --spacing-xs: 0.75rem;
--spacing-sm: 1.25rem; --spacing-sm: 1.25rem;
} }
.dark { .dark {
--background: oklch(0.145 0.01 275.627); --background: oklch(0.145 0.01 275.627);
@ -101,8 +108,11 @@
} }
@theme inline { @theme inline {
--font-sans: var(--font-inter), "Inter", "Inter Fallback", system-ui, -apple-system, sans-serif; --font-sans:
--font-mono: var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", monospace; var(--font-inter), "Inter", "Inter Fallback", system-ui, -apple-system,
sans-serif;
--font-mono:
var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", monospace;
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
@ -147,12 +157,19 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: var(--font-inter), "Inter", system-ui, -apple-system, sans-serif; font-family:
var(--font-inter),
"Inter",
system-ui,
-apple-system,
sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
html { html {
scroll-behavior: smooth; /* Added smooth scroll behavior for Apple-like experience */ scroll-behavior: smooth; /* Added smooth scroll behavior for Apple-like experience */
scroll-padding-top: calc(var(--header-height) + 1.25rem);
} }
/* Global Link Styling - Master Style Guide */ /* Global Link Styling - Master Style Guide */
@ -160,13 +177,20 @@
a { a {
color: var(--link-color); color: var(--link-color);
text-decoration: none; text-decoration: none;
transition: color 0.2s ease, background-color 0.2s ease; text-decoration-color: transparent;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.18em;
transition:
color 0.2s ease,
text-decoration-color 0.2s ease,
opacity 0.2s ease,
transform 0.2s ease;
} }
a:hover, a:hover,
a:focus { a:focus {
color: var(--link-hover-color); color: var(--link-hover-color);
background-color: var(--link-hover-bg); background-color: transparent;
} }
a:active { a:active {
@ -176,13 +200,50 @@
/* Next.js Link components inherit the same styling */ /* Next.js Link components inherit the same styling */
a[href] { a[href] {
color: var(--link-color); color: var(--link-color);
transition: color 0.2s ease, background-color 0.2s ease; transition:
color 0.2s ease,
text-decoration-color 0.2s ease,
opacity 0.2s ease,
transform 0.2s ease;
} }
a[href]:hover, a[href]:hover,
a[href]:focus { a[href]:focus {
color: var(--link-hover-color); color: var(--link-hover-color);
background-color: var(--link-hover-bg); background-color: transparent;
}
button a,
[role="button"] a,
.bg-primary a,
.text-primary-foreground a {
color: inherit;
}
button a:hover,
button a:focus,
[role="button"] a:hover,
[role="button"] a:focus,
.bg-primary a:hover,
.bg-primary a:focus,
.text-primary-foreground a:hover,
.text-primary-foreground a:focus {
color: inherit;
}
p,
li {
text-wrap: pretty;
}
a:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
} }
/* Hide Next.js dev tools portal */ /* Hide Next.js dev tools portal */
@ -239,6 +300,26 @@
color: var(--link-hover-color); color: var(--link-hover-color);
} }
.public-page {
margin-inline: auto;
width: 100%;
max-width: var(--public-shell-max);
padding-inline: 1rem;
padding-block: 2.5rem 3.75rem;
}
@media (min-width: 640px) {
.public-page {
padding-inline: 1.25rem;
}
}
@media (min-width: 768px) {
.public-page {
padding-block: 3rem 4.5rem;
}
}
.wordpress-content h1, .wordpress-content h1,
.wordpress-content h2, .wordpress-content h2,
.wordpress-content h3, .wordpress-content h3,
@ -303,7 +384,12 @@
line-height: 1.7; line-height: 1.7;
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
letter-spacing: -0.02em; letter-spacing: -0.02em;
line-height: 1.2; line-height: 1.2;
color: var(--foreground); color: var(--foreground);
@ -317,12 +403,12 @@
/* Hide scrollbar for horizontal scrolling galleries */ /* Hide scrollbar for horizontal scrolling galleries */
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */ display: none; /* Chrome, Safari and Opera */
} }
/* Focus visible styling for keyboard navigation - Following Vercel Web Design Guidelines */ /* Focus visible styling for keyboard navigation - Following Vercel Web Design Guidelines */

View file

@ -1,77 +1,59 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation"
import { loadImageMapping } from '@/lib/wordpress-content'; import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } 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 { WhoWeServePage } from '@/components/who-we-serve-page'; import { WhoWeServePage } from "@/components/who-we-serve-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
const WORDPRESS_SLUG = 'vending-machine-for-your-gym'; const WORDPRESS_SLUG = "vending-machine-for-your-gym"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
return { return {
title: 'Page Not Found | Rocky Mountain Vending', title: "Page Not Found | Rocky Mountain Vending",
}; }
} }
return generateSEOMetadata({ return generateRegistryMetadata("gyms", {
title: page.title || 'Gym Vending Solutions',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}); })
} }
export default async function GymsPage() { export default async function GymsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
const content = page.content ? ( const content = page.content ? (
<div className="max-w-none"> <div className="max-w-none">
{cleanWordPressContent(String(page.content), { {cleanWordPressContent(String(page.content), {
imageMapping, imageMapping,
pageTitle: page.title pageTitle: page.title,
})} })}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No content available.</p> <p className="text-muted-foreground">No content available.</p>
); )
let structuredData; const structuredData = generateRegistryStructuredData("gyms", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || 'Gym Vending Solutions', })
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/gyms/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Gym Vending Solutions',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/gyms/`,
};
}
return ( return (
<> <>
@ -79,14 +61,17 @@ export default async function GymsPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<WhoWeServePage title={page.title || 'Gym Vending Solutions'} content={content} /> <WhoWeServePage
title={page.title || "Gym Vending Solutions"}
description={page.seoDescription || page.excerpt || undefined}
content={content}
/>
</> </>
); )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Gyms page:', error); console.error("Error rendering Gyms page:", error)
} }
notFound(); notFound()
} }
} }

View file

@ -3,8 +3,6 @@ import type { Metadata } from "next"
import { Inter, Geist_Mono } from "next/font/google" import { Inter, Geist_Mono } from "next/font/google"
import { Header } from "@/components/header" import { Header } from "@/components/header"
import { Footer } from "@/components/footer" import { Footer } from "@/components/footer"
import { StructuredData } from "@/components/structured-data"
import { OrganizationSchema } from "@/components/organization-schema"
import { SiteChatWidget } from "@/components/site-chat-widget" import { SiteChatWidget } from "@/components/site-chat-widget"
import { CartProvider } from "@/lib/cart/context" import { CartProvider } from "@/lib/cart/context"
import { businessConfig } from "@/lib/seo-config" import { businessConfig } from "@/lib/seo-config"
@ -23,21 +21,12 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(businessConfig.website), metadataBase: new URL(businessConfig.website),
title: "Free Vending Machines Utah | Rocky Mountain Vending | Salt Lake City, Ogden, Provo", title: {
description: default: businessConfig.name,
"Get a FREE vending machine for your Utah business! No cost installation. Serving Salt Lake City, Ogden, Provo, and surrounding areas since 2019. 100+ machines installed. Call (435) 233-9668.", template: "%s",
},
description: businessConfig.description,
generator: "Next.js", generator: "Next.js",
keywords: [
"vending machines",
"vending machine supplier",
"free vending machines",
"Utah vending",
"Salt Lake City vending",
"Ogden vending",
"Provo vending",
"vending machine repair",
"vending machine service",
],
authors: [{ name: businessConfig.name }], authors: [{ name: businessConfig.name }],
creator: businessConfig.name, creator: businessConfig.name,
publisher: businessConfig.name, publisher: businessConfig.name,
@ -71,11 +60,7 @@ export const metadata: Metadata = {
openGraph: { openGraph: {
type: "website", type: "website",
locale: "en_US", locale: "en_US",
url: businessConfig.website,
siteName: businessConfig.name, siteName: businessConfig.name,
title: "Free Vending Machines Utah | Rocky Mountain Vending",
description:
"Get a FREE vending machine for your Utah business! No cost installation. Serving Salt Lake City, Ogden, Provo, and surrounding areas since 2019. 100+ machines installed.",
images: [ images: [
{ {
url: `${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`, url: `${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`,
@ -87,15 +72,11 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Free Vending Machines Utah | Rocky Mountain Vending", images: [
description: `${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`,
"Get a FREE vending machine for your Utah business! No cost installation. Serving Salt Lake City, Ogden, Provo, and surrounding areas since 2019.", ],
images: [`${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`],
creator: "@RMVVending", creator: "@RMVVending",
}, },
alternates: {
canonical: businessConfig.website,
},
verification: { verification: {
// Google Search Console verification // Google Search Console verification
// To enable: Add your verification code from Google Search Console // To enable: Add your verification code from Google Search Console
@ -112,28 +93,24 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" className={`${inter.variable} ${geistMono.variable}`}> <html lang="en" className={`${inter.variable} ${geistMono.variable}`}>
<head>
<StructuredData />
<OrganizationSchema />
</head>
<body className="font-sans antialiased"> <body className="font-sans antialiased">
<CartProvider> <CartProvider>
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* Skip to main content link for keyboard users */} {/* Skip to main content link for keyboard users */}
<a <a
href="#main-content" href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:border-primary focus:rounded-md focus:text-primary focus:font-medium" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:border-primary focus:rounded-md focus:text-primary focus:font-medium"
> >
Skip to main content Skip to main content
</a> </a>
<Header /> <Header />
<main id="main-content" className="flex-1"> <main id="main-content" className="flex-1">
{children} {children}
</main> </main>
<Footer /> <Footer />
</div> </div>
<SiteChatWidget /> <SiteChatWidget />
</CartProvider> </CartProvider>
</body> </body>
</html> </html>
) )

View file

@ -45,6 +45,7 @@ manuals/
## Adding Manuals ## Adding Manuals
Simply add PDF files to the appropriate manufacturer directory. The system will: Simply add PDF files to the appropriate manufacturer directory. The system will:
- Automatically detect new files - Automatically detect new files
- Extract categories from filenames - Extract categories from filenames
- Make them searchable and filterable - Make them searchable and filterable
@ -56,6 +57,3 @@ Simply add PDF files to the appropriate manufacturer directory. The system will:
- Client-side filtering ensures fast interactions - Client-side filtering ensures fast interactions
- PDFs are cached with long-term headers - PDFs are cached with long-term headers
- Supports hundreds of manuals without performance degradation - Supports hundreds of manuals without performance degradation

View file

@ -1,16 +1,17 @@
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
import { Metadata } from 'next' import { Metadata } from "next"
import { ManualsDashboardClient } from '@/components/manuals-dashboard-client' import { ManualsDashboardClient } from "@/components/manuals-dashboard-client"
import { PublicInset, PublicPageHeader } from '@/components/public-surface' import { PublicInset, PublicPageHeader } from "@/components/public-surface"
import { getConvexManualDashboard } from '@/lib/convex-service' import { getConvexManualDashboard } from "@/lib/convex-service"
import { businessConfig } from '@/lib/seo-config' import { businessConfig } from "@/lib/seo-config"
import { readFileSync } from 'fs' import { readFileSync } from "fs"
import { join } from 'path' import { join } from "path"
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Manual Processing Dashboard | Rocky Mountain Vending', title: "Manual Processing Dashboard | Rocky Mountain Vending",
description: 'Comprehensive dashboard showing processed manual data, statistics, and analytics', description:
"Comprehensive dashboard showing processed manual data, statistics, and analytics",
alternates: { alternates: {
canonical: `${businessConfig.website}/manuals/dashboard`, canonical: `${businessConfig.website}/manuals/dashboard`,
}, },
@ -31,35 +32,35 @@ interface DashboardData {
} }
async function loadDashboardData(): Promise<DashboardData> { async function loadDashboardData(): Promise<DashboardData> {
const projectRoot = join(process.cwd(), '..') const projectRoot = join(process.cwd(), "..")
try { try {
const missingManuals = JSON.parse( const missingManuals = JSON.parse(
readFileSync(join(projectRoot, 'missing_manuals_report.json'), 'utf-8') readFileSync(join(projectRoot, "missing_manuals_report.json"), "utf-8")
) )
const qaData = JSON.parse( const qaData = JSON.parse(
readFileSync(join(projectRoot, 'manuals_qa_comprehensive.json'), 'utf-8') readFileSync(join(projectRoot, "manuals_qa_comprehensive.json"), "utf-8")
) )
const metadata = JSON.parse( const metadata = JSON.parse(
readFileSync(join(projectRoot, 'manuals_enhanced_metadata.json'), 'utf-8') readFileSync(join(projectRoot, "manuals_enhanced_metadata.json"), "utf-8")
) )
const structuredData = JSON.parse( const structuredData = JSON.parse(
readFileSync(join(projectRoot, 'manuals_structured_data.jsonld'), 'utf-8') readFileSync(join(projectRoot, "manuals_structured_data.jsonld"), "utf-8")
) )
const semanticIndex = JSON.parse( const semanticIndex = JSON.parse(
readFileSync(join(projectRoot, 'manuals_semantic_index.json'), 'utf-8') readFileSync(join(projectRoot, "manuals_semantic_index.json"), "utf-8")
) )
const acquisitionList = JSON.parse( const acquisitionList = JSON.parse(
readFileSync(join(projectRoot, 'manual_acquisition_list.json'), 'utf-8') readFileSync(join(projectRoot, "manual_acquisition_list.json"), "utf-8")
) )
const nameMapping = JSON.parse( const nameMapping = JSON.parse(
readFileSync(join(projectRoot, 'manual_name_mapping.json'), 'utf-8') readFileSync(join(projectRoot, "manual_name_mapping.json"), "utf-8")
) )
return { return {
@ -72,7 +73,7 @@ async function loadDashboardData(): Promise<DashboardData> {
nameMapping, nameMapping,
} }
} catch (error) { } catch (error) {
console.error('Error loading dashboard data:', error) console.error("Error loading dashboard data:", error)
return { return {
missingManuals: { summary: {} }, missingManuals: { summary: {} },
qaData: [], qaData: [],
@ -106,6 +107,3 @@ export default async function ManualsDashboardPage() {
</div> </div>
) )
} }

View file

@ -1,125 +1,122 @@
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
import { existsSync } from 'fs' import { createHash } from "node:crypto"
import { join } from 'path' import { existsSync } from "node:fs"
import { Metadata } from 'next' import { Metadata } from "next"
import { businessConfig } from '@/lib/seo-config' import { headers } from "next/headers"
import { ManualsPageExperience } from '@/components/manuals-page-experience' import { businessConfig } from "@/lib/seo-config"
import { listConvexManuals } from '@/lib/convex-service' import { ManualsPageExperience } from "@/components/manuals-page-experience"
import { scanManuals } from '@/lib/manuals' import { listConvexManuals } from "@/lib/convex-service"
import { selectManualsForSite } from '@/lib/manuals-site-selection' import { scanManuals } from "@/lib/manuals"
import { generateStructuredData } from '@/lib/seo' import { selectManualsForSite } from "@/lib/manuals-site-selection"
import { getManualsThumbnailsRoot } from '@/lib/manuals-paths' import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
export const metadata: Metadata = { export const metadata: Metadata = generateSEOMetadata({
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending', title: "Vending Machine Manuals | Rocky Mountain Vending",
description: description:
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.', "Browse vending machine manuals, service guides, and support documentation for snack, beverage, combo, coffee, and food machines.",
path: "/manuals",
keywords: [ keywords: [
'vending machine manuals', "vending machine manuals",
'vending machine PDF', "vending machine PDF",
'vending machine service manual', "vending machine service manual",
'vending machine parts catalog', "vending machine repair manual",
'vending machine repair manual', "vending machine troubleshooting guide",
'vending machine installation guide', "Royal Vendors manual",
'vending machine troubleshooting guide', "Dixie-Narco manual",
'Royal Vendors manual', "Vendo manual",
'Dixie-Narco manual', "Crane vending manual",
'Vendo manual', "Seaga vending manual",
'Crane vending manual',
'BevMax manual',
'Merchant Series manual',
'AP vending manual',
'GPL vending manual',
'Seaga vending manual',
'USI vending manual',
'snack machine manual',
'beverage machine manual',
'combo vending machine manual',
'coffee vending machine manual',
'food vending machine manual',
'frozen food vending manual',
'vending machine parts',
'vending machine replacement parts',
'vending machine wiring diagram',
'vending machine maintenance',
], ],
openGraph: { })
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending',
description:
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from leading manufacturers. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines.',
type: 'website',
url: `${businessConfig.website}/manuals`,
images: [
{
url: `${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`,
width: 926,
height: 1024,
alt: 'Rocky Mountain Vending Manuals',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Vending Machine Manuals | Download PDF Guides',
description:
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, and more. Find service manuals, parts catalogs, installation instructions, and troubleshooting guides.',
},
alternates: {
canonical: `${businessConfig.website}/manuals`,
},
}
export default async function ManualsPage() { export default async function ManualsPage() {
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated. const requestHeaders = await headers()
const convexManuals = await listConvexManuals() const requestHost =
const allManuals = convexManuals.length > 0 ? convexManuals : await scanManuals() requestHeaders.get("x-forwarded-host") || requestHeaders.get("host")
let manuals = convexManuals.length > 0 ? convexManuals : selectManualsForSite(allManuals).manuals const manualsDomain = resolveManualsTenantDomain({
requestHost,
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
})
// Hide broken local thumbnails so the public manuals page doesn't spam 404s. const convexManuals = manualsDomain
const thumbnailsRoot = getManualsThumbnailsRoot() ? await listConvexManuals(manualsDomain)
manuals = manuals.map((manual) => { : []
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
return manual
}
const relativeThumbnailPath = manual.thumbnailUrl.includes('/thumbnails/') const isLocalDevelopment = process.env.NODE_ENV === "development"
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, '') const shouldUseFilesystemFallback = isLocalDevelopment
: manual.thumbnailUrl
return existsSync(join(thumbnailsRoot, relativeThumbnailPath)) const allManuals =
? manual convexManuals.length > 0 || !shouldUseFilesystemFallback
: { ...manual, thumbnailUrl: undefined } ? convexManuals
: await scanManuals()
let manuals =
convexManuals.length > 0
? convexManuals
: shouldUseFilesystemFallback
? selectManualsForSite(allManuals, manualsDomain).manuals
: []
const shouldShowDegradedState =
!shouldUseFilesystemFallback && manuals.length === 0
if (shouldShowDegradedState) {
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || ""
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, {
isLocalDevelopment,
thumbnailsRoot: getManualsThumbnailsRoot(),
fileExists: existsSync,
}) })
// Generate structured data for SEO // Generate structured data for SEO
const structuredData = generateStructuredData({ const structuredData = generateStructuredData({
title: 'Vending Machine Manuals', title: "Vending Machine Manuals",
description: description:
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.', "Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.",
url: `${businessConfig.website}/manuals`, url: `${businessConfig.website}/manuals`,
type: 'WebPage', type: "WebPage",
}) })
// Add CollectionPage schema for better SEO // Add CollectionPage schema for better SEO
const collectionSchema = { const collectionSchema = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'CollectionPage', "@type": "CollectionPage",
name: 'Vending Machine Manuals', name: "Vending Machine Manuals",
description: description:
'A comprehensive collection of vending machine manuals, service guides, and parts documentation from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Includes service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. Many manuals feature available replacement parts with direct purchase links.', "A comprehensive collection of vending machine manuals, service guides, and parts documentation from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Includes service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. Many manuals feature available replacement parts with direct purchase links.",
url: `${businessConfig.website}/manuals`, url: `${businessConfig.website}/manuals`,
mainEntity: { mainEntity: {
'@type': 'ItemList', "@type": "ItemList",
numberOfItems: manuals.length, numberOfItems: manuals.length,
itemListElement: manuals.slice(0, 50).map((manual, index) => ({ itemListElement: manuals.slice(0, 50).map((manual, index) => ({
'@type': 'ListItem', "@type": "ListItem",
position: index + 1, position: index + 1,
item: { item: {
'@type': 'DigitalDocument', "@type": "DigitalDocument",
name: manual.filename.replace(/\.pdf$/i, ''), name: manual.filename.replace(/\.pdf$/i, ""),
description: `${manual.manufacturer} ${manual.category} Manual`, description: `${manual.manufacturer} ${manual.category} Manual`,
encodingFormat: 'application/pdf', encodingFormat: "application/pdf",
}, },
})), })),
}, },
@ -135,8 +132,22 @@ export default async function ManualsPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
/> />
<div className="container mx-auto px-4 py-8 md:py-12"> <div className="public-page" data-manuals-domain={manualsDomain}>
<ManualsPageExperience initialManuals={manuals} /> {shouldShowDegradedState ? (
<div className="mx-auto max-w-[var(--public-shell-max)] px-4 py-10 sm:px-5 lg:px-6">
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-6">
<h1 className="text-xl font-semibold text-foreground">
Manuals Library Temporarily Unavailable
</h1>
<p className="mt-2 text-sm text-muted-foreground">
We are restoring tenant catalog data for this domain. Please
refresh shortly or contact support if this persists.
</p>
</div>
</div>
) : (
<ManualsPageExperience initialManuals={manuals} />
)}
</div> </div>
</> </>
) )

View file

@ -1,4 +1,4 @@
import { OrderTracking } from '@/components/order-tracking' import { OrderTracking } from "@/components/order-tracking"
export default function OrdersPage() { export default function OrdersPage() {
return ( return (
@ -9,6 +9,6 @@ export default function OrdersPage() {
} }
export const metadata = { export const metadata = {
title: 'Track Your Order | Rocky Mountain Vending', title: "Track Your Order | Rocky Mountain Vending",
description: 'Track your order status and view delivery information', description: "Track your order status and view delivery information",
} }

View file

@ -1,3 +1,4 @@
import type { Metadata } from "next"
import { HeroSection } from "@/components/hero-section" import { HeroSection } from "@/components/hero-section"
import { StatsSection } from "@/components/stats-section" import { StatsSection } from "@/components/stats-section"
import { FeaturesSection } from "@/components/features-section" import { FeaturesSection } from "@/components/features-section"
@ -8,10 +9,25 @@ import { ServiceAreasSection } from "@/components/service-areas-section"
import { ReviewsSection } from "@/components/reviews-section" import { ReviewsSection } from "@/components/reviews-section"
import { RequestMachineSection } from "@/components/request-machine-section" import { RequestMachineSection } from "@/components/request-machine-section"
import { ContactSection } from "@/components/contact-section" import { ContactSection } from "@/components/contact-section"
import { StructuredData } from "@/components/structured-data"
import { OrganizationSchema } from "@/components/organization-schema"
import { generateSEOMetadata } from "@/lib/seo"
import { getSeoPageDefinition } from "@/lib/seo-registry"
const homeSeo = getSeoPageDefinition("home")
export const metadata: Metadata = generateSEOMetadata({
title: homeSeo.title,
description: homeSeo.description,
path: homeSeo.path,
keywords: [...homeSeo.keywords],
})
export default function Home() { export default function Home() {
return ( return (
<> <>
<StructuredData />
<OrganizationSchema />
<HeroSection /> <HeroSection />
<StatsSection /> <StatsSection />
<FeaturesSection /> <FeaturesSection />

View file

@ -1,25 +1,26 @@
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { PrivacyPolicyPage } from '@/components/privacy-policy-page'; import { PrivacyPolicyPage } from "@/components/privacy-policy-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({ return generateSEOMetadata({
title: 'Privacy Policy | Rocky Mountain Vending', title: "Privacy Policy | Rocky Mountain Vending",
description: 'Privacy Policy for Rocky Mountain Vending', description: "Privacy Policy for Rocky Mountain Vending",
path: "/privacy-policy",
robots: { robots: {
index: false, index: false,
follow: false, follow: false,
}, },
}); })
} }
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const structuredData = generateStructuredData({ const structuredData = generateStructuredData({
title: 'Privacy Policy', title: "Privacy Policy",
description: 'Privacy Policy for Rocky Mountain Vending', description: "Privacy Policy for Rocky Mountain Vending",
url: 'https://rockymountainvending.com/privacy-policy/', url: "https://rockymountainvending.com/privacy-policy/",
type: 'WebPage', type: "WebPage",
}); })
return ( return (
<> <>
@ -29,5 +30,5 @@ export default function PrivacyPolicy() {
/> />
<PrivacyPolicyPage /> <PrivacyPolicyPage />
</> </>
); )
} }

View file

@ -1,35 +1,37 @@
import { notFound } from 'next/navigation' import { notFound } from "next/navigation"
import Image from 'next/image' import Image from "next/image"
import { fetchProductById, fetchAllProducts } from '@/lib/stripe/products' import { fetchProductById, fetchAllProducts } from "@/lib/stripe/products"
import { AddToCartButton } from '@/components/add-to-cart-button' import { AddToCartButton } from "@/components/add-to-cart-button"
import { Card, CardContent } from '@/components/ui/card' import { PublicInset, PublicSurface } from "@/components/public-surface"
interface ProductPageProps { interface ProductPageProps {
params: Promise<{ id: string }> params: Promise<{ id: string }>
} }
// Required for static export // Required for static export
export const dynamic = 'force-static'; export const dynamic = "force-static"
export const dynamicParams = false; export const dynamicParams = false
// Generate static params for all products // Generate static params for all products
export async function generateStaticParams() { export async function generateStaticParams() {
try { try {
const products = await fetchAllProducts(); const products = await fetchAllProducts()
// Ensure we have products // Ensure we have products
if (!products || products.length === 0) { if (!products || products.length === 0) {
console.warn('No products found for static generation. Product pages will not be pre-rendered.'); console.warn(
return []; "No products found for static generation. Product pages will not be pre-rendered."
)
return []
} }
return products.map((product) => ({ return products.map((product) => ({
id: product.id, id: product.id,
})); }))
} catch (error) { } catch (error) {
console.error('Error generating static params for products:', error); console.error("Error generating static params for products:", error)
// Return empty array - product pages won't be pre-rendered but won't break the build // Return empty array - product pages won't be pre-rendered but won't break the build
return []; return []
} }
} }
@ -39,7 +41,7 @@ export async function generateMetadata({ params }: ProductPageProps) {
if (!product) { if (!product) {
return { return {
title: 'Product Not Found | Rocky Mountain Vending', title: "Product Not Found | Rocky Mountain Vending",
} }
} }
@ -57,69 +59,70 @@ export default async function ProductPage({ params }: ProductPageProps) {
notFound() notFound()
} }
const imageUrl = product.images?.[0] || '/placeholder.svg' const imageUrl = product.images?.[0] || "/placeholder.svg"
return ( return (
<div className="container mx-auto px-4 py-8 md:py-16"> <div className="public-page">
<div className="grid gap-8 md:grid-cols-2 lg:gap-12"> <div className="grid gap-6 lg:grid-cols-[1fr_0.95fr] lg:gap-8">
{/* Product Image */} <PublicSurface className="overflow-hidden p-0">
<Card className="overflow-hidden border-border/50 hover:border-secondary/50 transition-all"> <div className="relative aspect-square overflow-hidden bg-muted/60">
<CardContent className="p-0"> <Image
<div className="aspect-square relative overflow-hidden bg-muted"> src={imageUrl}
<Image alt={product.name}
src={imageUrl} fill
alt={product.name} className="object-cover"
fill sizes="(max-width: 1024px) 100vw, 52vw"
className="object-cover" priority
sizes="(max-width: 768px) 100vw, 50vw" />
priority </div>
/> </PublicSurface>
</div>
</CardContent>
</Card>
{/* Product Details */}
<div className="flex flex-col">
<h1 className="text-3xl md:text-4xl font-bold mb-4">{product.name}</h1>
<PublicSurface className="flex flex-col">
<div className="mb-6"> <div className="mb-6">
<p className="text-3xl font-bold text-[var(--link-hover-color)] mb-4"> <h1 className="text-3xl font-bold tracking-tight text-balance md:text-4xl">
{product.name}
</h1>
<p className="mt-4 text-3xl font-bold text-[var(--link-hover-color)]">
${product.price.toFixed(2)} {product.currency.toUpperCase()} ${product.price.toFixed(2)} {product.currency.toUpperCase()}
</p> </p>
</div> </div>
{product.description && ( {product.description && (
<div className="mb-8"> <PublicInset className="mb-5">
<h2 className="text-xl font-semibold mb-3">Description</h2> <h2 className="text-lg font-semibold">Description</h2>
<div <div
className="text-muted-foreground leading-relaxed whitespace-pre-line" className="mt-3 whitespace-pre-line text-muted-foreground leading-7"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: product.description.replace(/\n/g, '<br />'), __html: product.description.replace(/\n/g, "<br />"),
}} }}
/> />
</div> </PublicInset>
)} )}
{product.metadata && Object.keys(product.metadata).length > 0 && ( {product.metadata && Object.keys(product.metadata).length > 0 && (
<div className="mb-8"> <PublicInset className="mb-5">
<h2 className="text-xl font-semibold mb-3">Specifications</h2> <h2 className="text-lg font-semibold">Specifications</h2>
<dl className="space-y-2"> <dl className="mt-3 space-y-3">
{Object.entries(product.metadata).map(([key, value]) => ( {Object.entries(product.metadata).map(([key, value]) => (
<div key={key} className="flex"> <div
<dt className="font-medium mr-2">{key}:</dt> key={key}
className="flex flex-col gap-1 border-b border-border/50 pb-3 last:border-b-0 last:pb-0 sm:flex-row sm:gap-3"
>
<dt className="font-medium text-foreground sm:min-w-32">
{key}
</dt>
<dd className="text-muted-foreground">{value}</dd> <dd className="text-muted-foreground">{value}</dd>
</div> </div>
))} ))}
</dl> </dl>
</div> </PublicInset>
)} )}
<div className="mt-auto pt-6"> <div className="mt-auto pt-3">
<AddToCartButton product={product} /> <AddToCartButton product={product} />
</div> </div>
</div> </PublicSurface>
</div> </div>
</div> </div>
) )
} }

View file

@ -1,11 +1,15 @@
import { fetchAllProducts } from '@/lib/stripe/products' import { fetchAllProducts } from "@/lib/stripe/products"
import { PublicInset, PublicPageHeader, PublicSurface } from '@/components/public-surface' import {
import { ProductGrid } from '@/components/product-grid' PublicInset,
import Link from 'next/link' PublicPageHeader,
PublicSurface,
} from "@/components/public-surface"
import { ProductGrid } from "@/components/product-grid"
import Link from "next/link"
export const metadata = { export const metadata = {
title: 'Products | Rocky Mountain Vending', title: "Products | Rocky Mountain Vending",
description: 'Shop our selection of vending machines and equipment', description: "Shop our selection of vending machines and equipment",
} }
export default async function ProductsPage() { export default async function ProductsPage() {
@ -16,13 +20,14 @@ export default async function ProductsPage() {
products = await fetchAllProducts() products = await fetchAllProducts()
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
if (err.message.includes('STRIPE_SECRET_KEY')) { if (err.message.includes("STRIPE_SECRET_KEY")) {
error = 'Our product catalog is temporarily unavailable. Please contact us for current machine options.' error =
"Our product catalog is temporarily unavailable. Please contact us for current machine options."
} else { } else {
error = 'Failed to load products. Please try again later.' error = "Failed to load products. Please try again later."
} }
} else { } else {
error = 'Failed to load products. Please try again later.' error = "Failed to load products. Please try again later."
} }
} }
@ -58,12 +63,16 @@ export default async function ProductsPage() {
No products available yet No products available yet
</p> </p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Our product catalog is being prepared. Please check back soon or contact us directly for current offerings. Our product catalog is being prepared. Please check back soon or
contact us directly for current offerings.
</p> </p>
</div> </div>
<PublicInset className="mx-auto max-w-md text-sm text-muted-foreground"> <PublicInset className="mx-auto max-w-md text-sm text-muted-foreground">
<p className="font-medium mb-1">For Vending Machine Sales:</p> <p className="font-medium mb-1">For Vending Machine Sales:</p>
<p>Call us at (435) 233-9668 or visit our contact page for immediate assistance.</p> <p>
Call us at (435) 233-9668 or visit our contact page for immediate
assistance.
</p>
</PublicInset> </PublicInset>
</PublicSurface> </PublicSurface>
) : ( ) : (

View file

@ -1,21 +1,13 @@
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { ReviewsPage } from '@/components/reviews-page'; import { ReviewsPage } from "@/components/reviews-page"
import type { Metadata } from 'next'; import type { Metadata } from "next"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({ return generateRegistryMetadata("reviews")
title: 'Customer Reviews | Rocky Mountain Vending',
description: 'Read authentic customer reviews and testimonials about Rocky Mountain Vending\'s exceptional vending services in Utah. See why businesses trust us for free vending machines.',
});
} }
export default function Reviews() { export default function Reviews() {
const structuredData = generateStructuredData({ const structuredData = generateRegistryStructuredData("reviews")
title: 'Customer Reviews',
description: 'See what our customers are saying about Rocky Mountain Vending\'s exceptional service',
url: 'https://rockymountainvending.com/reviews/',
type: 'WebPage',
});
return ( return (
<> <>
@ -25,5 +17,5 @@ export default function Reviews() {
/> />
<ReviewsPage /> <ReviewsPage />
</> </>
); )
} }

View file

@ -1,39 +0,0 @@
import { MetadataRoute } from "next"
import { businessConfig } from "@/lib/seo-config"
// Required for static export
export const dynamic = 'force-static'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/api/",
"/admin/",
"/_next/",
"/test-page/",
"/manuals/dashboard/",
],
},
{
userAgent: "Googlebot",
allow: "/",
disallow: [
"/api/",
"/admin/",
"/_next/",
"/test-page/",
"/manuals/dashboard/",
],
},
],
sitemap: `${businessConfig.website}/sitemap.xml`,
host: businessConfig.website,
}
}

40
app/robots.txt/route.ts Normal file
View file

@ -0,0 +1,40 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import {
PRODUCTION_HOST,
PRODUCTION_WEBSITE,
isProductionHost,
normalizeHost,
} from "@/lib/seo-config"
export const dynamic = "force-dynamic"
const productionRobots = `User-agent: *
Allow: /
Disallow: /api/
Disallow: /admin/
Disallow: /_next/
Disallow: /test-page/
Disallow: /manuals/dashboard/
Sitemap: ${PRODUCTION_WEBSITE}/sitemap.xml
Host: ${PRODUCTION_HOST}
`
const previewRobots = `User-agent: *
Disallow: /
Sitemap: ${PRODUCTION_WEBSITE}/sitemap.xml
Host: ${PRODUCTION_HOST}
`
export function GET(request: NextRequest) {
const host = normalizeHost(request.headers.get("host"))
const body = isProductionHost(host) ? productionRobots : previewRobots
return new NextResponse(body, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
})
}

View file

@ -0,0 +1,16 @@
import type { Metadata } from "next"
import type { ReactNode } from "react"
export const metadata: Metadata = {
title: "Seaga HY900 Support | Rocky Mountain Vending",
description:
"Watch Seaga HY900 support videos and access the owner manual from Rocky Mountain Vending.",
}
export default function SeagaSupportLayout({
children,
}: {
children: ReactNode
}) {
return children
}

Some files were not shown because too many files have changed in this diff Show more