deploy: publish unpublished site updates

This commit is contained in:
DMleadgen 2026-04-06 13:42:56 -06:00
parent d496a58935
commit 96ad13d6a9
Signed by: matt
GPG key ID: C2720CF8CD701894
333 changed files with 16960 additions and 9789 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

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"
}

View file

@ -1,6 +1,5 @@
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"
@ -16,14 +15,10 @@ export async function generateMetadata(): Promise<Metadata> {
} }
} }
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,
path: "/about-us",
}) })
} }
@ -35,28 +30,10 @@ export default async function AboutUsPage() {
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 (
<> <>

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 answers = Array.from(answerMatches).map(m => {
let answer = m[1].trim(); const questions = Array.from(questionMatches).map((m) => m[1].trim())
answer = answer.replace(/\n\s*\n/g, '\n').replace(/>\s+</g, '><').trim(); const answers = Array.from(answerMatches).map((m) => {
return answer; let answer = m[1].trim()
}); answer = 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">
<FAQSchema <Breadcrumbs
faqs={faqs} className="mb-6"
pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`} items={[
{ label: "About", href: "/about" },
{ label: "FAQs", href: "/about/faqs" },
]}
/> />
<FAQSection faqs={faqs} /> <FAQSchema
</> faqs={faqs}
pageUrl={pageUrl}
/>
<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,16 +1,11 @@
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" import { businessConfig } from "@/lib/seo-config"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
...generateSEOMetadata({ ...generateRegistryMetadata("aboutLegacy"),
title: "About Rocky Mountain Vending | Utah Vending Company",
description:
"Learn about Rocky Mountain Vending, the Utah service-area business behind our vending placement, repair, sales, and support services.",
path: "/about",
}),
alternates: { alternates: {
canonical: `${businessConfig.website}/about-us`, canonical: `${businessConfig.website}/about-us`,
}, },
@ -22,13 +17,7 @@ export async function generateMetadata(): Promise<Metadata> {
} }
export default function About() { export default function About() {
const structuredData = generateStructuredData({ const structuredData = generateRegistryStructuredData("aboutUs")
title: "About Rocky Mountain Vending",
description:
"Learn about Rocky Mountain Vending, the Utah service-area business behind our vending placement, repair, sales, and support services.",
url: "https://rockymountainvending.com/about-us/",
type: "WebPage",
})
return ( return (
<> <>

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,19 @@ 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} {normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity}
</p> </p>
</div> </div>
</div> </div>
@ -51,58 +63,107 @@ 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"}> 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 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.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 +171,12 @@ 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> </div>
) : null} ) : null}
</CardContent> </CardContent>
@ -121,37 +186,56 @@ 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>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@ -160,11 +244,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 +268,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"
@ -107,35 +123,65 @@ 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={11}
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> {normalizePhoneFromIdentity(
call.participantIdentity
) || call.participantIdentity}
</div>
<div className="text-xs text-muted-foreground">
{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="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 +193,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 +205,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 +218,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 +229,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

@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation"
import { isAdminUiEnabled } from "@/lib/server/admin-auth"; import { isAdminUiEnabled } from "@/lib/server/admin-auth"
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
@ -7,8 +7,8 @@ export default async function AdminLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
if (!isAdminUiEnabled()) { if (!isAdminUiEnabled()) {
redirect("/"); redirect("/")
} }
return <>{children}</>; return <>{children}</>
} }

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,22 +1,28 @@
import Link from 'next/link' import Link from "next/link"
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import {
import { Badge } from '@/components/ui/badge' Card,
import { CardContent,
ShoppingCart, CardDescription,
Package, CardHeader,
Users, CardTitle,
TrendingUp, } from "@/components/ui/card"
DollarSign, import { Badge } from "@/components/ui/badge"
import {
ShoppingCart,
Package,
Users,
TrendingUp,
DollarSign,
Clock, Clock,
CheckCircle, CheckCircle,
Truck, Truck,
AlertTriangle, AlertTriangle,
Settings, Settings,
BarChart3, BarChart3,
Phone Phone,
} from 'lucide-react' } from "lucide-react"
import { fetchAllProducts } from '@/lib/stripe/products' import { fetchAllProducts } from "@/lib/stripe/products"
// Mock analytics data for demo // Mock analytics data for demo
const mockAnalytics = { const mockAnalytics = {
@ -27,7 +33,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 +47,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
@ -53,127 +59,127 @@ async function getOrdersCount() {
export default async function AdminDashboard() { export default async function AdminDashboard() {
const [productsCount, ordersCount] = await Promise.all([ const [productsCount, ordersCount] = await Promise.all([
getProductsCount(), getProductsCount(),
getOrdersCount() getOrdersCount(),
]) ])
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,7 +188,9 @@ 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 Overview of your store performance and management tools
</p> </p>
@ -220,7 +228,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 +274,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 +286,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 +302,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 +333,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 +361,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>
@ -371,7 +396,7 @@ export default async function AdminDashboard() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/admin/products"> <Link href="/admin/products">
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow"> <Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center"> <CardContent className="p-6 flex flex-col items-center text-center">
@ -383,7 +408,7 @@ export default async function AdminDashboard() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/orders"> <Link href="/orders">
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow"> <Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center"> <CardContent className="p-6 flex flex-col items-center text-center">
@ -395,7 +420,7 @@ export default async function AdminDashboard() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Card className="h-full hover:shadow-md transition-shadow"> <Card className="h-full hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center"> <CardContent className="p-6 flex flex-col items-center text-center">
<CheckCircle className="h-8 w-8 text-orange-600 mb-3" /> <CheckCircle className="h-8 w-8 text-orange-600 mb-3" />
@ -414,6 +439,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

@ -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

@ -18,7 +18,11 @@ import {
isSiteChatSuppressedRoute, isSiteChatSuppressedRoute,
} from "@/lib/site-chat/config" } from "@/lib/site-chat/config"
import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt" import { SITE_CHAT_SYSTEM_PROMPT } 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 { createSmsConsentPayload } from "@/lib/sms-compliance" import { createSmsConsentPayload } from "@/lib/sms-compliance"
type ChatRole = "user" | "assistant" type ChatRole = "user" | "assistant"
@ -81,7 +85,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 +96,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
@ -190,25 +219,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 +257,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 +274,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 +293,11 @@ 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 xaiApiKey = getOptionalEnv("XAI_API_KEY") const xaiApiKey = getOptionalEnv("XAI_API_KEY")
if (!xaiApiKey) { if (!xaiApiKey) {
@ -263,32 +308,36 @@ 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: `${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"}`,
], },
}), ...messages,
}) ],
}),
}
)
const completionData = await completionResponse.json().catch(() => ({})) const completionData = await completionResponse.json().catch(() => ({}))
@ -302,10 +351,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 +367,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 +393,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 +409,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 +422,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

@ -50,20 +50,26 @@ function getChallengeCode(request: NextRequest) {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const challengeCode = getChallengeCode(request) const challengeCode = getChallengeCode(request)
if (!challengeCode) { if (!challengeCode) {
return NextResponse.json({ error: "Missing challenge_code query parameter." }, { status: 400 }) return NextResponse.json(
{ error: "Missing challenge_code query parameter." },
{ status: 400 }
)
} }
const verificationToken = getEbayNotificationVerificationToken() const verificationToken = getEbayNotificationVerificationToken()
if (!verificationToken) { if (!verificationToken) {
return NextResponse.json( return NextResponse.json(
{ error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." }, { error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." },
{ status: 500 }, { status: 500 }
) )
} }
const endpoint = getEbayNotificationEndpoint(request.url) const endpoint = getEbayNotificationEndpoint(request.url)
if (!endpoint) { if (!endpoint) {
return NextResponse.json({ error: "EBAY_NOTIFICATION_ENDPOINT is not configured." }, { status: 500 }) return NextResponse.json(
{ error: "EBAY_NOTIFICATION_ENDPOINT is not configured." },
{ status: 500 }
)
} }
const challengeResponse = computeEbayChallengeResponse({ const challengeResponse = computeEbayChallengeResponse({
@ -78,7 +84,7 @@ export async function GET(request: NextRequest) {
headers: { headers: {
"Cache-Control": "no-store", "Cache-Control": "no-store",
}, },
}, }
) )
} }
@ -93,18 +99,27 @@ export async function POST(request: NextRequest) {
}) })
if (!verification.verified) { if (!verification.verified) {
if (verification.reason === "Notification verification credentials are not configured.") { if (
console.warn("[ebay/notifications] accepted notification without signature verification", { verification.reason ===
reason: 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 payload = parseNotificationBody(body)
const notification = payload?.notification const notification = payload?.notification
console.info("[ebay/notifications] accepted notification without verification", { console.info(
topic: payload?.metadata?.topic || "unknown", "[ebay/notifications] accepted notification without verification",
notificationId: notification?.notificationId || "unknown", {
publishAttemptCount: notification?.publishAttemptCount ?? null, topic: payload?.metadata?.topic || "unknown",
}) notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
}
)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} }
@ -133,9 +148,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
error: error instanceof Error ? error.message : "Failed to verify eBay notification.", error:
error instanceof Error
? error.message
: "Failed to verify eBay notification.",
}, },
{ status: 500 }, { status: 500 }
) )
} }
} }

View file

@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from "next/server"
/** /**
* eBay API Proxy Route * eBay API Proxy Route
@ -27,11 +27,15 @@ interface eBaySearchResult {
type MaybeArray<T> = T | T[] type MaybeArray<T> = T | T[]
const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
const searchResponseCache = new Map<string, { results: eBaySearchResult[]; timestamp: number }>() const searchResponseCache = new Map<
string,
{ results: eBaySearchResult[]; timestamp: number }
>()
const inFlightSearchResponses = new Map<string, Promise<eBaySearchResult[]>>() const inFlightSearchResponses = new Map<string, Promise<eBaySearchResult[]>>()
// Affiliate campaign ID for generating links // Affiliate campaign ID for generating links
const AFFILIATE_CAMPAIGN_ID = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || '' const AFFILIATE_CAMPAIGN_ID =
process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
// Generate eBay affiliate link // Generate eBay affiliate link
function generateAffiliateLink(viewItemUrl: string): string { function generateAffiliateLink(viewItemUrl: string): string {
@ -41,12 +45,12 @@ function generateAffiliateLink(viewItemUrl: string): string {
try { try {
const url = new URL(viewItemUrl) const url = new URL(viewItemUrl)
url.searchParams.set('mkcid', '1') url.searchParams.set("mkcid", "1")
url.searchParams.set('mkrid', '711-53200-19255-0') url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set('siteid', '0') url.searchParams.set("siteid", "0")
url.searchParams.set('campid', AFFILIATE_CAMPAIGN_ID) url.searchParams.set("campid", AFFILIATE_CAMPAIGN_ID)
url.searchParams.set('toolid', '10001') url.searchParams.set("toolid", "10001")
url.searchParams.set('mkevt', '1') url.searchParams.set("mkevt", "1")
return url.toString() return url.toString()
} catch { } catch {
return viewItemUrl return viewItemUrl
@ -65,25 +69,25 @@ function normalizeItem(item: any): eBaySearchResult {
const currentPrice = first(item.sellingStatus?.currentPrice) const currentPrice = first(item.sellingStatus?.currentPrice)
const shippingCost = first(item.shippingInfo?.shippingServiceCost) const shippingCost = first(item.shippingInfo?.shippingServiceCost)
const condition = first(item.condition) const condition = first(item.condition)
const viewItemUrl = item.viewItemURL || item.viewItemUrl || '' const viewItemUrl = item.viewItemURL || item.viewItemUrl || ""
return { return {
itemId: item.itemId || '', itemId: item.itemId || "",
title: item.title || 'Unknown Item', title: item.title || "Unknown Item",
price: `${currentPrice?.value || '0'} ${currentPrice?.currencyId || 'USD'}`, price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || 'USD', currency: currentPrice?.currencyId || "USD",
imageUrl: first(item.galleryURL) || undefined, imageUrl: first(item.galleryURL) || undefined,
viewItemUrl, viewItemUrl,
condition: condition?.conditionDisplayName || undefined, condition: condition?.conditionDisplayName || undefined,
shippingCost: shippingCost?.value shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || 'USD'}` ? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: undefined, : undefined,
affiliateLink: generateAffiliateLink(viewItemUrl), affiliateLink: generateAffiliateLink(viewItemUrl),
} }
} }
async function readEbayErrorMessage(response: Response) { async function readEbayErrorMessage(response: Response) {
const text = await response.text().catch(() => '') const text = await response.text().catch(() => "")
if (!text.trim()) { if (!text.trim()) {
return `eBay API error: ${response.status}` return `eBay API error: ${response.status}`
} }
@ -91,11 +95,17 @@ async function readEbayErrorMessage(response: Response) {
try { try {
const parsed = JSON.parse(text) as any const parsed = JSON.parse(text) as any
const messages = parsed?.errorMessage?.[0]?.error?.[0] const messages = parsed?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(messages?.message) ? messages.message[0] : messages?.message const message = Array.isArray(messages?.message)
? messages.message[0]
: messages?.message
if (typeof message === 'string' && message.trim()) { if (typeof message === "string" && message.trim()) {
const errorId = Array.isArray(messages?.errorId) ? messages.errorId[0] : messages?.errorId const errorId = Array.isArray(messages?.errorId)
return errorId ? `eBay API error ${errorId}: ${message}` : `eBay API error: ${message}` ? messages.errorId[0]
: messages?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
} }
} catch { } catch {
// Fall through to returning the raw text below. // Fall through to returning the raw text below.
@ -108,14 +118,14 @@ function buildCacheKey(
keywords: string, keywords: string,
categoryId: string | undefined, categoryId: string | undefined,
sortOrder: string, sortOrder: string,
maxResults: number, maxResults: number
): string { ): string {
return [ return [
keywords.trim().toLowerCase(), keywords.trim().toLowerCase(),
categoryId || '', categoryId || "",
sortOrder || 'BestMatch', sortOrder || "BestMatch",
String(maxResults), String(maxResults),
].join('|') ].join("|")
} }
function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null { function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null {
@ -143,22 +153,33 @@ function setCachedSearchResults(cacheKey: string, results: eBaySearchResult[]) {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const keywords = searchParams.get('keywords') const keywords = searchParams.get("keywords")
const categoryId = searchParams.get('categoryId') || undefined const categoryId = searchParams.get("categoryId") || undefined
const sortOrder = searchParams.get('sortOrder') || 'BestMatch' const sortOrder = searchParams.get("sortOrder") || "BestMatch"
const maxResults = parseInt(searchParams.get('maxResults') || '6', 10) const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
const cacheKey = buildCacheKey(keywords || '', categoryId, sortOrder, maxResults) const cacheKey = buildCacheKey(
keywords || "",
categoryId,
sortOrder,
maxResults
)
if (!keywords) { if (!keywords) {
return NextResponse.json({ error: 'Keywords parameter is required' }, { status: 400 }) return NextResponse.json(
{ error: "Keywords parameter is required" },
{ status: 400 }
)
} }
const appId = process.env.EBAY_APP_ID?.trim() const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) { if (!appId) {
console.error('EBAY_APP_ID not configured') console.error("EBAY_APP_ID not configured")
return NextResponse.json( return NextResponse.json(
{ error: 'eBay API not configured. Please set EBAY_APP_ID environment variable.' }, {
error:
"eBay API not configured. Please set EBAY_APP_ID environment variable.",
},
{ status: 503 } { status: 503 }
) )
} }
@ -175,35 +196,40 @@ export async function GET(request: NextRequest) {
return NextResponse.json(results) return NextResponse.json(results)
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch products from eBay' }, {
error:
error instanceof Error
? error.message
: "Failed to fetch products from eBay",
},
{ status: 500 } { status: 500 }
) )
} }
} }
// Build eBay Finding API URL // Build eBay Finding API URL
const baseUrl = 'https://svcs.ebay.com/services/search/FindingService/v1' const baseUrl = "https://svcs.ebay.com/services/search/FindingService/v1"
const url = new URL(baseUrl) const url = new URL(baseUrl)
url.searchParams.set('OPERATION-NAME', 'findItemsAdvanced') url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
url.searchParams.set('SERVICE-VERSION', '1.0.0') url.searchParams.set("SERVICE-VERSION", "1.0.0")
url.searchParams.set('SECURITY-APPNAME', appId) url.searchParams.set("SECURITY-APPNAME", appId)
url.searchParams.set('RESPONSE-DATA-FORMAT', 'JSON') url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
url.searchParams.set('REST-PAYLOAD', 'true') url.searchParams.set("REST-PAYLOAD", "true")
url.searchParams.set('keywords', keywords) url.searchParams.set("keywords", keywords)
url.searchParams.set('sortOrder', sortOrder) url.searchParams.set("sortOrder", sortOrder)
url.searchParams.set('paginationInput.entriesPerPage', maxResults.toString()) url.searchParams.set("paginationInput.entriesPerPage", maxResults.toString())
if (categoryId) { if (categoryId) {
url.searchParams.set('categoryId', categoryId) url.searchParams.set("categoryId", categoryId)
} }
try { try {
const request = (async () => { const request = (async () => {
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: 'GET', method: "GET",
headers: { headers: {
'Accept': 'application/json', Accept: "application/json",
}, },
}) })
@ -221,11 +247,17 @@ export async function GET(request: NextRequest) {
} }
const searchResult = findItemsAdvancedResponse.searchResult?.[0] const searchResult = findItemsAdvancedResponse.searchResult?.[0]
if (!searchResult || !searchResult.item || searchResult.item.length === 0) { if (
!searchResult ||
!searchResult.item ||
searchResult.item.length === 0
) {
return [] return []
} }
const items = Array.isArray(searchResult.item) ? searchResult.item : [searchResult.item] const items = Array.isArray(searchResult.item)
? searchResult.item
: [searchResult.item]
return items.map((item: any) => normalizeItem(item)) return items.map((item: any) => normalizeItem(item))
})() })()
@ -235,11 +267,15 @@ export async function GET(request: NextRequest) {
const results = await request const results = await request
setCachedSearchResults(cacheKey, results) setCachedSearchResults(cacheKey, results)
return NextResponse.json(results) return NextResponse.json(results)
} catch (error) { } catch (error) {
console.error('Error fetching from eBay API:', error) console.error("Error fetching from eBay API:", error)
return NextResponse.json( return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch products from eBay' }, {
error:
error instanceof Error
? error.message
: "Failed to fetch products from eBay",
},
{ status: 500 } { status: 500 }
) )
} finally { } finally {

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,35 @@
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,
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,
})
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,46 @@
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.upsertPhoneCallSession, { const result = await fetchMutation(
roomName: String(body.roomName || ""), api.voiceSessions.upsertPhoneCallSession,
participantIdentity: String(body.participantIdentity || ""), {
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined, roomName: String(body.roomName || ""),
pathname: body.pathname ? String(body.pathname) : undefined, participantIdentity: String(body.participantIdentity || ""),
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined, siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
source: "phone-agent", pathname: body.pathname ? String(body.pathname) : undefined,
metadata: body.metadata ? String(body.metadata) : undefined, pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined, source: "phone-agent",
recordingDisclosureAt: metadata: body.metadata ? String(body.metadata) : undefined,
typeof body.recordingDisclosureAt === "number" ? body.recordingDisclosureAt : undefined, startedAt:
recordingStatus: body.recordingStatus || "pending", 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,
}); })
} 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,15 +30,16 @@ 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)
) )
} }
// TODO: Implement category filtering based on metadata // TODO: Implement category filtering based on metadata
// if (category) { // if (category) {
// filteredProducts = filteredProducts.filter(product => // filteredProducts = filteredProducts.filter(product =>
// product.metadata?.category === category // product.metadata?.category === category
// ) // )
// } // }
@ -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,12 +2,12 @@ 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
* POST /api/request-indexing * POST /api/request-indexing
* *
* Body: { url: string } * Body: { url: string }
* NOTE: This route is disabled for static export. * NOTE: This route is disabled for static export.
*/ */
@ -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,12 +3,12 @@ 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
* POST /api/sitemap-submit * POST /api/sitemap-submit
* *
* Body: { sitemapUrl?: string, siteUrl?: string } * Body: { sitemapUrl?: string, siteUrl?: string }
* NOTE: This route is disabled for static export. * NOTE: This route is disabled for static export.
*/ */
@ -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)
@ -10,20 +10,20 @@ export async function POST(request: Request) {
try { try {
const stripe = getStripeClient() const stripe = getStripeClient()
// Test basic Stripe connectivity // Test basic Stripe connectivity
const account = await stripe.accounts.retrieve() const account = await stripe.accounts.retrieve()
// 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,67 +1,62 @@
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()
let event: Stripe.Event let event: Stripe.Event
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
default: default:
console.log(`Unhandled event type: ${event.type}`) console.log(`Unhandled event type: ${event.type}`)
} }

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,5 +1,6 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content" import { loadImageMapping } from "@/lib/wordpress-content"
import { buildAbsoluteUrl } from "@/lib/seo-registry"
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"
@ -59,10 +60,7 @@ export default async function AbandonedVendingMachinesPage() {
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: url: buildAbsoluteUrl("/blog/abandoned-vending-machines"),
page.link ||
page.urlPath ||
`https://rockymountainvending.com/abandoned-vending-machines/`,
datePublished: page.date, datePublished: page.date,
dateModified: page.modified || page.date, dateModified: page.modified || page.date,
type: "WebPage", type: "WebPage",
@ -73,7 +71,7 @@ export default async function AbandonedVendingMachinesPage() {
"@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"),
} }
} }

View file

@ -1,5 +1,6 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content" import { loadImageMapping } from "@/lib/wordpress-content"
import { buildAbsoluteUrl } from "@/lib/seo-registry"
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"
@ -28,7 +29,7 @@ export async function generateMetadata(): Promise<Metadata> {
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,
@ -68,11 +69,10 @@ export default async function BestVendingMachineSupplierPage() {
try { try {
structuredData = generateStructuredData({ structuredData = generateStructuredData({
title: DISPLAY_TITLE, title: DISPLAY_TITLE,
description: page.seoDescription || page.excerpt || "", description: DISPLAY_DESCRIPTION,
url: url: buildAbsoluteUrl(
page.link || "/blog/best-vending-machine-supplier-in-salt-lake-city-utah"
page.urlPath || ),
`https://rockymountainvending.com/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",
@ -82,8 +82,10 @@ export default async function BestVendingMachineSupplierPage() {
"@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"
),
} }
} }

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,5 +1,5 @@
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"
@ -15,14 +15,10 @@ export async function generateMetadata(): Promise<Metadata> {
} }
} }
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,
path: "/contact-us",
}) })
} }
@ -34,28 +30,10 @@ export default async function ContactUsPage() {
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 (
<> <>

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

@ -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

@ -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,37 +32,37 @@ 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 {
missingManuals, missingManuals,
qaData, qaData,
@ -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,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,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,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,24 +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:
"Browse Rocky Mountain Vending reviews and the live Google review feed to see what Utah businesses say about placement, restocking, repairs, and service.",
path: "/reviews",
})
} }
export default function Reviews() { export default function Reviews() {
const structuredData = generateStructuredData({ const structuredData = generateRegistryStructuredData("reviews")
title: "Customer Reviews",
description:
"Browse Rocky Mountain Vending reviews and the live Google review feed",
url: "https://rockymountainvending.com/reviews/",
type: "WebPage",
})
return ( return (
<> <>

View file

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

@ -1,112 +1,146 @@
'use client' "use client"
import { useState } from 'react' import { useState } from "react"
import Link from 'next/link' import Link from "next/link"
import { Card, CardContent } from '@/components/ui/card' import { Button } from "@/components/ui/button"
import { Button } from '@/components/ui/button' import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicSurface,
} from "@/components/public-surface"
const videos = [ const videos = [
{ {
id: 'dgE8nyaxdJI', id: "dgE8nyaxdJI",
title: 'Seaga HY900 Overview', title: "Seaga HY900 Overview",
category: 'all' category: "all",
}, },
{ {
id: 'HcVuro9drHo', id: "HcVuro9drHo",
title: 'Troubleshooting Vertical Drop', title: "Troubleshooting Vertical Drop",
category: 'troubleshooting-vertical-drop' category: "troubleshooting-vertical-drop",
}, },
{ {
id: '-FGJVfZSMAg', id: "-FGJVfZSMAg",
title: 'Loading Cans', title: "Loading Cans",
category: 'loading-cans' category: "loading-cans",
}, },
{ {
id: '-AzbNKq9nHg', id: "-AzbNKq9nHg",
title: 'Loading Bottles', title: "Loading Bottles",
category: 'loading-bottles' category: "loading-bottles",
}, },
{ {
id: 'LeKX2zJzFMY', id: "LeKX2zJzFMY",
title: 'Changing Can to Bottle', title: "Changing Can to Bottle",
category: 'changing-can-to-bottle' category: "changing-can-to-bottle",
} },
] ]
const filterCategories = [ const filterCategories = [
{ value: '', label: 'All' }, { value: "", label: "All" },
{ value: 'troubleshooting-vertical-drop', label: 'Troubleshooting Vertical Drop' }, {
{ value: 'loading-cans', label: 'Loading Cans' }, value: "troubleshooting-vertical-drop",
{ value: 'loading-bottles', label: 'Loading Bottles' }, label: "Troubleshooting Vertical Drop",
{ value: 'changing-can-to-bottle', label: 'Changing Can to Bottle' } },
{ value: "loading-cans", label: "Loading Cans" },
{ value: "loading-bottles", label: "Loading Bottles" },
{ value: "changing-can-to-bottle", label: "Changing Can to Bottle" },
] ]
export default function SeagaHY900SupportPage() { export default function SeagaHY900SupportPage() {
const [selectedCategory, setSelectedCategory] = useState<string>('') const [selectedCategory, setSelectedCategory] = useState<string>("")
const filteredVideos = videos.filter(video => const filteredVideos = videos.filter(
selectedCategory === '' || (video) =>
selectedCategory === 'all' || selectedCategory === "" ||
video.category === selectedCategory selectedCategory === "all" ||
video.category === selectedCategory
) )
return ( return (
<article className="container mx-auto px-4 py-8 md:py-12 max-w-6xl"> <div className="public-page">
<header className="mb-8 md:mb-12 text-center"> <Breadcrumbs
<h1 className="text-4xl md:text-5xl font-bold mb-4 tracking-tight text-balance"> className="mb-6"
Seaga HY 900 Support items={[
</h1> { label: "Blog", href: "/blog" },
</header> { label: "Seaga HY 900 Support", href: "/seaga-hy900-support" },
]}
<div className="mb-8 text-center"> />
<p className="text-lg mb-6">Please watch the videos to learn more about your HY 900</p>
<Button asChild>
<Link href="/manuals/Seaga/seaga-hy900-owners-manual.pdf">
Owner's Manual
</Link>
</Button>
</div>
<div className="mb-12"> <PublicPageHeader
<div className="aspect-video w-full max-w-4xl mx-auto mb-8"> align="center"
eyebrow="Support Guides"
title="Seaga HY 900 Support"
description="Watch the essential setup and troubleshooting videos for the Seaga HY 900 and jump straight to the owners manual when you need the reference file."
/>
<section className="mt-10">
<PublicSurface className="text-center">
<p className="mx-auto max-w-2xl text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
Please watch the videos to learn more about your HY 900
</p>
<div className="mt-6">
<Button asChild className="min-h-11 rounded-full px-6">
<Link href="/manuals/Seaga/seaga-hy900-owners-manual.pdf">
Owner&apos;s Manual
</Link>
</Button>
</div>
</PublicSurface>
</section>
<section className="mt-12">
<PublicSurface className="overflow-hidden p-4 md:p-5">
<div className="aspect-video w-full overflow-hidden rounded-[1.5rem] border border-border/60 bg-background">
<iframe <iframe
className="w-full h-full rounded-lg" className="h-full w-full"
src={`https://www.youtube.com/embed/${videos[0].id}`} src={`https://www.youtube.com/embed/${videos[0].id}`}
title={videos[0].title} title={videos[0].title}
frameBorder="0" frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen
/> />
</div> </div>
</div> </PublicSurface>
</section>
<section className="mb-8"> <section className="mt-12">
<h2 className="text-2xl font-semibold mb-6 text-center">Video Tutorials</h2> <div className="mx-auto mb-6 max-w-3xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
{/* Filter Buttons */} Video Tutorials
<div className="mb-8 flex flex-wrap justify-center gap-3"> </p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
Filter support videos by the task you&apos;re working on
</h2>
</div>
<PublicInset className="mb-6 flex flex-wrap justify-center gap-3 rounded-[1.5rem]">
{filterCategories.map((category) => ( {filterCategories.map((category) => (
<Button <Button
key={category.value} key={category.value}
onClick={() => setSelectedCategory(category.value)} onClick={() => setSelectedCategory(category.value)}
variant={selectedCategory === category.value ? 'default' : 'outline'} variant={
selectedCategory === category.value ? "default" : "outline"
}
size="sm" size="sm"
className="min-h-11 rounded-full"
> >
{category.label} {category.label}
</Button> </Button>
))} ))}
</div> </PublicInset>
{/* Video Gallery */} <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredVideos.map((video) => ( {filteredVideos.map((video) => (
<Card <PublicSurface
key={video.id} key={video.id}
className="overflow-hidden hover:shadow-lg transition-shadow" className="overflow-hidden p-4 transition-all hover:-translate-y-0.5 hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)]"
> >
<div className="aspect-video relative"> <div className="relative aspect-video overflow-hidden rounded-[1.5rem] border border-border/60 bg-background">
<iframe <iframe
className="w-full h-full" className="h-full w-full"
src={`https://www.youtube.com/embed/${video.id}`} src={`https://www.youtube.com/embed/${video.id}`}
title={video.title} title={video.title}
frameBorder="0" frameBorder="0"
@ -114,14 +148,15 @@ export default function SeagaHY900SupportPage() {
allowFullScreen allowFullScreen
/> />
</div> </div>
<CardContent className="p-4"> <div className="px-1 pb-1 pt-4">
<h3 className="font-semibold text-base">{video.title}</h3> <h3 className="text-lg font-semibold tracking-tight text-foreground">
</CardContent> {video.title}
</Card> </h3>
</div>
</PublicSurface>
))} ))}
</div> </div>
</section> </section>
</article> </div>
) )
} }

View file

@ -1,11 +1,15 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import Image from "next/image" import Image from "next/image"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { businessConfig } from "@/lib/seo-config" import { businessConfig } from "@/lib/seo-config"
import { Phone, CheckCircle2, Shield, Clock, MapPin } from "lucide-react" import { Phone, CheckCircle2, Shield, Clock, MapPin } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { PublicPageHeader, PublicSurface } from "@/components/public-surface" import {
PublicInset,
PublicPageHeader,
PublicSectionHeader,
PublicSurface,
} from "@/components/public-surface"
import { Breadcrumbs } from "@/components/breadcrumbs" import { Breadcrumbs } from "@/components/breadcrumbs"
import { generateSEOMetadata } from "@/lib/seo" import { generateSEOMetadata } from "@/lib/seo"
@ -66,13 +70,15 @@ export default function MovingServicesPage() {
{/* Image Gallery Section */} {/* Image Gallery Section */}
<section className="mb-12"> <section className="mb-12">
<h2 className="text-3xl font-bold mb-8 tracking-tight text-balance"> <PublicSurface className="p-5 md:p-7">
Professional Vending Machine Moving in Action <PublicSectionHeader
</h2> eyebrow="In the Field"
<div className="grid gap-8 md:grid-cols-3"> title="Professional vending machine moving in action"
{/* Image 1 */} description="These examples show the way we protect machines, packaging, and the path around them when a move needs to happen cleanly."
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors"> className="mb-6"
<CardContent className="p-0"> />
<div className="grid gap-4 md:grid-cols-3">
<PublicInset className="overflow-hidden p-0">
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]"> <div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
<Image <Image
src="/images/vending-machine-moving-service-1.png" src="/images/vending-machine-moving-service-1.png"
@ -91,12 +97,8 @@ export default function MovingServicesPage() {
sizes, ensuring your equipment arrives damage-free. sizes, ensuring your equipment arrives damage-free.
</p> </p>
</div> </div>
</CardContent> </PublicInset>
</Card> <PublicInset className="overflow-hidden p-0">
{/* Image 2 */}
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
<CardContent className="p-0">
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]"> <div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
<Image <Image
src="/images/vending-machine-moving-service-2.png" src="/images/vending-machine-moving-service-2.png"
@ -115,12 +117,8 @@ export default function MovingServicesPage() {
throughout the relocation process. throughout the relocation process.
</p> </p>
</div> </div>
</CardContent> </PublicInset>
</Card> <PublicInset className="overflow-hidden p-0">
{/* Image 3 */}
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
<CardContent className="p-0">
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]"> <div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
<Image <Image
src="/images/vending-machine-moving-service-3.webp" src="/images/vending-machine-moving-service-3.webp"
@ -139,22 +137,25 @@ export default function MovingServicesPage() {
heavy vending machine transport. heavy vending machine transport.
</p> </p>
</div> </div>
</CardContent> </PublicInset>
</Card> </div>
</div> </PublicSurface>
</section> </section>
{/* Specialized Moving Capabilities Section */} {/* Specialized Moving Capabilities Section */}
<section className="mb-12"> <section className="mb-12">
<h2 className="text-3xl font-bold mb-8 tracking-tight text-balance"> <PublicSurface className="p-5 md:p-7">
Our Specialized Moving Capabilities <PublicSectionHeader
</h2> eyebrow="Capabilities"
<div className="grid gap-6 md:grid-cols-2"> title="What we handle during a vending move"
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors"> description="The moving page should read like part of the same service family, so these capabilities use the same surface language and spacing as the other support pages."
<CardContent className="p-6"> className="mb-6"
<div className="flex items-start gap-4"> />
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center"> <div className="grid gap-4 md:grid-cols-2">
<CheckCircle2 className="w-6 h-6 text-primary" /> <PublicInset className="h-full p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<CheckCircle2 className="w-6 h-6 text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold mb-2"> <h3 className="text-xl font-semibold mb-2">
@ -166,16 +167,14 @@ export default function MovingServicesPage() {
navigate stairs up or down, straight or curved without navigate stairs up or down, straight or curved without
risking strain or tipping. risking strain or tipping.
</p> </p>
</div>
</div> </div>
</div> </PublicInset>
</CardContent>
</Card>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors"> <PublicInset className="h-full p-6">
<CardContent className="p-6"> <div className="flex items-start gap-4">
<div className="flex items-start gap-4"> <div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center"> <CheckCircle2 className="w-6 h-6 text-primary" />
<CheckCircle2 className="w-6 h-6 text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold mb-2"> <h3 className="text-xl font-semibold mb-2">
@ -187,16 +186,14 @@ export default function MovingServicesPage() {
use low-profile dollies, straps, and protective padding to use low-profile dollies, straps, and protective padding to
maneuver through confined areas. maneuver through confined areas.
</p> </p>
</div>
</div> </div>
</div> </PublicInset>
</CardContent>
</Card>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors"> <PublicInset className="h-full p-6">
<CardContent className="p-6"> <div className="flex items-start gap-4">
<div className="flex items-start gap-4"> <div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center"> <Shield className="w-6 h-6 text-primary" />
<Shield className="w-6 h-6 text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold mb-2"> <h3 className="text-xl font-semibold mb-2">
@ -208,16 +205,14 @@ export default function MovingServicesPage() {
gates on our enclosed trailers to prevent shifting during gates on our enclosed trailers to prevent shifting during
transit. transit.
</p> </p>
</div>
</div> </div>
</div> </PublicInset>
</CardContent>
</Card>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors"> <PublicInset className="h-full p-6">
<CardContent className="p-6"> <div className="flex items-start gap-4">
<div className="flex items-start gap-4"> <div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center"> <Shield className="w-6 h-6 text-primary" />
<Shield className="w-6 h-6 text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold mb-2"> <h3 className="text-xl font-semibold mb-2">
@ -228,16 +223,14 @@ export default function MovingServicesPage() {
safeguard floors, walls, doors, and elevators from scratches safeguard floors, walls, doors, and elevators from scratches
or dents. or dents.
</p> </p>
</div>
</div> </div>
</div> </PublicInset>
</CardContent>
</Card>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors md:col-span-2"> <PublicInset className="h-full p-6 md:col-span-2">
<CardContent className="p-6"> <div className="flex items-start gap-4">
<div className="flex items-start gap-4"> <div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center"> <CheckCircle2 className="w-6 h-6 text-primary" />
<CheckCircle2 className="w-6 h-6 text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold mb-2"> <h3 className="text-xl font-semibold mb-2">
@ -249,16 +242,14 @@ export default function MovingServicesPage() {
and reposition/level the machine at the new site. and reposition/level the machine at the new site.
Reconnection and testing available upon request. Reconnection and testing available upon request.
</p> </p>
</div>
</div> </div>
</div> </PublicInset>
</CardContent>
</Card>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors md:col-span-2"> <PublicInset className="h-full p-6 md:col-span-2">
<CardContent className="p-6"> <div className="flex items-start gap-4">
<div className="flex items-start gap-4"> <div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center"> <MapPin className="w-6 h-6 text-primary" />
<MapPin className="w-6 h-6 text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold mb-2"> <h3 className="text-xl font-semibold mb-2">
@ -269,11 +260,11 @@ export default function MovingServicesPage() {
Northern Utah (Ogden, Salt Lake City, Layton, Provo, Park Northern Utah (Ogden, Salt Lake City, Layton, Provo, Park
City, and surrounding areas). City, and surrounding areas).
</p> </p>
</div>
</div> </div>
</div> </PublicInset>
</CardContent> </div>
</Card> </PublicSurface>
</div>
</section> </section>
{/* Why Choose Us Section */} {/* Why Choose Us Section */}

View file

@ -1,101 +1,200 @@
import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import type { Metadata } from "next" import type { Metadata } from "next"
import Link from "next/link"
const WORDPRESS_SLUG = "vending-machine-repairs" import {
ArrowRight,
BookOpenText,
MapPin,
MoveRight,
Package,
Wrench,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicSectionHeader,
PublicSurface,
} from "@/components/public-surface"
import {
generateRegistryMetadata,
generateRegistryStructuredData,
} from "@/lib/seo"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG) return generateRegistryMetadata("services")
if (!page) {
return {
title: "Page Not Found | Rocky Mountain Vending",
}
}
return generateSEOMetadata({
title: page.title || "Vending Machine Services",
description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt,
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
path: "/services",
})
} }
export default async function ServicesPage() { const serviceCards = [
try { {
const page = getPageBySlug(WORDPRESS_SLUG) title: "Repairs & Maintenance",
body: "On-site repair help, troubleshooting, preventative service, and support for common machine issues across Utah.",
href: "/services/repairs",
cta: "View repairs",
icon: Wrench,
},
{
title: "Moving & Relocation",
body: "Professional machine moving for in-building changes, relocations, and more complex access situations.",
href: "/services/moving",
cta: "View moving",
icon: MoveRight,
},
{
title: "Parts & Support",
body: "Replacement parts, payment components, cooling parts, and support for the brands and systems we work with most.",
href: "/services/parts",
cta: "View parts",
icon: Package,
},
{
title: "Manuals & Documentation",
body: "Browse manuals, service guides, and reference documentation without forcing the page to belong to only one menu family.",
href: "/manuals",
cta: "Browse manuals",
icon: BookOpenText,
},
{
title: "Coverage by Location",
body: "See how service support connects to the cities and counties we cover across Utah.",
href: "/services/service-areas",
cta: "View service areas",
icon: MapPin,
},
]
if (!page) { export default function ServicesPage() {
notFound() const structuredData = generateRegistryStructuredData("services")
}
let imageMapping: any = {} return (
try { <>
imageMapping = loadImageMapping() <script
} catch (e) { type="application/ld+json"
imageMapping = {} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
} />
<div className="public-page">
const content = page.content ? ( <Breadcrumbs
<div className="max-w-none"> className="mb-6"
{cleanWordPressContent(String(page.content), { items={[{ label: "Services", href: "/services" }]}
imageMapping,
pageTitle: page.title,
})}
</div>
) : (
<p className="text-muted-foreground">No content available.</p>
)
let structuredData
try {
structuredData = generateStructuredData({
title: page.title || "Vending Machine Services",
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/services/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: "WebPage",
})
} catch (e) {
structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
headline: page.title || "Vending Machine Services",
description: page.seoDescription || "",
url: `https://rockymountainvending.com/services/`,
}
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
<header className="mb-8"> <PublicPageHeader
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-6"> align="center"
{page.title || "Vending Machine Services"} eyebrow="Service Family"
</h1> title="Vending machine services that stay connected from install to ongoing support."
</header> description="Use this page as the hub for repairs, moving, parts, manuals, and service coverage so the Services family reads like one system instead of a collection of separate pages."
{content} >
</article> <PublicInset className="mx-auto inline-flex w-fit rounded-full px-4 py-2 text-sm text-muted-foreground shadow-none">
</> Repairs, moving, parts, manuals, and Utah coverage in one place.
) </PublicInset>
} catch (error) { </PublicPageHeader>
if (process.env.NODE_ENV === "development") {
console.error("Error rendering Services page:", error) <section className="mt-10">
} <PublicSurface className="p-5 md:p-7">
notFound() <PublicSectionHeader
} eyebrow="Overview"
title="How the services family fits together"
description="Every page in this group should help a customer understand the next step quickly, whether they need a technician, a move, a part, a manual, or location-specific support."
className="mb-6"
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{serviceCards.map((service) => (
<PublicInset key={service.title} className="flex h-full flex-col">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<service.icon className="h-5 w-5" />
</div>
<h2 className="mt-4 text-xl font-semibold tracking-tight text-balance">
{service.title}
</h2>
<p className="mt-3 flex-1 text-sm leading-relaxed text-muted-foreground">
{service.body}
</p>
<Button
asChild
variant="outline"
className="mt-5 h-10 w-full rounded-full"
>
<Link href={service.href}>
{service.cta}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</PublicInset>
))}
</div>
</PublicSurface>
</section>
<section className="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<PublicSurface>
<PublicSectionHeader
eyebrow="Common Paths"
title="Where most people go next"
description="The goal of this family is to make it obvious which page to visit next instead of making people guess between support, resources, or coverage."
/>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{[
[
"Need on-site help?",
"Start with repairs if a machine is down, payment is failing, or refrigeration is not working correctly.",
],
[
"Need to move equipment?",
"Use the moving page for relocations, stair moves, access constraints, and pickup-to-placement planning.",
],
[
"Need documentation?",
"Manuals stay neutral so customers can reach them from services or equipment pages without confusion.",
],
[
"Need to confirm coverage?",
"Use the service-area pages to connect support needs back to your city and route coverage.",
],
].map(([title, body]) => (
<PublicInset key={title}>
<h3 className="text-lg font-semibold text-foreground">
{title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{body}
</p>
</PublicInset>
))}
</div>
</PublicSurface>
<PublicSurface className="flex flex-col justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Get Help
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
Need a technician, a quote, or a quick answer?
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
Reach out if you need help narrowing down the right service page
or if you already know you need repairs, moving support, parts,
or placement guidance.
</p>
</div>
<div className="mt-6 grid gap-3">
<Button asChild size="lg" className="min-h-11 rounded-full">
<Link href="/contact-us#contact-form">Talk to Our Team</Link>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="min-h-11 rounded-full"
>
<Link href="/services/repairs#request-service">
Request repair help
</Link>
</Button>
</div>
</PublicSurface>
</section>
</div>
</>
)
} }

View file

@ -1,7 +1,7 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { loadImageMapping, cleanHtmlEntities } from "@/lib/wordpress-content" import { loadImageMapping, cleanHtmlEntities } 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@ -18,8 +18,6 @@ import {
Package, Package,
Wrench, Wrench,
Phone, Phone,
Clock,
Shield,
ShoppingCart, ShoppingCart,
ArrowRight, ArrowRight,
MapPin, MapPin,
@ -32,6 +30,11 @@ import {
} from "lucide-react" } from "lucide-react"
import { FAQSection } from "@/components/faq-section" import { FAQSection } from "@/components/faq-section"
import { Breadcrumbs } from "@/components/breadcrumbs" import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicPageHeader,
PublicSectionHeader,
PublicSurface,
} from "@/components/public-surface"
import type { Metadata } from "next" import type { Metadata } from "next"
const WORDPRESS_SLUG = "parts-and-support" const WORDPRESS_SLUG = "parts-and-support"
@ -47,14 +50,10 @@ export async function generateMetadata(): Promise<Metadata> {
} }
} }
return generateSEOMetadata({ return generateRegistryMetadata("parts", {
title: page.title || "Vending Machine Parts & Support",
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,
path: "/services/parts",
}) })
} }
@ -337,28 +336,10 @@ export default async function PartsPage() {
</div> </div>
) : null ) : null
let structuredData const structuredData = generateRegistryStructuredData("parts", {
try { datePublished: page.date,
structuredData = generateStructuredData({ dateModified: page.modified || page.date,
title: page.title || "Vending Machine Parts & Support", })
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/services/parts/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: "WebPage",
})
} catch (e) {
structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
headline: page.title || "Vending Machine Parts & Support",
description: page.seoDescription || "",
url: `https://rockymountainvending.com/services/parts/`,
}
}
const cleanExcerpt = page.excerpt const cleanExcerpt = page.excerpt
? cleanHtmlEntities(page.excerpt) ? cleanHtmlEntities(page.excerpt)
@ -373,6 +354,11 @@ export default async function PartsPage() {
.trim() .trim()
: "" : ""
const surfaceCardClass =
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] shadow-[var(--public-surface-shadow)]"
const insetCardClass =
"rounded-[var(--public-inset-radius)] border border-border/60 bg-white/95 shadow-[0_10px_28px_rgba(15,23,42,0.06)]"
return ( return (
<> <>
<script <script
@ -389,16 +375,15 @@ export default async function PartsPage() {
{ label: "Parts", href: "/services/parts" }, { label: "Parts", href: "/services/parts" },
]} ]}
/> />
<header className="text-center mb-6"> <PublicPageHeader
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4"> align="center"
{page.title || "Vending Machine Parts & Support"} eyebrow="Parts & Support"
</h1> title={page.title || "Vending Machine Parts & Support"}
{cleanExcerpt && ( description={
<p className="text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto text-pretty leading-relaxed"> cleanExcerpt ||
{cleanExcerpt} "Replacement parts, documentation, and support for the brands and payment systems Rocky Mountain Vending works with most."
</p> }
)} />
</header>
</div> </div>
</section> </section>
@ -406,11 +391,11 @@ export default async function PartsPage() {
{content && ( {content && (
<section className="py-12 md:py-16 bg-muted/30"> <section className="py-12 md:py-16 bg-muted/30">
<div className="container mx-auto px-4 max-w-4xl"> <div className="container mx-auto px-4 max-w-4xl">
<article> <PublicSurface as="article">
<div className="prose prose-lg max-w-none text-pretty leading-relaxed prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-foreground prose-a:hover:text-secondary prose-a:transition-colors prose-headings:font-bold prose-headings:tracking-tight prose-img:rounded-lg prose-img:shadow-md"> <div className="prose prose-lg max-w-none text-pretty leading-relaxed prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-foreground prose-a:hover:text-secondary prose-a:transition-colors prose-headings:font-bold prose-headings:tracking-tight prose-img:rounded-lg prose-img:shadow-md">
{content} {content}
</div> </div>
</article> </PublicSurface>
</div> </div>
</section> </section>
)} )}
@ -420,18 +405,15 @@ export default async function PartsPage() {
{/* Services Section */} {/* Services Section */}
<section className="py-12 md:py-16"> <section className="py-12 md:py-16">
<div className="container mx-auto px-4 max-w-6xl"> <div className="container mx-auto px-4 max-w-6xl">
<div className="text-center mb-8 md:mb-12"> <PublicSectionHeader
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance"> eyebrow="Service Scope"
Parts & Support Services title="Parts & support services"
</h2> description="Comprehensive parts and technical support to keep your vending machines operational."
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed"> className="mx-auto mb-8 max-w-3xl text-center md:mb-12"
Comprehensive parts and technical support to keep your vending />
machines operational
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="p-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-6 w-6 text-primary" /> <Package className="h-6 w-6 text-primary" />
</div> </div>
@ -444,8 +426,8 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="p-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<ShoppingCart className="h-6 w-6 text-primary" /> <ShoppingCart className="h-6 w-6 text-primary" />
</div> </div>
@ -458,8 +440,8 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="p-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Wrench className="h-6 w-6 text-primary" /> <Wrench className="h-6 w-6 text-primary" />
</div> </div>
@ -473,8 +455,8 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="p-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<CreditCard className="h-6 w-6 text-primary" /> <CreditCard className="h-6 w-6 text-primary" />
</div> </div>
@ -488,8 +470,8 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="p-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Wrench className="h-6 w-6 text-primary" /> <Wrench className="h-6 w-6 text-primary" />
</div> </div>
@ -503,8 +485,8 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="p-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-6 w-6 text-primary" /> <Package className="h-6 w-6 text-primary" />
</div> </div>
@ -524,22 +506,19 @@ export default async function PartsPage() {
{/* Available Parts Section */} {/* Available Parts Section */}
<section className="py-12 md:py-16 bg-muted/30"> <section className="py-12 md:py-16 bg-muted/30">
<div className="container mx-auto px-4 max-w-6xl"> <div className="container mx-auto px-4 max-w-6xl">
<div className="text-center mb-8 md:mb-12"> <PublicSectionHeader
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance"> eyebrow="Inventory"
Available Parts Include title="Available parts include"
</h2> description="We stock a comprehensive inventory of vending machine parts to keep your equipment running smoothly."
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed"> className="mx-auto mb-8 max-w-3xl text-center md:mb-12"
We stock a comprehensive inventory of vending machine parts to />
keep your equipment running smoothly <Card className={surfaceCardClass}>
</p>
</div>
<Card className="border-border/50 shadow-lg">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl md:text-3xl"> <CardTitle className="text-2xl md:text-3xl">
Parts Inventory Parts Inventory
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="px-6 pb-6 pt-2">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<ul className="space-y-3"> <ul className="space-y-3">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
@ -643,7 +622,7 @@ export default async function PartsPage() {
<Accordion type="single" collapsible className="w-full space-y-4"> <Accordion type="single" collapsible className="w-full space-y-4">
<AccordionItem <AccordionItem
value="vending-brands" value="vending-brands"
className="border border-border/50 rounded-lg px-6 py-2 bg-card shadow-sm hover:shadow-md hover:border-secondary/50 transition-all" className={`${insetCardClass} rounded-[1.5rem] px-6 py-2 transition-all hover:border-primary/30`}
> >
<AccordionTrigger className="text-xl font-semibold hover:no-underline py-4"> <AccordionTrigger className="text-xl font-semibold hover:no-underline py-4">
Vending Machine Brands Vending Machine Brands
@ -730,7 +709,7 @@ export default async function PartsPage() {
<AccordionItem <AccordionItem
value="card-reader-brands" value="card-reader-brands"
className="border border-border/50 rounded-lg px-6 py-2 bg-card shadow-sm hover:shadow-md hover:border-secondary/50 transition-all" className={`${insetCardClass} rounded-[1.5rem] px-6 py-2 transition-all hover:border-primary/30`}
> >
<AccordionTrigger className="text-xl font-semibold hover:no-underline py-4"> <AccordionTrigger className="text-xl font-semibold hover:no-underline py-4">
Compatible Card Reader Brands Compatible Card Reader Brands
@ -763,7 +742,7 @@ export default async function PartsPage() {
<AccordionItem <AccordionItem
value="payment-brands" value="payment-brands"
className="border border-border/50 rounded-lg px-6 py-2 bg-card shadow-sm hover:shadow-md hover:border-secondary/50 transition-all" className={`${insetCardClass} rounded-[1.5rem] px-6 py-2 transition-all hover:border-primary/30`}
> >
<AccordionTrigger className="text-xl font-semibold hover:no-underline py-4"> <AccordionTrigger className="text-xl font-semibold hover:no-underline py-4">
Compatible Bill Validator and Coin Mechanism Brands Compatible Bill Validator and Coin Mechanism Brands
@ -819,7 +798,7 @@ export default async function PartsPage() {
</p> </p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-8"> <CardContent className="pt-8">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary/10 text-primary text-2xl font-bold"> <div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary/10 text-primary text-2xl font-bold">
@ -836,7 +815,7 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-8"> <CardContent className="pt-8">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary/10 text-primary"> <div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary/10 text-primary">
@ -853,7 +832,7 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-8"> <CardContent className="pt-8">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary/10 text-primary"> <div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary/10 text-primary">
@ -870,7 +849,7 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-8"> <CardContent className="pt-8">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary/10 text-primary"> <div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary/10 text-primary">
@ -903,7 +882,7 @@ export default async function PartsPage() {
</p> </p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<MapPin className="h-6 w-6 text-primary" /> <MapPin className="h-6 w-6 text-primary" />
@ -918,7 +897,7 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Truck className="h-6 w-6 text-primary" /> <Truck className="h-6 w-6 text-primary" />
@ -931,7 +910,7 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Award className="h-6 w-6 text-primary" /> <Award className="h-6 w-6 text-primary" />
@ -946,7 +925,7 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Users className="h-6 w-6 text-primary" /> <Users className="h-6 w-6 text-primary" />
@ -959,7 +938,7 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<DollarSign className="h-6 w-6 text-primary" /> <DollarSign className="h-6 w-6 text-primary" />
@ -974,7 +953,7 @@ export default async function PartsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-6 w-6 text-primary" /> <Search className="h-6 w-6 text-primary" />
@ -998,7 +977,7 @@ export default async function PartsPage() {
{/* Call to Action */} {/* Call to Action */}
<section className="py-12 md:py-16 bg-background"> <section className="py-12 md:py-16 bg-background">
<div className="container mx-auto px-4 max-w-4xl"> <div className="container mx-auto px-4 max-w-4xl">
<Card className="border-2 border-primary/20 shadow-lg"> <Card className={`${surfaceCardClass} border-primary/20`}>
<CardContent className="p-6 md:p-8 text-center"> <CardContent className="p-6 md:p-8 text-center">
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10"> <div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Phone className="h-8 w-8 text-primary" /> <Phone className="h-8 w-8 text-primary" />

View file

@ -6,7 +6,6 @@ import {
} from "@/lib/seo" } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader" import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content" import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { ServicesSection } from "@/components/services-section"
import { FAQSection } from "@/components/faq-section" import { FAQSection } from "@/components/faq-section"
import { ServiceAreasSection } from "@/components/service-areas-section" import { ServiceAreasSection } from "@/components/service-areas-section"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@ -14,8 +13,6 @@ import {
CheckCircle2, CheckCircle2,
Wrench, Wrench,
Clock, Clock,
Phone,
Shield,
MapPin, MapPin,
} from "lucide-react" } from "lucide-react"
import { RepairsImageCarousel } from "@/components/repairs-image-carousel" import { RepairsImageCarousel } from "@/components/repairs-image-carousel"
@ -24,6 +21,7 @@ import { Breadcrumbs } from "@/components/breadcrumbs"
import { import {
PublicInset, PublicInset,
PublicPageHeader, PublicPageHeader,
PublicSectionHeader,
PublicSurface, PublicSurface,
} from "@/components/public-surface" } from "@/components/public-surface"
import Image from "next/image" import Image from "next/image"
@ -148,6 +146,11 @@ export default async function RepairsPage() {
dateModified: page.modified || page.date, dateModified: page.modified || page.date,
}) })
const surfaceCardClass =
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] shadow-[var(--public-surface-shadow)]"
const insetCardClass =
"rounded-[var(--public-inset-radius)] border border-border/60 bg-white/95 shadow-[0_10px_28px_rgba(15,23,42,0.06)]"
return ( return (
<> <>
<script <script
@ -167,6 +170,7 @@ export default async function RepairsPage() {
<PublicPageHeader <PublicPageHeader
align="center" align="center"
className="mb-8" className="mb-8"
eyebrow="Repair Services"
title={page.title || "Vending Machine Repairs and Service"} title={page.title || "Vending Machine Repairs and Service"}
description={ description={
"Rocky Mountain Vending delivers expert vending machine repair and maintenance services to keep your business thriving." "Rocky Mountain Vending delivers expert vending machine repair and maintenance services to keep your business thriving."
@ -207,20 +211,28 @@ export default async function RepairsPage() {
</div> </div>
</section> </section>
{contentWithoutVirtualServices ? (
<section className="py-16 md:py-20 bg-background">
<div className="container mx-auto px-4 max-w-4xl">
<PublicSurface>
<div className="max-w-none">{content}</div>
</PublicSurface>
</div>
</section>
) : null}
{/* Services Section */} {/* Services Section */}
<section className="py-20 md:py-28 bg-muted/30"> <section className="py-20 md:py-28 bg-muted/30">
<div className="container mx-auto px-4 max-w-6xl"> <div className="container mx-auto px-4 max-w-6xl">
<div className="text-center mb-12 md:mb-16"> <PublicSectionHeader
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance"> eyebrow="Service Scope"
Services title="Repair and maintenance support"
</h2> description="Our repair and maintenance services cover the most common machine, payment, and refrigeration issues businesses call us about."
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed mb-8"> className="mx-auto mb-12 max-w-3xl text-center md:mb-16"
<strong>Our Repair and Maintenance Services Include:</strong> />
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 items-start"> <div className="grid gap-8 md:grid-cols-2 items-start">
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardContent className="pt-6"> <CardContent className="p-6">
<ul className="space-y-4"> <ul className="space-y-4">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" /> <CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
@ -273,12 +285,12 @@ export default async function RepairsPage() {
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
<div className="relative aspect-[4/3] max-w-md mx-auto md:mx-0 rounded-lg overflow-hidden shadow-lg"> <div className="relative aspect-[4/3] max-w-md mx-auto overflow-hidden rounded-[1.75rem] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(247,244,236,0.94))] p-3 shadow-[0_18px_40px_rgba(15,23,42,0.08)] md:mx-0">
<Image <Image
src="https://rockymountainvending.com/wp-content/uploads/elementor/thumbs/Vending-Machine-repairs-e1737751914630-r0hul4xrzal3af9zw0ss3hzq6enyjd5ldxqqylpgnc.webp" src="https://rockymountainvending.com/wp-content/uploads/elementor/thumbs/Vending-Machine-repairs-e1737751914630-r0hul4xrzal3af9zw0ss3hzq6enyjd5ldxqqylpgnc.webp"
alt="Vending machine being repaired" alt="Vending machine being repaired"
fill fill
className="object-cover" className="rounded-[1rem] object-cover"
sizes="(max-width: 768px) 100vw, 400px" sizes="(max-width: 768px) 100vw, 400px"
/> />
</div> </div>
@ -289,23 +301,24 @@ export default async function RepairsPage() {
{/* Why Choose Section */} {/* Why Choose Section */}
<section className="py-20 md:py-28"> <section className="py-20 md:py-28">
<div className="container mx-auto px-4 max-w-6xl"> <div className="container mx-auto px-4 max-w-6xl">
<div className="text-center mb-12 md:mb-16"> <PublicSectionHeader
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance"> eyebrow="Why Rocky"
Why Choose Rocky Mountain Vending? title="Why businesses call Rocky Mountain Vending for repairs"
</h2> description="This page should feel connected to the rest of the Services family while still making the repair value proposition obvious."
</div> className="mx-auto mb-12 max-w-3xl text-center md:mb-16"
/>
<div className="grid gap-8 md:grid-cols-2 items-start"> <div className="grid gap-8 md:grid-cols-2 items-start">
<div className="relative aspect-video rounded-lg overflow-hidden shadow-lg"> <div className="relative aspect-video overflow-hidden rounded-[1.75rem] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(247,244,236,0.94))] p-3 shadow-[0_18px_40px_rgba(15,23,42,0.08)]">
<Image <Image
src="https://rockymountainvending.com/wp-content/uploads/elementor/thumbs/Coin-Mech-with-Low-Battery-e1737751934594-r0hulnqjrzatqmiou8xbhd8y243atb884isgk4xl6w.webp" src="https://rockymountainvending.com/wp-content/uploads/elementor/thumbs/Coin-Mech-with-Low-Battery-e1737751934594-r0hulnqjrzatqmiou8xbhd8y243atb884isgk4xl6w.webp"
alt="Coin mech with low battery" alt="Coin mech with low battery"
fill fill
className="object-cover" className="rounded-[1rem] object-cover"
sizes="(max-width: 768px) 100vw, 50vw" sizes="(max-width: 768px) 100vw, 50vw"
/> />
</div> </div>
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardContent className="pt-6"> <CardContent className="p-6">
<ul className="space-y-4"> <ul className="space-y-4">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" /> <CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
@ -358,18 +371,15 @@ export default async function RepairsPage() {
{/* Related Services Section */} {/* Related Services Section */}
<section className="py-20 md:py-28 bg-background"> <section className="py-20 md:py-28 bg-background">
<div className="container mx-auto px-4 max-w-6xl"> <div className="container mx-auto px-4 max-w-6xl">
<div className="text-center mb-12 md:mb-16"> <PublicSectionHeader
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance"> eyebrow="Connected Services"
Our Complete Service Network title="Our complete service network"
</h2> description="Repairs should still point cleanly to the rest of the family, including moving, parts, and city-based support."
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed mb-8"> className="mx-auto mb-12 max-w-3xl text-center md:mb-16"
Rocky Mountain Vending provides comprehensive vending solutions />
across Utah
</p>
</div>
<div className="grid gap-8 md:grid-cols-3"> <div className="grid gap-8 md:grid-cols-3">
<Card className="border-border/50 hover:border-secondary/50 transition-colors"> <Card className={`${insetCardClass} hover:border-primary/30`}>
<CardContent className="pt-8 text-center"> <CardContent className="p-8 text-center">
<Wrench className="h-12 w-12 text-primary mx-auto mb-4" /> <Wrench className="h-12 w-12 text-primary mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-3"> <h3 className="text-xl font-semibold mb-3">
Vending Machine Parts Vending Machine Parts
@ -386,8 +396,8 @@ export default async function RepairsPage() {
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors"> <Card className={`${insetCardClass} hover:border-primary/30`}>
<CardContent className="pt-8 text-center"> <CardContent className="p-8 text-center">
<MapPin className="h-12 w-12 text-primary mx-auto mb-4" /> <MapPin className="h-12 w-12 text-primary mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-3">Service Areas</h3> <h3 className="text-xl font-semibold mb-3">Service Areas</h3>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
@ -402,8 +412,8 @@ export default async function RepairsPage() {
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors"> <Card className={`${insetCardClass} hover:border-primary/30`}>
<CardContent className="pt-8 text-center"> <CardContent className="p-8 text-center">
<Clock className="h-12 w-12 text-primary mx-auto mb-4" /> <Clock className="h-12 w-12 text-primary mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-3"> <h3 className="text-xl font-semibold mb-3">
Moving Services Moving Services
@ -435,8 +445,8 @@ export default async function RepairsPage() {
</h2> </h2>
</div> </div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4 mb-12"> <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4 mb-12">
<Card className="border-border/50 hover:border-secondary/50 transition-colors"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-8"> <CardContent className="p-8">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold"> <div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold">
01 01
@ -454,8 +464,8 @@ export default async function RepairsPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-8"> <CardContent className="p-8">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold"> <div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold">
02 02
@ -471,8 +481,8 @@ export default async function RepairsPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-8"> <CardContent className="p-8">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold"> <div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold">
03 03
@ -490,8 +500,8 @@ export default async function RepairsPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors"> <Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="pt-8"> <CardContent className="p-8">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold"> <div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold">
04 04
@ -576,13 +586,13 @@ export default async function RepairsPage() {
</div> </div>
<div className="grid gap-8 md:grid-cols-3"> <div className="grid gap-8 md:grid-cols-3">
{/* Vending Machine Brands */} {/* Vending Machine Brands */}
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl"> <CardTitle className="text-2xl">
Vending Machine Brands We Commonly Service Vending Machine Brands We Commonly Service
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="px-6 pb-6 pt-0">
<ul className="space-y-2"> <ul className="space-y-2">
<li> AMS (Automated Merchandising Systems)</li> <li> AMS (Automated Merchandising Systems)</li>
<li> AP (Automatic Products)</li> <li> AP (Automatic Products)</li>
@ -603,13 +613,13 @@ export default async function RepairsPage() {
</Card> </Card>
{/* Card Reader Brands */} {/* Card Reader Brands */}
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl"> <CardTitle className="text-2xl">
Card Reader Brands We Service Card Reader Brands We Service
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="px-6 pb-6 pt-0">
<ul className="space-y-2"> <ul className="space-y-2">
<li> Nayax</li> <li> Nayax</li>
<li> USA Technologies/Cantaloupe</li> <li> USA Technologies/Cantaloupe</li>
@ -619,13 +629,13 @@ export default async function RepairsPage() {
</Card> </Card>
{/* Bill Validator and Coin Mechanism Brands */} {/* Bill Validator and Coin Mechanism Brands */}
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl"> <CardTitle className="text-2xl">
Bill Validator and Coin Mechanism Brands We Service Bill Validator and Coin Mechanism Brands We Service
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="px-6 pb-6 pt-0">
<ul className="space-y-2"> <ul className="space-y-2">
<li> CPI (Crane Payment Innovations)</li> <li> CPI (Crane Payment Innovations)</li>
<li> Coinco (PayComplete)</li> <li> Coinco (PayComplete)</li>
@ -648,8 +658,8 @@ export default async function RepairsPage() {
Technologies and Protocols We Service Technologies and Protocols We Service
</h2> </h2>
</div> </div>
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardContent className="pt-6"> <CardContent className="p-6">
<p className="text-muted-foreground mb-6 leading-relaxed"> <p className="text-muted-foreground mb-6 leading-relaxed">
At Rocky Mountain Vending, we support three key vending At Rocky Mountain Vending, we support three key vending
machine technologies to boost your business profitability. The machine technologies to boost your business profitability. The
@ -718,11 +728,11 @@ export default async function RepairsPage() {
</div> </div>
{/* Required Tools Card */} {/* Required Tools Card */}
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">What You'll Need</CardTitle> <CardTitle className="text-2xl">What You'll Need</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="px-6 pb-6 pt-2">
<p className="text-muted-foreground mb-4 leading-relaxed"> <p className="text-muted-foreground mb-4 leading-relaxed">
To make the most of your virtual session, it's important To make the most of your virtual session, it's important
to be prepared with the necessary tools. Having the right to be prepared with the necessary tools. Having the right
@ -782,11 +792,11 @@ export default async function RepairsPage() {
</Card> </Card>
{/* How to Prepare Card */} {/* How to Prepare Card */}
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">How to Prepare</CardTitle> <CardTitle className="text-2xl">How to Prepare</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="px-6 pb-6 pt-2">
<ol className="space-y-4"> <ol className="space-y-4">
<li className="text-foreground"> <li className="text-foreground">
<strong>Be On-Site with Your Machine:</strong> <strong>Be On-Site with Your Machine:</strong>
@ -814,13 +824,13 @@ export default async function RepairsPage() {
</Card> </Card>
{/* Important Policies Card */} {/* Important Policies Card */}
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl"> <CardTitle className="text-2xl">
Important Policies Important Policies
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="px-6 pb-6 pt-2">
<ul className="space-y-4"> <ul className="space-y-4">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" /> <CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
@ -876,13 +886,13 @@ export default async function RepairsPage() {
</Card> </Card>
{/* Schedule Your Virtual Session Today */} {/* Schedule Your Virtual Session Today */}
<Card className="border-border/50"> <Card className={surfaceCardClass}>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl"> <CardTitle className="text-2xl">
Schedule Your Virtual Session Today Schedule Your Virtual Session Today
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="px-6 pb-6 pt-2">
<p className="text-muted-foreground leading-relaxed mb-4"> <p className="text-muted-foreground leading-relaxed mb-4">
Ready to get started? Fill out our{" "} Ready to get started? Fill out our{" "}
<Link <Link

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next"
import Link from "next/link" import Link from "next/link"
import { getAllLocations } from "@/lib/location-data" import { getAllLocations } from "@/lib/location-data"
import { businessConfig } from "@/lib/seo-config" import { businessConfig } from "@/lib/seo-config"
import { Breadcrumbs } from "@/components/breadcrumbs"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ArrowRight, MapPin } from "lucide-react" import { ArrowRight, MapPin } from "lucide-react"
import { import {
@ -120,14 +121,21 @@ export default function ServiceAreasServicesPage() {
return ( return (
<div className="public-page"> <div className="public-page">
<PublicPageHeader <Breadcrumbs
align="center" className="mb-6"
eyebrow="Legacy Route" items={[
title="Vending machine services by location" { label: "Services", href: "/services" },
description="Rocky Mountain Vending provides vending machine sales, service, repairs, and healthy option planning across our Utah coverage area. Browse by city below or head to the main service-areas page for the canonical experience." { label: "By Location", href: "/services/service-areas" },
className="mb-10 md:mb-14" ]}
/> />
<PublicPageHeader
align="center"
eyebrow="Services by Location"
title="See how service support connects to the cities we cover."
description="Use this route to browse service coverage from the Services family view, then jump into the main service-area experience when you want the full location-by-location directory."
className="mb-10 md:mb-14"
/>
<section className="mx-auto mb-12 max-w-5xl"> <section className="mx-auto mb-12 max-w-5xl">
<PublicSurface className="p-5 md:p-7"> <PublicSurface className="p-5 md:p-7">
<PublicSectionHeader <PublicSectionHeader
@ -235,8 +243,8 @@ export default function ServiceAreasServicesPage() {
</div> </div>
<PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center"> <PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center">
<p className="text-sm leading-6 text-muted-foreground"> <p className="text-sm leading-6 text-muted-foreground">
This route stays available for legacy links, but the primary This route stays available to keep the Services family connected,
service-area experience lives on the main{" "} but the primary service-area experience lives on the main{" "}
<Link <Link
href="/service-areas" href="/service-areas"
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary" className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"

View file

@ -1,10 +1,10 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation"
import { isAdminUiEnabled } from "@/lib/server/admin-auth"; import { isAdminUiEnabled } from "@/lib/server/admin-auth"
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"; import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
export default function SignInPage() { export default function SignInPage() {
if (!isAdminUiEnabled()) { if (!isAdminUiEnabled()) {
redirect("/"); redirect("/")
} }
return ( return (
@ -19,13 +19,16 @@ export default function SignInPage() {
/> />
<PublicSurface className="p-6 text-center md:p-8"> <PublicSurface className="p-6 text-center md:p-8">
<h2 className="text-2xl font-semibold">Admin sign-in is not configured</h2> <h2 className="text-2xl font-semibold">
Admin sign-in is not configured
</h2>
<p className="mt-3 text-sm text-muted-foreground"> <p className="mt-3 text-sm text-muted-foreground">
Enable the admin UI and connect an auth provider before using this area. Enable the admin UI and connect an auth provider before using this
area.
</p> </p>
</PublicSurface> </PublicSurface>
</div> </div>
</div> </div>
</div> </div>
); )
} }

View file

@ -1,26 +1,32 @@
import Link from 'next/link' import Link from "next/link"
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import {
import { Badge } from '@/components/ui/badge' Card,
import { CardContent,
CheckCircle, CardDescription,
XCircle, CardHeader,
AlertCircle, CardTitle,
ExternalLink, } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
CheckCircle,
XCircle,
AlertCircle,
ExternalLink,
Package, Package,
ShoppingCart, ShoppingCart,
CreditCard, CreditCard,
Settings, Settings,
Zap Zap,
} from 'lucide-react' } from "lucide-react"
export default async function StripeSetupPage() { export default async function StripeSetupPage() {
// Test Stripe connectivity // Test Stripe connectivity
const testResponse = await fetch('http://localhost:3000/api/stripe/test', { const testResponse = await fetch("http://localhost:3000/api/stripe/test", {
method: 'POST', method: "POST",
cache: 'no-store' cache: "no-store",
}) })
const testResult = testResponse.ok ? await testResponse.json() : null const testResult = testResponse.ok ? await testResponse.json() : null
const stripeWorking = testResult?.success const stripeWorking = testResult?.success
@ -31,31 +37,48 @@ export default async function StripeSetupPage() {
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Zap className="h-8 w-8 text-blue-600" /> <Zap className="h-8 w-8 text-blue-600" />
<h1 className="text-4xl font-bold tracking-tight">Stripe Integration Setup</h1> <h1 className="text-4xl font-bold tracking-tight">
Stripe Integration Setup
</h1>
</div> </div>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Everything is ready for your Stripe products. Complete the setup below to activate your e-commerce functionality. Everything is ready for your Stripe products. Complete the setup
below to activate your e-commerce functionality.
</p> </p>
</div> </div>
{/* Status Overview */} {/* Status Overview */}
<Card className={stripeWorking ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}> <Card
className={
stripeWorking
? "border-green-200 bg-green-50"
: "border-red-200 bg-red-50"
}
>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{stripeWorking ? ( {stripeWorking ? (
<> <>
<CheckCircle className="h-8 w-8 text-green-600" /> <CheckCircle className="h-8 w-8 text-green-600" />
<div> <div>
<h3 className="text-lg font-semibold text-green-800">Stripe Integration Active</h3> <h3 className="text-lg font-semibold text-green-800">
<p className="text-green-700">Your Stripe account is connected and ready for products.</p> Stripe Integration Active
</h3>
<p className="text-green-700">
Your Stripe account is connected and ready for products.
</p>
</div> </div>
</> </>
) : ( ) : (
<> <>
<XCircle className="h-8 w-8 text-red-600" /> <XCircle className="h-8 w-8 text-red-600" />
<div> <div>
<h3 className="text-lg font-semibold text-red-800">Setup Required</h3> <h3 className="text-lg font-semibold text-red-800">
<p className="text-red-700">Complete the steps below to activate Stripe integration.</p> Setup Required
</h3>
<p className="text-red-700">
Complete the steps below to activate Stripe integration.
</p>
</div> </div>
</> </>
)} )}
@ -80,7 +103,7 @@ export default async function StripeSetupPage() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/products"> <Link href="/products">
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer"> <Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="p-6 flex flex-col items-center text-center"> <CardContent className="p-6 flex flex-col items-center text-center">
@ -95,7 +118,7 @@ export default async function StripeSetupPage() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/admin"> <Link href="/admin">
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer"> <Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="p-6 flex flex-col items-center text-center"> <CardContent className="p-6 flex flex-col items-center text-center">
@ -132,7 +155,8 @@ export default async function StripeSetupPage() {
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">Add Products to Stripe</h3> <h3 className="font-medium">Add Products to Stripe</h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Create your vending machine products with prices in Stripe Dashboard Create your vending machine products with prices in Stripe
Dashboard
</p> </p>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
{testResult?.products?.total ? ( {testResult?.products?.total ? (
@ -140,9 +164,7 @@ export default async function StripeSetupPage() {
{testResult.products.total} products {testResult.products.total} products
</Badge> </Badge>
) : ( ) : (
<Badge variant="secondary"> <Badge variant="secondary">No products yet</Badge>
No products yet
</Badge>
)} )}
<Link href="https://dashboard.stripe.com/products/new"> <Link href="https://dashboard.stripe.com/products/new">
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
@ -162,7 +184,8 @@ export default async function StripeSetupPage() {
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">Enable Payment Methods</h3> <h3 className="font-medium">Enable Payment Methods</h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Ensure customers can pay with credit cards, Apple Pay, Google Pay, etc. Ensure customers can pay with credit cards, Apple Pay, Google
Pay, etc.
</p> </p>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
{stripeWorking && testResult?.paymentMethods ? ( {stripeWorking && testResult?.paymentMethods ? (
@ -170,9 +193,7 @@ export default async function StripeSetupPage() {
{testResult.paymentMethods.total} methods {testResult.paymentMethods.total} methods
</Badge> </Badge>
) : ( ) : (
<Badge variant="secondary"> <Badge variant="secondary">Check Stripe Dashboard</Badge>
Check Stripe Dashboard
</Badge>
)} )}
{stripeWorking ? ( {stripeWorking ? (
<Badge variant="outline" className="text-green-600"> <Badge variant="outline" className="text-green-600">
@ -197,7 +218,8 @@ export default async function StripeSetupPage() {
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">Webhook Configuration</h3> <h3 className="font-medium">Webhook Configuration</h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Webhooks are already configured to handle order completion and payments Webhooks are already configured to handle order completion and
payments
</p> </p>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
{process.env.STRIPE_WEBHOOK_SECRET ? ( {process.env.STRIPE_WEBHOOK_SECRET ? (
@ -267,19 +289,29 @@ export default async function StripeSetupPage() {
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<div className="text-sm text-muted-foreground">Account ID</div> <div className="text-sm text-muted-foreground">
Account ID
</div>
<div className="font-medium">{testResult.account.id}</div> <div className="font-medium">{testResult.account.id}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Business Name</div> <div className="text-sm text-muted-foreground">
<div className="font-medium">{testResult.account.name || 'Not set'}</div> Business Name
</div>
<div className="font-medium">
{testResult.account.name || "Not set"}
</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Country</div> <div className="text-sm text-muted-foreground">Country</div>
<div className="font-medium">{testResult.account.country}</div> <div className="font-medium">
{testResult.account.country}
</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Charges Enabled</div> <div className="text-sm text-muted-foreground">
Charges Enabled
</div>
<div className="font-medium"> <div className="font-medium">
{testResult.account.charges_enabled ? ( {testResult.account.charges_enabled ? (
<Badge variant="default">Yes</Badge> <Badge variant="default">Yes</Badge>
@ -289,7 +321,9 @@ export default async function StripeSetupPage() {
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Payouts Enabled</div> <div className="text-sm text-muted-foreground">
Payouts Enabled
</div>
<div className="font-medium"> <div className="font-medium">
{testResult.account.payouts_enabled ? ( {testResult.account.payouts_enabled ? (
<Badge variant="default">Yes</Badge> <Badge variant="default">Yes</Badge>
@ -299,7 +333,9 @@ export default async function StripeSetupPage() {
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">API Version</div> <div className="text-sm text-muted-foreground">
API Version
</div>
<div className="font-medium">{testResult.apiVersion}</div> <div className="font-medium">{testResult.apiVersion}</div>
</div> </div>
</div> </div>
@ -338,12 +374,13 @@ export default async function StripeSetupPage() {
</ol> </ol>
</div> </div>
</div> </div>
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Need help? Check the Stripe documentation or contact support. Need help? Check the Stripe documentation or contact
support.
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -364,7 +401,7 @@ export default async function StripeSetupPage() {
} }
export const metadata = { export const metadata = {
title: 'Stripe Setup | Rocky Mountain Vending', title: "Stripe Setup | Rocky Mountain Vending",
description: 'Complete your Stripe integration setup and add products to your store', description:
"Complete your Stripe integration setup and add products to your store",
} }

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,18 @@
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { TermsAndConditionsPage } from '@/components/terms-and-conditions-page'; import { TermsAndConditionsPage } from "@/components/terms-and-conditions-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("terms", {
title: 'Terms & Conditions | Rocky Mountain Vending',
description: 'Terms and Conditions for Rocky Mountain Vending',
robots: { robots: {
index: false, index: false,
follow: false, follow: false,
}, },
}); })
} }
export default function TermsAndConditions() { export default function TermsAndConditions() {
const structuredData = generateStructuredData({ const structuredData = generateRegistryStructuredData("terms")
title: 'Terms & Conditions',
description: 'Terms and Conditions for Rocky Mountain Vending',
url: 'https://rockymountainvending.com/terms-and-conditions/',
type: 'WebPage',
});
return ( return (
<> <>
@ -29,13 +22,5 @@ export default function TermsAndConditions() {
/> />
<TermsAndConditionsPage /> <TermsAndConditionsPage />
</> </>
); )
} }

View file

@ -1,30 +1,33 @@
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 type { ImageMapping } from "@/lib/wordpress-content"
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 type { Metadata } from 'next' import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { PublicPageHeader, PublicSurface } from '@/components/public-surface' import type { Metadata } from "next"
import { GetFreeMachineCta } from '@/components/get-free-machine-cta' import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
import { Breadcrumbs } from "@/components/breadcrumbs"
const WORDPRESS_SLUG = 'vending-machines-for-sale-in-utah' const WORDPRESS_SLUG = "vending-machines-for-sale-in-utah"
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 || 'Vending Machines for Sale in Utah', title: page.title || "Vending Machines for Sale in Utah",
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: "/vending-machines/machines-for-sale",
}) })
} }
@ -36,7 +39,7 @@ export default async function MachinesForSalePage() {
notFound() notFound()
} }
let imageMapping: Record<string, string> = {} let imageMapping: ImageMapping = {}
try { try {
imageMapping = loadImageMapping() imageMapping = loadImageMapping()
} catch { } catch {
@ -46,44 +49,71 @@ export default async function MachinesForSalePage() {
const structuredData = (() => { const structuredData = (() => {
try { try {
return generateStructuredData({ return generateStructuredData({
title: page.title || 'Vending Machines for Sale in Utah', title: page.title || "Vending Machines for Sale in Utah",
description: page.seoDescription || page.excerpt || '', description: page.seoDescription || page.excerpt || "",
url: page.link || page.urlPath || 'https://rockymountainvending.com/vending-machines/machines-for-sale/', url:
page.link ||
page.urlPath ||
"https://rockymountainvending.com/vending-machines/machines-for-sale/",
datePublished: page.date, datePublished: page.date,
dateModified: page.modified || page.date, dateModified: page.modified || page.date,
type: 'WebPage', type: "WebPage",
}) })
} catch { } catch {
return { return {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'WebPage', "@type": "WebPage",
headline: page.title || 'Vending Machines for Sale in Utah', headline: page.title || "Vending Machines for Sale in Utah",
description: page.seoDescription || '', description: page.seoDescription || "",
url: 'https://rockymountainvending.com/vending-machines/machines-for-sale/', url: "https://rockymountainvending.com/vending-machines/machines-for-sale/",
} }
} }
})() })()
return ( return (
<> <>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} /> <script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<div className="container mx-auto px-4 py-10 md:py-14"> <div className="container mx-auto px-4 py-10 md:py-14">
<Breadcrumbs
className="mb-6"
items={[
{ label: "Vending Machines", href: "/vending-machines" },
{
label: "Machines for Sale",
href: "/vending-machines/machines-for-sale",
},
]}
/>
<PublicPageHeader <PublicPageHeader
eyebrow="Machine Sales" eyebrow="Machine Sales"
title={page.title || 'Vending Machines for Sale in Utah'} title={page.title || "Vending Machines for Sale in Utah"}
description="Compare machine options, payment hardware, and support with help from the Rocky Mountain Vending team." description="Compare machine options, payment hardware, and support with help from the Rocky Mountain Vending team."
/> />
<PublicSurface className="mt-10"> <PublicSurface className="mt-10">
<div className="max-w-none">{cleanWordPressContent(String(page.content || ''), { imageMapping, pageTitle: page.title })}</div> <div className="max-w-none">
{cleanWordPressContent(String(page.content || ""), {
imageMapping,
pageTitle: page.title,
})}
</div>
</PublicSurface> </PublicSurface>
<section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]"> <section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<PublicSurface> <PublicSurface>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Free Placement</p> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">Need a free machine instead of buying one?</h2> Free Placement
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
Need a free machine instead of buying one?
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground"> <p className="mt-3 text-base leading-relaxed text-muted-foreground">
If you&apos;re a business looking for placement instead of a purchase, we can help you find the right setup for your location. If you&apos;re a business looking for placement instead of a
purchase, we can help you find the right setup for your
location.
</p> </p>
<div className="mt-6"> <div className="mt-6">
<GetFreeMachineCta buttonLabel="Get Free Placement" /> <GetFreeMachineCta buttonLabel="Get Free Placement" />
@ -91,10 +121,16 @@ export default async function MachinesForSalePage() {
</PublicSurface> </PublicSurface>
<PublicSurface className="flex items-center justify-center text-center"> <PublicSurface className="flex items-center justify-center text-center">
<div className="max-w-xl"> <div className="max-w-xl">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary/80">Need Sales Help?</p> <p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary/80">
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">Talk through machine sales, placement, or feature questions.</h3> Need Sales Help?
</p>
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">
Talk through machine sales, placement, or feature questions.
</h3>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground"> <p className="mt-3 text-sm leading-relaxed text-muted-foreground">
We can help with new vs. used options, payment hardware, and whether free placement or a direct purchase makes more sense for your location. We can help with new vs. used options, payment hardware, and
whether free placement or a direct purchase makes more sense
for your location.
</p> </p>
</div> </div>
</PublicSurface> </PublicSurface>
@ -103,8 +139,8 @@ export default async function MachinesForSalePage() {
</> </>
) )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Machines for Sale page:', error) console.error("Error rendering Machines for Sale page:", error)
} }
notFound() notFound()
} }

View file

@ -1,30 +1,47 @@
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo' import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { VendingMachinesShowcase } from '@/components/vending-machines-showcase' import { VendingMachinesShowcase } from "@/components/vending-machines-showcase"
import { FeatureCard } from '@/components/feature-card' import { FeatureCard } from "@/components/feature-card"
import type { Metadata } from 'next' import type { Metadata } from "next"
import { PublicPageHeader, PublicSurface } from '@/components/public-surface' import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
import { GetFreeMachineCta } from '@/components/get-free-machine-cta' import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
import { Breadcrumbs } from "@/components/breadcrumbs"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({ return generateSEOMetadata({
title: 'Machines We Use | Rocky Mountain Vending', title: "Machines We Use | Rocky Mountain Vending",
description: 'Learn about the high-quality vending machines and equipment we use at Rocky Mountain Vending, including credit card readers, drop sensors, and specialty equipment.', description:
"Learn about the high-quality vending machines and equipment we use at Rocky Mountain Vending, including credit card readers, drop sensors, and specialty equipment.",
path: "/vending-machines/machines-we-use",
}) })
} }
export default async function MachinesWeUsePage() { export default async function MachinesWeUsePage() {
try { try {
const structuredData = generateStructuredData({ const structuredData = generateStructuredData({
title: 'Machines We Use', title: "Machines We Use",
description: 'Learn about the high-quality vending machines and equipment we use at Rocky Mountain Vending, including credit card readers, drop sensors, and specialty equipment.', description:
url: 'https://rockymountainvending.com/vending-machines/machines-we-use/', "Learn about the high-quality vending machines and equipment we use at Rocky Mountain Vending, including credit card readers, drop sensors, and specialty equipment.",
type: 'WebPage', url: "https://rockymountainvending.com/vending-machines/machines-we-use/",
type: "WebPage",
}) })
return ( return (
<> <>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} /> <script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<div className="container mx-auto px-4 py-10 md:py-14"> <div className="container mx-auto px-4 py-10 md:py-14">
<Breadcrumbs
className="mb-6"
items={[
{ label: "Vending Machines", href: "/vending-machines" },
{
label: "Machines We Use",
href: "/vending-machines/machines-we-use",
},
]}
/>
<PublicPageHeader <PublicPageHeader
align="center" align="center"
eyebrow="Equipment" eyebrow="Equipment"
@ -61,9 +78,13 @@ export default async function MachinesWeUsePage() {
<section className="mt-12 grid gap-6 lg:grid-cols-2"> <section className="mt-12 grid gap-6 lg:grid-cols-2">
<PublicSurface> <PublicSurface>
<h2 className="text-3xl font-semibold tracking-tight text-balance">Specialty equipment</h2> <h2 className="text-3xl font-semibold tracking-tight text-balance">
Specialty equipment
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground"> <p className="mt-3 text-base leading-relaxed text-muted-foreground">
We invest in higher-quality tools and machine components so the service experience stays cleaner, more dependable, and easier for customers to use. We invest in higher-quality tools and machine components so the
service experience stays cleaner, more dependable, and easier
for customers to use.
</p> </p>
<div className="mt-6 grid gap-6 md:grid-cols-2"> <div className="mt-6 grid gap-6 md:grid-cols-2">
<FeatureCard <FeatureCard
@ -85,10 +106,16 @@ export default async function MachinesWeUsePage() {
</div> </div>
</PublicSurface> </PublicSurface>
<PublicSurface> <PublicSurface>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Free Placement</p> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">Want this kind of setup at your location?</h2> Free Placement
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
Want this kind of setup at your location?
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground"> <p className="mt-3 text-base leading-relaxed text-muted-foreground">
If you&apos;re looking for free placement at your business, tell us about your location and we&apos;ll help you choose the right mix. If you&apos;re looking for free placement at your business, tell
us about your location and we&apos;ll help you choose the right
mix.
</p> </p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<GetFreeMachineCta buttonLabel="Get Free Placement" /> <GetFreeMachineCta buttonLabel="Get Free Placement" />
@ -103,15 +130,19 @@ export default async function MachinesWeUsePage() {
</> </>
) )
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.error('Error rendering Machines We Use page:', error) console.error("Error rendering Machines We Use page:", error)
} }
return ( return (
<div className="container mx-auto px-4 py-10 md:py-14"> <div className="container mx-auto px-4 py-10 md:py-14">
<PublicSurface> <PublicSurface>
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground md:text-5xl">Error Loading Page</h1> <h1 className="text-4xl font-bold tracking-tight text-balance text-foreground md:text-5xl">
<p className="mt-4 text-base leading-relaxed text-destructive">There was an error loading this page. Please try again later.</p> Error Loading Page
{process.env.NODE_ENV === 'development' && ( </h1>
<p className="mt-4 text-base leading-relaxed text-destructive">
There was an error loading this page. Please try again later.
</p>
{process.env.NODE_ENV === "development" && (
<pre className="mt-4 overflow-auto rounded-[1.5rem] bg-muted/60 p-4 text-sm"> <pre className="mt-4 overflow-auto rounded-[1.5rem] bg-muted/60 p-4 text-sm">
{error instanceof Error ? error.message : String(error)} {error instanceof Error ? error.message : String(error)}
</pre> </pre>

View file

@ -1,23 +1,18 @@
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo" import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { VendingMachinesPage } from "@/components/vending-machines-page" import { VendingMachinesPage } from "@/components/vending-machines-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("vendingMachines", {
title: "Vending Machines | Rocky Mountain Vending",
description: description:
"Compare snack, beverage, and combo vending machines for Utah businesses, including payment options, layouts, and placement or purchase paths.", "Compare snack, beverage, and combo vending machines for Utah businesses, including payment options, layouts, and placement or purchase paths.",
path: "/vending-machines",
}) })
} }
export default function VendingMachines() { export default function VendingMachines() {
const structuredData = generateStructuredData({ const structuredData = generateRegistryStructuredData("vendingMachines", {
title: "Vending Machines",
description: description:
"Compare snack, beverage, and combo vending machines for Utah businesses", "Compare snack, beverage, and combo vending machines for Utah businesses, including payment options, layouts, and placement or purchase paths.",
url: "https://rockymountainvending.com/vending-machines/",
type: "WebPage",
}) })
return ( return (

View file

@ -1,80 +1,62 @@
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 = 'streamlining-snack-and-beverage-access-in-warehouse-environments'; const WORDPRESS_SLUG =
"streamlining-snack-and-beverage-access-in-warehouse-environments"
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("warehouses", {
title: page.title || 'Warehouse 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 WarehousesPage() { export default async function WarehousesPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG); const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) { if (!page) {
notFound(); notFound()
} }
// Load image mapping (optional, won't break if it fails) // Load image mapping (optional, won't break if it fails)
let imageMapping: any = {}; let imageMapping: any = {}
try { try {
imageMapping = loadImageMapping(); imageMapping = loadImageMapping()
} catch (e) { } catch (e) {
imageMapping = {}; imageMapping = {}
} }
// Clean and render WordPress content as styled React components // Clean and render WordPress content as styled React components
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>
); )
// Generate structured data const structuredData = generateRegistryStructuredData("warehouses", {
let structuredData; datePublished: page.date,
try { dateModified: page.modified || page.date,
structuredData = generateStructuredData({ })
title: page.title || 'Warehouse Vending Solutions',
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/warehouses/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Warehouse Vending Solutions',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/warehouses/`,
};
}
return ( return (
<> <>
@ -82,14 +64,17 @@ export default async function WarehousesPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<WhoWeServePage title={page.title || 'Warehouse Vending Solutions'} content={content} /> <WhoWeServePage
title={page.title || "Warehouse 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 Warehouses page:', error); console.error("Error rendering Warehouses page:", error)
} }
notFound(); notFound()
} }
} }

5
artifacts/README.md Normal file
View file

@ -0,0 +1,5 @@
# Artifacts
- `backups/` holds preserved source snapshots and formatting backups.
- `logs/` holds local runtime logs that are useful for troubleshooting but not part of the app.
- `reports/` holds generated SEO and formatting outputs so the repo root stays focused on source and durable docs.

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