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/
temp/
.pnpm-store/
.formatting-backups/
.cursor/
.playwright-cli/
output/
docs/
artifacts/
# 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
/node_modules
# next.js
/.next/
/out/
/output/
# production
/build
@ -15,10 +14,12 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
/dev.log
# env files
.env*
!.env.example
!.env.staging.example
# vercel
.vercel
@ -26,3 +27,10 @@ yarn-error.log*
# typescript
*.tsbuildinfo
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 = {
ci: {
collect: {
url: ['http://localhost:3000'],
url: ["http://localhost:3000"],
numberOfRuns: 3,
startServerCommand: 'npm run start',
startServerReadyPattern: 'ready',
startServerCommand: "npm run start",
startServerReadyPattern: "ready",
startServerReadyTimeout: 30000,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 1 }],
'categories:accessibility': ['error', { minScore: 1 }],
'categories:best-practices': ['error', { minScore: 1 }],
'categories:seo': ['error', { minScore: 1 }],
"categories:performance": ["error", { minScore: 1 }],
"categories:accessibility": ["error", { minScore: 1 }],
"categories:best-practices": ["error", { minScore: 1 }],
"categories:seo": ["error", { minScore: 1 }],
// Core Web Vitals
'first-contentful-paint': ['error', { maxNumericValue: 1800 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'speed-index': ['error', { maxNumericValue: 3400 }],
"first-contentful-paint": ["error", { maxNumericValue: 1800 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-blocking-time": ["error", { maxNumericValue: 200 }],
"speed-index": ["error", { maxNumericValue: 3400 }],
// Performance metrics
'interactive': ['error', { maxNumericValue: 3800 }],
'uses-optimized-images': 'error',
'uses-text-compression': 'error',
'uses-responsive-images': 'error',
'modern-image-formats': 'error',
'offscreen-images': 'error',
'render-blocking-resources': 'error',
'unused-css-rules': 'error',
'unused-javascript': 'error',
'efficient-animated-content': 'error',
'preload-lcp-image': 'error',
'uses-long-cache-ttl': 'error',
'total-byte-weight': ['error', { maxNumericValue: 1600000 }],
interactive: ["error", { maxNumericValue: 3800 }],
"uses-optimized-images": "error",
"uses-text-compression": "error",
"uses-responsive-images": "error",
"modern-image-formats": "error",
"offscreen-images": "error",
"render-blocking-resources": "error",
"unused-css-rules": "error",
"unused-javascript": "error",
"efficient-animated-content": "error",
"preload-lcp-image": "error",
"uses-long-cache-ttl": "error",
"total-byte-weight": ["error", { maxNumericValue: 1600000 }],
},
},
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 { 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 { AboutPage } from "@/components/about-page"
import type { Metadata } from "next"
@ -16,14 +15,10 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
return generateSEOMetadata({
title: page.title || "About Us",
description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt,
return generateRegistryMetadata("aboutUs", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
path: "/about-us",
})
}
@ -35,28 +30,10 @@ export default async function AboutUsPage() {
notFound()
}
let structuredData
try {
structuredData = generateStructuredData({
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/`,
}
}
const structuredData = generateRegistryStructuredData("aboutUs", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
return (
<>

View file

@ -1,83 +1,74 @@
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 { FAQSchema } from '@/components/faq-schema';
import { FAQSection } from '@/components/faq-section';
import type { Metadata } from 'next';
import { notFound } from "next/navigation"
import { buildAbsoluteUrl } from "@/lib/seo-registry"
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { Breadcrumbs } from "@/components/breadcrumbs"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { FAQSchema } from "@/components/faq-schema"
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> {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
return {
title: 'Page Not Found | Rocky Mountain Vending',
};
title: "Page Not Found | Rocky Mountain Vending",
}
}
return generateSEOMetadata({
title: page.title || 'FAQs',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
return generateRegistryMetadata("faqs", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
});
})
}
export default async function FAQsPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
notFound();
notFound()
}
// Extract FAQs from content
const faqs: Array<{ question: string; answer: string }> = [];
const faqs: Array<{ question: string; answer: string }> = []
if (page.content) {
const contentStr = String(page.content);
const contentStr = String(page.content)
// 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
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();
answer = answer.replace(/\n\s*\n/g, '\n').replace(/>\s+</g, '><').trim();
return answer;
});
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()
answer = answer
.replace(/\n\s*\n/g, "\n")
.replace(/>\s+</g, "><")
.trim()
return answer
})
// Match questions with answers
questions.forEach((question, index) => {
if (answers[index]) {
faqs.push({ question, answer: answers[index] });
faqs.push({ question, answer: answers[index] })
}
});
})
}
let structuredData;
try {
structuredData = generateStructuredData({
title: page.title || 'FAQs',
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/`,
};
}
const pageUrl = buildAbsoluteUrl("/about/faqs")
const structuredData = generateRegistryStructuredData("faqs", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
return (
<>
@ -86,28 +77,27 @@ export default async function FAQsPage() {
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
{faqs.length > 0 && (
<>
<FAQSchema
faqs={faqs}
pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`}
<div className="public-page">
<Breadcrumbs
className="mb-6"
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) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering FAQs page:', error);
if (process.env.NODE_ENV === "development") {
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 type { Metadata } from "next"
import { businessConfig } from "@/lib/seo-config"
export async function generateMetadata(): Promise<Metadata> {
return {
...generateSEOMetadata({
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",
}),
...generateRegistryMetadata("aboutLegacy"),
alternates: {
canonical: `${businessConfig.website}/about-us`,
},
@ -22,13 +17,7 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default function About() {
const structuredData = generateStructuredData({
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",
})
const structuredData = generateRegistryStructuredData("aboutUs")
return (
<>

View file

@ -1,31 +1,37 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { fetchQuery } from "convex/nextjs";
import { ArrowLeft, ExternalLink, Phone } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link"
import { notFound } from "next/navigation"
import { fetchQuery } from "convex/nextjs"
import { ArrowLeft, ExternalLink, Phone } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
formatPhoneCallDuration,
formatPhoneCallTimestamp,
normalizePhoneFromIdentity,
} from "@/lib/phone-calls";
} from "@/lib/phone-calls"
type PageProps = {
params: Promise<{
id: string;
}>;
};
id: string
}>
}
export default async function AdminCallDetailPage({ params }: PageProps) {
const { id } = await params;
const { id } = await params
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
callId: id,
});
})
if (!detail) {
notFound();
notFound()
}
return (
@ -33,13 +39,19 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<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" />
Back to calls
</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">
{normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity}
{normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity}
</p>
</div>
</div>
@ -51,58 +63,107 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<Phone className="h-5 w-5" />
Call Status
</CardTitle>
<CardDescription>Operational detail for this direct phone session.</CardDescription>
<CardDescription>
Operational detail for this direct phone session.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Started</p>
<p className="font-medium">{formatPhoneCallTimestamp(detail.call.startedAt)}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Started
</p>
<p className="font-medium">
{formatPhoneCallTimestamp(detail.call.startedAt)}
</p>
</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>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Duration</p>
<p className="font-medium">{formatPhoneCallDuration(detail.call.durationMs)}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Duration
</p>
<p className="font-medium">
{formatPhoneCallDuration(detail.call.durationMs)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Participant Identity</p>
<p className="font-medium break-all">{detail.call.participantIdentity || "Unknown"}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Participant Identity
</p>
<p className="font-medium break-all">
{detail.call.participantIdentity || "Unknown"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Call Status</p>
<Badge className="mt-1" variant={detail.call.callStatus === "failed" ? "destructive" : detail.call.callStatus === "started" ? "secondary" : "default"}>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Call Status
</p>
<Badge
className="mt-1"
variant={
detail.call.callStatus === "failed"
? "destructive"
: detail.call.callStatus === "started"
? "secondary"
: "default"
}
>
{detail.call.callStatus}
</Badge>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Jessica Answered</p>
<p className="font-medium">{detail.call.answered ? "Yes" : "No"}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Jessica Answered
</p>
<p className="font-medium">
{detail.call.answered ? "Yes" : "No"}
</p>
</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>
</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>
</div>
<div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Summary</p>
<p className="text-sm whitespace-pre-wrap">{detail.call.summaryText || "No summary available yet."}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Summary
</p>
<p className="text-sm whitespace-pre-wrap">
{detail.call.summaryText || "No summary available yet."}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Recording Status</p>
<p className="font-medium">{detail.call.recordingStatus || "Unavailable"}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Recording Status
</p>
<p className="font-medium">
{detail.call.recordingStatus || "Unavailable"}
</p>
</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>
</div>
{detail.call.recordingUrl ? (
<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
<ExternalLink className="h-4 w-4" />
</Link>
@ -110,8 +171,12 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
) : null}
{detail.call.notificationError ? (
<div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Error</p>
<p className="text-sm text-destructive">{detail.call.notificationError}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Email Error
</p>
<p className="text-sm text-destructive">
{detail.call.notificationError}
</p>
</div>
) : null}
</CardContent>
@ -121,37 +186,56 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<CardHeader>
<CardTitle>Linked Lead</CardTitle>
<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>
</CardHeader>
<CardContent className="space-y-3">
{detail.linkedLead ? (
<>
<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">
{detail.linkedLead.firstName} {detail.linkedLead.lastName}
</p>
</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>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email</p>
<p className="font-medium break-all">{detail.linkedLead.email}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Email
</p>
<p className="font-medium break-all">
{detail.linkedLead.email}
</p>
</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>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Message</p>
<p className="text-sm whitespace-pre-wrap">{detail.linkedLead.message || "—"}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Message
</p>
<p className="text-sm whitespace-pre-wrap">
{detail.linkedLead.message || "—"}
</p>
</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>
</Card>
@ -160,11 +244,15 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<Card>
<CardHeader>
<CardTitle>Transcript</CardTitle>
<CardDescription>Complete mirrored transcript for this phone call.</CardDescription>
<CardDescription>
Complete mirrored transcript for this phone call.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{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) => (
<div key={turn.id} className="rounded-lg border p-3">
@ -180,10 +268,11 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</Card>
</div>
</div>
);
)
}
export const metadata = {
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 { fetchQuery } from "convex/nextjs";
import { Phone, Search } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import Link from "next/link"
import { fetchQuery } from "convex/nextjs"
import { Phone, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
formatPhoneCallDuration,
formatPhoneCallTimestamp,
normalizePhoneFromIdentity,
} from "@/lib/phone-calls";
} from "@/lib/phone-calls"
type PageProps = {
searchParams: Promise<{
search?: string;
status?: "started" | "completed" | "failed";
page?: string;
}>;
};
search?: string
status?: "started" | "completed" | "failed"
page?: string
}>
}
function getStatusVariant(status: "started" | "completed" | "failed") {
if (status === "failed") {
return "destructive" as const;
return "destructive" as const
}
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) {
const params = await searchParams;
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
const status = params.status;
const search = params.search?.trim() || undefined;
const params = await searchParams
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
const status = params.status
const search = params.search?.trim() || undefined
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
search,
status,
page,
limit: 25,
});
})
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Calls</h1>
<h1 className="text-4xl font-bold tracking-tight text-balance">
Phone Calls
</h1>
<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>
</div>
<Link href="/admin">
@ -66,13 +75,20 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<Phone className="h-5 w-5" />
Call Inbox
</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>
<CardContent className="space-y-4">
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_auto]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input name="search" defaultValue={search || ""} placeholder="Search calls" className="pl-9" />
<Input
name="search"
defaultValue={search || ""}
placeholder="Search calls"
className="pl-9"
/>
</div>
<select
name="status"
@ -107,35 +123,65 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<tbody>
{data.items.length === 0 ? (
<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.
</td>
</tr>
) : (
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">
<div>{normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}</div>
<div className="text-xs text-muted-foreground">{call.roomName}</div>
<div>
{normalizePhoneFromIdentity(
call.participantIdentity
) || call.participantIdentity}
</div>
<div className="text-xs text-muted-foreground">
{call.roomName}
</div>
</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">
<Badge variant={getStatusVariant(call.callStatus)}>{call.callStatus}</Badge>
{formatPhoneCallTimestamp(call.startedAt)}
</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"}
{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 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="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 className="py-3">
<Link href={`/admin/calls/${call.id}`}>
<Button size="sm" variant="outline">View</Button>
<Button size="sm" variant="outline">
View
</Button>
</Link>
</td>
</tr>
@ -147,7 +193,8 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<div className="flex items-center justify-between gap-4">
<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>
<div className="flex gap-2">
{data.pagination.page > 1 ? (
@ -158,7 +205,9 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
page: String(data.pagination.page - 1),
}).toString()}`}
>
<Button variant="outline" size="sm">Previous</Button>
<Button variant="outline" size="sm">
Previous
</Button>
</Link>
) : null}
{data.pagination.page < data.pagination.totalPages ? (
@ -169,7 +218,9 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
page: String(data.pagination.page + 1),
}).toString()}`}
>
<Button variant="outline" size="sm">Next</Button>
<Button variant="outline" size="sm">
Next
</Button>
</Link>
) : null}
</div>
@ -178,10 +229,10 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
</Card>
</div>
</div>
);
)
}
export const metadata = {
title: "Phone Calls | Admin",
description: "View direct phone calls, transcript history, and lead outcomes",
};
}

View file

@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { isAdminUiEnabled } from "@/lib/server/admin-auth";
import { redirect } from "next/navigation"
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
export default async function AdminLayout({
children,
@ -7,8 +7,8 @@ export default async function AdminLayout({
children: React.ReactNode
}) {
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() {
return (
@ -9,6 +9,6 @@ export default function AdminOrdersPage() {
}
export const metadata = {
title: 'Order Management | Admin',
description: 'View and manage customer orders',
title: "Order Management | Admin",
description: "View and manage customer orders",
}

View file

@ -1,22 +1,28 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
ShoppingCart,
Package,
Users,
TrendingUp,
DollarSign,
import Link from "next/link"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
ShoppingCart,
Package,
Users,
TrendingUp,
DollarSign,
Clock,
CheckCircle,
Truck,
AlertTriangle,
Settings,
BarChart3,
Phone
} from 'lucide-react'
import { fetchAllProducts } from '@/lib/stripe/products'
Phone,
} from "lucide-react"
import { fetchAllProducts } from "@/lib/stripe/products"
// Mock analytics data for demo
const mockAnalytics = {
@ -27,7 +33,7 @@ const mockAnalytics = {
lowStockProducts: 3,
avgOrderValue: 311.46,
conversionRate: 2.8,
monthlyGrowth: 15.2
monthlyGrowth: 15.2,
}
async function getProductsCount() {
@ -41,7 +47,7 @@ async function getProductsCount() {
async function getOrdersCount() {
try {
const response = await fetch('/api/orders')
const response = await fetch("/api/orders")
if (response.ok) {
const data = await response.json()
return data.pagination.total || 0
@ -53,127 +59,127 @@ async function getOrdersCount() {
export default async function AdminDashboard() {
const [productsCount, ordersCount] = await Promise.all([
getProductsCount(),
getOrdersCount()
getOrdersCount(),
])
const dashboardCards = [
{
title: 'Total Revenue',
title: "Total Revenue",
value: `$${mockAnalytics.totalRevenue.toLocaleString()}`,
description: 'Total revenue from all orders',
description: "Total revenue from all orders",
icon: DollarSign,
trend: '+15.2%',
trend: "+15.2%",
trendPositive: true,
color: 'text-green-600'
color: "text-green-600",
},
{
title: 'Total Orders',
title: "Total Orders",
value: mockAnalytics.totalOrders.toString(),
description: 'Total number of orders',
description: "Total number of orders",
icon: ShoppingCart,
trend: '+12.8%',
trend: "+12.8%",
trendPositive: true,
color: 'text-blue-600'
color: "text-blue-600",
},
{
title: 'Products',
title: "Products",
value: productsCount.toString(),
description: 'Active products in inventory',
description: "Active products in inventory",
icon: Package,
trend: '+5',
trend: "+5",
trendPositive: true,
color: 'text-purple-600'
color: "text-purple-600",
},
{
title: 'Pending Orders',
title: "Pending Orders",
value: mockAnalytics.pendingOrders.toString(),
description: 'Orders awaiting processing',
description: "Orders awaiting processing",
icon: Clock,
trend: '-3',
trend: "-3",
trendPositive: false,
color: 'text-orange-600'
}
color: "text-orange-600",
},
]
const quickStats = [
{
title: 'Average Order Value',
title: "Average Order Value",
value: `$${mockAnalytics.avgOrderValue.toFixed(2)}`,
description: 'Average value per order',
icon: TrendingUp
description: "Average value per order",
icon: TrendingUp,
},
{
title: 'Conversion Rate',
title: "Conversion Rate",
value: `${mockAnalytics.conversionRate}%`,
description: 'Visitors to orders ratio',
icon: Users
description: "Visitors to orders ratio",
icon: Users,
},
{
title: 'Monthly Growth',
title: "Monthly Growth",
value: `${mockAnalytics.monthlyGrowth}%`,
description: 'Revenue growth this month',
icon: BarChart3
description: "Revenue growth this month",
icon: BarChart3,
},
{
title: 'Low Stock Alert',
title: "Low Stock Alert",
value: mockAnalytics.lowStockProducts.toString(),
description: 'Products need restocking',
icon: AlertTriangle
}
description: "Products need restocking",
icon: AlertTriangle,
},
]
const recentOrders = [
{
id: 'ORD-001234',
customer: 'john.doe@email.com',
id: "ORD-001234",
customer: "john.doe@email.com",
amount: 2799.98,
status: 'paid',
date: '2024-01-15 10:30'
status: "paid",
date: "2024-01-15 10:30",
},
{
id: 'ORD-001233',
customer: 'jane.smith@email.com',
id: "ORD-001233",
customer: "jane.smith@email.com",
amount: 1499.99,
status: 'fulfilled',
date: '2024-01-15 09:45'
status: "fulfilled",
date: "2024-01-15 09:45",
},
{
id: 'ORD-001232',
customer: 'bob.johnson@email.com',
id: "ORD-001232",
customer: "bob.johnson@email.com",
amount: 899.97,
status: 'pending',
date: '2024-01-15 08:20'
status: "pending",
date: "2024-01-15 08:20",
},
{
id: 'ORD-001231',
customer: 'alice.wilson@email.com',
id: "ORD-001231",
customer: "alice.wilson@email.com",
amount: 3499.99,
status: 'cancelled',
date: '2024-01-14 16:15'
}
status: "cancelled",
date: "2024-01-14 16:15",
},
]
const popularProducts = [
{
name: 'SEAGA HY900 Vending Machine',
name: "SEAGA HY900 Vending Machine",
orders: 45,
revenue: 112499.55
revenue: 112499.55,
},
{
name: 'Vending Machine Stand',
name: "Vending Machine Stand",
orders: 38,
revenue: 11399.62
revenue: 11399.62,
},
{
name: 'Snack Vending Machine Combo',
name: "Snack Vending Machine Combo",
orders: 23,
revenue: 45999.77
revenue: 45999.77,
},
{
name: 'Drink Vending Machine',
name: "Drink Vending Machine",
orders: 19,
revenue: 37999.81
}
revenue: 37999.81,
},
]
return (
@ -182,7 +188,9 @@ export default async function AdminDashboard() {
{/* Header */}
<div className="flex justify-between items-start">
<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">
Overview of your store performance and management tools
</p>
@ -220,7 +228,7 @@ export default async function AdminDashboard() {
<div className="text-2xl font-bold">{card.value}</div>
<div className="flex items-center gap-1 mt-2">
<span className={`text-sm ${card.color}`}>
{card.trend} {card.trendPositive ? '↑' : '↓'}
{card.trend} {card.trendPositive ? "↑" : "↓"}
</span>
<span className="text-sm text-muted-foreground">
from last month
@ -266,7 +274,9 @@ export default async function AdminDashboard() {
<CardTitle className="flex items-center justify-between">
Recent Orders
<Link href="/admin/orders">
<Button variant="outline" size="sm">View All</Button>
<Button variant="outline" size="sm">
View All
</Button>
</Link>
</CardTitle>
<CardDescription>
@ -276,7 +286,10 @@ export default async function AdminDashboard() {
<CardContent>
<div className="space-y-4">
{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="min-w-0 flex-1">
<div className="font-medium">{order.id}</div>
@ -289,16 +302,23 @@ export default async function AdminDashboard() {
</div>
</div>
<div className="text-right">
<div className="font-medium">${order.amount.toFixed(2)}</div>
<div className="font-medium">
${order.amount.toFixed(2)}
</div>
<Badge
variant={
order.status === 'paid' ? 'default' :
order.status === 'fulfilled' ? 'default' :
order.status === 'pending' ? 'secondary' : 'destructive'
order.status === "paid"
? "default"
: order.status === "fulfilled"
? "default"
: order.status === "pending"
? "secondary"
: "destructive"
}
className="mt-1"
>
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
{order.status.charAt(0).toUpperCase() +
order.status.slice(1)}
</Badge>
</div>
</div>
@ -313,17 +333,20 @@ export default async function AdminDashboard() {
<CardTitle className="flex items-center justify-between">
Popular Products
<Link href="/admin/products">
<Button variant="outline" size="sm">View All</Button>
<Button variant="outline" size="sm">
View All
</Button>
</Link>
</CardTitle>
<CardDescription>
Top-selling products this month
</CardDescription>
<CardDescription>Top-selling products this month</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{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="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground">
{index + 1}
@ -338,7 +361,9 @@ export default async function AdminDashboard() {
</div>
</div>
<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">
${(product.revenue / product.orders).toFixed(2)} avg
</div>
@ -371,7 +396,7 @@ export default async function AdminDashboard() {
</CardContent>
</Card>
</Link>
<Link href="/admin/products">
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center">
@ -383,7 +408,7 @@ export default async function AdminDashboard() {
</CardContent>
</Card>
</Link>
<Link href="/orders">
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center">
@ -395,7 +420,7 @@ export default async function AdminDashboard() {
</CardContent>
</Card>
</Link>
<Card className="h-full hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center">
<CheckCircle className="h-8 w-8 text-orange-600 mb-3" />
@ -414,6 +439,7 @@ export default async function AdminDashboard() {
}
export const metadata = {
title: 'Admin Dashboard | Rocky Mountain Vending',
description: 'Administrative dashboard for managing your vending machine business',
title: "Admin Dashboard | Rocky Mountain Vending",
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() {
return (
@ -9,6 +9,6 @@ export default function AdminProductsPage() {
}
export const metadata = {
title: 'Product Management | Admin',
description: 'Manage your Stripe products and inventory',
title: "Product Management | Admin",
description: "Manage your Stripe products and inventory",
}

View file

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

View file

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

View file

@ -18,7 +18,11 @@ import {
isSiteChatSuppressedRoute,
} from "@/lib/site-chat/config"
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"
type ChatRole = "user" | "assistant"
@ -81,7 +85,10 @@ function normalizeSessionId(rawSessionId: string | undefined | null) {
}
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}`
}
@ -89,24 +96,46 @@ function normalizeMessages(messages: ChatMessage[] | undefined) {
const safeMessages = Array.isArray(messages) ? messages : []
return safeMessages
.filter((message) => message && (message.role === "user" || message.role === "assistant"))
.filter(
(message) =>
message && (message.role === "user" || message.role === "assistant")
)
.map((message) => ({
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)
.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) {
return null
}
const name = String(rawVisitor.name || "").replace(/\s+/g, " ").trim().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)
const name = String(rawVisitor.name || "")
.replace(/\s+/g, " ")
.trim()
.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) {
return null
@ -190,25 +219,36 @@ export async function POST(request: NextRequest) {
const visitor = normalizeVisitorProfile(body.visitor, 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) {
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 messages = normalizeMessages(body.messages)
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user")
const latestUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user")
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) {
@ -217,7 +257,7 @@ export async function POST(request: NextRequest) {
error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`,
sessionId,
},
{ status: 400, headers: responseHeaders },
{ status: 400, headers: responseHeaders }
)
}
@ -234,11 +274,12 @@ export async function POST(request: NextRequest) {
if (limitStatus.blocked) {
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,
limits: limitStatus,
},
{ status: 429, headers: responseHeaders },
{ status: 429, headers: responseHeaders }
)
blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
@ -252,7 +293,11 @@ export async function POST(request: NextRequest) {
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")
if (!xaiApiKey) {
@ -263,32 +308,36 @@ export async function POST(request: NextRequest) {
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,
},
{ status: 503, headers: responseHeaders },
{ status: 503, headers: responseHeaders }
)
}
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${xaiApiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: SITE_CHAT_MODEL,
temperature: SITE_CHAT_TEMPERATURE,
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
messages: [
{
role: "system",
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 completionResponse = await fetch(
"https://api.x.ai/v1/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${xaiApiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: SITE_CHAT_MODEL,
temperature: SITE_CHAT_TEMPERATURE,
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
messages: [
{
role: "system",
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(() => ({}))
@ -302,10 +351,11 @@ export async function POST(request: NextRequest) {
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,
},
{ 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.",
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({
ip,
@ -339,7 +393,7 @@ export async function POST(request: NextRequest) {
sessionId,
limits: nextLimitStatus,
},
{ headers: responseHeaders },
{ headers: responseHeaders }
)
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)
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."
: error instanceof Error
? error.message
@ -365,7 +422,7 @@ export async function POST(request: NextRequest) {
{
error: safeError,
},
{ status: 500, headers: responseHeaders },
{ status: 500, headers: responseHeaders }
)
}
}

View file

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

View file

@ -50,20 +50,26 @@ function getChallengeCode(request: NextRequest) {
export async function GET(request: NextRequest) {
const challengeCode = getChallengeCode(request)
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()
if (!verificationToken) {
return NextResponse.json(
{ error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." },
{ status: 500 },
{ status: 500 }
)
}
const endpoint = getEbayNotificationEndpoint(request.url)
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({
@ -78,7 +84,7 @@ export async function GET(request: NextRequest) {
headers: {
"Cache-Control": "no-store",
},
},
}
)
}
@ -93,18 +99,27 @@ export async function POST(request: NextRequest) {
})
if (!verification.verified) {
if (verification.reason === "Notification verification credentials are not configured.") {
console.warn("[ebay/notifications] accepted notification without signature verification", {
reason: verification.reason,
})
if (
verification.reason ===
"Notification verification credentials are not configured."
) {
console.warn(
"[ebay/notifications] accepted notification without signature verification",
{
reason: verification.reason,
}
)
const payload = parseNotificationBody(body)
const notification = payload?.notification
console.info("[ebay/notifications] accepted notification without verification", {
topic: payload?.metadata?.topic || "unknown",
notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
})
console.info(
"[ebay/notifications] accepted notification without verification",
{
topic: payload?.metadata?.topic || "unknown",
notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
}
)
return new NextResponse(null, { status: 204 })
}
@ -133,9 +148,12 @@ export async function POST(request: NextRequest) {
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
@ -27,11 +27,15 @@ interface eBaySearchResult {
type MaybeArray<T> = T | T[]
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[]>>()
// 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
function generateAffiliateLink(viewItemUrl: string): string {
@ -41,12 +45,12 @@ function generateAffiliateLink(viewItemUrl: string): string {
try {
const url = new URL(viewItemUrl)
url.searchParams.set('mkcid', '1')
url.searchParams.set('mkrid', '711-53200-19255-0')
url.searchParams.set('siteid', '0')
url.searchParams.set('campid', AFFILIATE_CAMPAIGN_ID)
url.searchParams.set('toolid', '10001')
url.searchParams.set('mkevt', '1')
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", AFFILIATE_CAMPAIGN_ID)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
@ -65,25 +69,25 @@ function normalizeItem(item: any): eBaySearchResult {
const currentPrice = first(item.sellingStatus?.currentPrice)
const shippingCost = first(item.shippingInfo?.shippingServiceCost)
const condition = first(item.condition)
const viewItemUrl = item.viewItemURL || item.viewItemUrl || ''
const viewItemUrl = item.viewItemURL || item.viewItemUrl || ""
return {
itemId: item.itemId || '',
title: item.title || 'Unknown Item',
price: `${currentPrice?.value || '0'} ${currentPrice?.currencyId || 'USD'}`,
currency: currentPrice?.currencyId || 'USD',
itemId: item.itemId || "",
title: item.title || "Unknown Item",
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: first(item.galleryURL) || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || 'USD'}`
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: undefined,
affiliateLink: generateAffiliateLink(viewItemUrl),
}
}
async function readEbayErrorMessage(response: Response) {
const text = await response.text().catch(() => '')
const text = await response.text().catch(() => "")
if (!text.trim()) {
return `eBay API error: ${response.status}`
}
@ -91,11 +95,17 @@ async function readEbayErrorMessage(response: Response) {
try {
const parsed = JSON.parse(text) as any
const messages = parsed?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(messages?.message) ? messages.message[0] : messages?.message
const message = Array.isArray(messages?.message)
? messages.message[0]
: messages?.message
if (typeof message === 'string' && message.trim()) {
const errorId = Array.isArray(messages?.errorId) ? messages.errorId[0] : messages?.errorId
return errorId ? `eBay API error ${errorId}: ${message}` : `eBay API error: ${message}`
if (typeof message === "string" && message.trim()) {
const errorId = Array.isArray(messages?.errorId)
? messages.errorId[0]
: messages?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
} catch {
// Fall through to returning the raw text below.
@ -108,14 +118,14 @@ function buildCacheKey(
keywords: string,
categoryId: string | undefined,
sortOrder: string,
maxResults: number,
maxResults: number
): string {
return [
keywords.trim().toLowerCase(),
categoryId || '',
sortOrder || 'BestMatch',
categoryId || "",
sortOrder || "BestMatch",
String(maxResults),
].join('|')
].join("|")
}
function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null {
@ -143,22 +153,33 @@ function setCachedSearchResults(cacheKey: string, results: eBaySearchResult[]) {
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const keywords = searchParams.get('keywords')
const categoryId = searchParams.get('categoryId') || undefined
const sortOrder = searchParams.get('sortOrder') || 'BestMatch'
const maxResults = parseInt(searchParams.get('maxResults') || '6', 10)
const cacheKey = buildCacheKey(keywords || '', categoryId, sortOrder, maxResults)
const keywords = searchParams.get("keywords")
const categoryId = searchParams.get("categoryId") || undefined
const sortOrder = searchParams.get("sortOrder") || "BestMatch"
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
const cacheKey = buildCacheKey(
keywords || "",
categoryId,
sortOrder,
maxResults
)
if (!keywords) {
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()
if (!appId) {
console.error('EBAY_APP_ID not configured')
console.error("EBAY_APP_ID not configured")
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 }
)
}
@ -175,35 +196,40 @@ export async function GET(request: NextRequest) {
return NextResponse.json(results)
} catch (error) {
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 }
)
}
}
// 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)
url.searchParams.set('OPERATION-NAME', 'findItemsAdvanced')
url.searchParams.set('SERVICE-VERSION', '1.0.0')
url.searchParams.set('SECURITY-APPNAME', appId)
url.searchParams.set('RESPONSE-DATA-FORMAT', 'JSON')
url.searchParams.set('REST-PAYLOAD', 'true')
url.searchParams.set('keywords', keywords)
url.searchParams.set('sortOrder', sortOrder)
url.searchParams.set('paginationInput.entriesPerPage', maxResults.toString())
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
url.searchParams.set("SERVICE-VERSION", "1.0.0")
url.searchParams.set("SECURITY-APPNAME", appId)
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
url.searchParams.set("REST-PAYLOAD", "true")
url.searchParams.set("keywords", keywords)
url.searchParams.set("sortOrder", sortOrder)
url.searchParams.set("paginationInput.entriesPerPage", maxResults.toString())
if (categoryId) {
url.searchParams.set('categoryId', categoryId)
url.searchParams.set("categoryId", categoryId)
}
try {
const request = (async () => {
const response = await fetch(url.toString(), {
method: 'GET',
method: "GET",
headers: {
'Accept': 'application/json',
Accept: "application/json",
},
})
@ -221,11 +247,17 @@ export async function GET(request: NextRequest) {
}
const searchResult = findItemsAdvancedResponse.searchResult?.[0]
if (!searchResult || !searchResult.item || searchResult.item.length === 0) {
if (
!searchResult ||
!searchResult.item ||
searchResult.item.length === 0
) {
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))
})()
@ -235,11 +267,15 @@ export async function GET(request: NextRequest) {
const results = await request
setCachedSearchResults(cacheKey, results)
return NextResponse.json(results)
} catch (error) {
console.error('Error fetching from eBay API:', error)
console.error("Error fetching from eBay API:", error)
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 }
)
} finally {

View file

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

View file

@ -1,27 +1,35 @@
import { NextResponse } from "next/server";
import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request);
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError;
return authError
}
try {
const body = await request.json();
const body = await request.json()
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
sessionId: body.sessionId,
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
leadOutcome: body.leadOutcome || "none",
handoffRequested: typeof body.handoffRequested === "boolean" ? body.handoffRequested : undefined,
handoffReason: body.handoffReason ? String(body.handoffReason) : undefined,
});
handoffRequested:
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) {
console.error("Failed to link phone call lead:", error);
return NextResponse.json({ error: "Failed to link phone call lead" }, { status: 500 });
console.error("Failed to link phone call lead:", error)
return NextResponse.json(
{ error: "Failed to link phone call lead" },
{ status: 500 }
)
}
}

View file

@ -1,51 +1,51 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { hasConvexUrl } from "@/lib/convex-config";
import { timingSafeEqual } from "node:crypto"
import { NextResponse } from "next/server"
import { hasConvexUrl } from "@/lib/convex-config"
function readBearerToken(request: Request) {
const authHeader = request.headers.get("authorization") || "";
const authHeader = request.headers.get("authorization") || ""
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) {
const expectedBuffer = Buffer.from(expected);
const providedBuffer = Buffer.from(provided);
const expectedBuffer = Buffer.from(expected)
const providedBuffer = Buffer.from(provided)
if (expectedBuffer.length !== providedBuffer.length) {
return false;
return false
}
return timingSafeEqual(expectedBuffer, providedBuffer);
return timingSafeEqual(expectedBuffer, providedBuffer)
}
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) {
if (!hasConvexUrl()) {
return NextResponse.json(
{ error: "Convex is not configured for phone call sync" },
{ status: 503 },
);
{ status: 503 }
)
}
const configuredToken = getPhoneAgentInternalToken();
const configuredToken = getPhoneAgentInternalToken()
if (!configuredToken) {
return NextResponse.json(
{ 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)) {
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 { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request);
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError;
return authError
}
try {
const body = await request.json();
const result = await fetchMutation(api.voiceSessions.upsertPhoneCallSession, {
roomName: String(body.roomName || ""),
participantIdentity: String(body.participantIdentity || ""),
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
pathname: body.pathname ? String(body.pathname) : undefined,
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
source: "phone-agent",
metadata: body.metadata ? String(body.metadata) : undefined,
startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined,
recordingDisclosureAt:
typeof body.recordingDisclosureAt === "number" ? body.recordingDisclosureAt : undefined,
recordingStatus: body.recordingStatus || "pending",
});
const body = await request.json()
const result = await fetchMutation(
api.voiceSessions.upsertPhoneCallSession,
{
roomName: String(body.roomName || ""),
participantIdentity: String(body.participantIdentity || ""),
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
pathname: body.pathname ? String(body.pathname) : undefined,
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
source: "phone-agent",
metadata: body.metadata ? String(body.metadata) : undefined,
startedAt:
typeof body.startedAt === "number" ? body.startedAt : undefined,
recordingDisclosureAt:
typeof body.recordingDisclosureAt === "number"
? body.recordingDisclosureAt
: undefined,
recordingStatus: body.recordingStatus || "pending",
}
)
return NextResponse.json({
success: true,
sessionId: result?._id,
roomName: result?.roomName,
});
})
} catch (error) {
console.error("Failed to start phone call sync:", error);
return NextResponse.json({ error: "Failed to start phone call sync" }, { status: 500 });
console.error("Failed to start phone call sync:", error)
return NextResponse.json(
{ error: "Failed to start phone call sync" },
{ status: 500 }
)
}
}

View file

@ -1,16 +1,16 @@
import { NextResponse } from "next/server";
import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request);
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError;
return authError
}
try {
const body = await request.json();
const body = await request.json()
await fetchMutation(api.voiceSessions.addTranscriptTurn, {
sessionId: body.sessionId,
roomName: String(body.roomName || ""),
@ -21,12 +21,16 @@ export async function POST(request: Request) {
isFinal: typeof body.isFinal === "boolean" ? body.isFinal : undefined,
language: body.language ? String(body.language) : undefined,
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) {
console.error("Failed to append phone call turn:", error);
return NextResponse.json({ error: "Failed to append phone call turn" }, { status: 500 });
console.error("Failed to append phone call turn:", error)
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) {
try {
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)) {
console.info("[voice-assistant/token] blocked on suppressed route", { pathname })
return NextResponse.json({ error: "Voice assistant is not available on this route." }, { status: 403 })
console.info("[voice-assistant/token] blocked on suppressed route", {
pathname,
})
return NextResponse.json(
{ error: "Voice assistant is not available on this route." },
{ status: 403 }
)
}
const tokenResponse = await createVoiceAssistantTokenResponse(pathname)
@ -30,9 +38,12 @@ export async function POST(request: NextRequest) {
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(
_request: Request,
{ params }: { params: Promise<{ path: string[] }> },
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path: pathArray } = await params
@ -59,7 +59,9 @@ export async function GET(
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, "/")
if (!resolvedPath.startsWith(normalizedManualsDir)) {
return new NextResponse("Invalid path", { status: 400 })

View file

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

View file

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

View file

@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server"
import { getGSCConfig } from "@/lib/google-search-console"
// 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
* POST /api/request-indexing
*
*
* Body: { url: string }
* NOTE: This route is disabled for static export.
*/
@ -68,13 +68,14 @@ export async function POST(request: NextRequest) {
success: true,
message: "Indexing request endpoint ready",
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) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
error:
error instanceof Error ? error.message : "Unknown error occurred",
},
{ status: 500 }
)
@ -90,9 +91,7 @@ export async function GET() {
return NextResponse.json({
message: "Google Search Console URL Indexing Request API",
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) {
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"
// 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
* POST /api/sitemap-submit
*
*
* Body: { sitemapUrl?: string, siteUrl?: string }
* NOTE: This route is disabled for static export.
*/
@ -21,7 +21,8 @@ export async function generateStaticParams() {
export async function POST(request: NextRequest) {
try {
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 config = getGSCConfig()
@ -60,13 +61,14 @@ export async function POST(request: NextRequest) {
message: "Sitemap submission endpoint ready",
sitemapUrl,
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) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
error:
error instanceof Error ? error.message : "Unknown error occurred",
},
{ status: 500 }
)
@ -84,9 +86,7 @@ export async function GET() {
message: "Google Search Console Sitemap Submission API",
sitemapUrl,
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 { getStripeClient } from '@/lib/stripe/client'
import { NextRequest, NextResponse } from "next/server"
import { getStripeClient } from "@/lib/stripe/client"
/**
* POST /api/stripe/checkout
@ -13,24 +13,25 @@ export async function POST(request: NextRequest) {
const { items, successUrl, cancelUrl } = body
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json(
{ error: 'Items are required' },
{ status: 400 }
)
return NextResponse.json({ error: "Items are required" }, { status: 400 })
}
// Build line items for Stripe Checkout
const lineItems = items.map((item: { priceId: string; quantity: number }) => ({
price: item.priceId,
quantity: item.quantity,
}))
const lineItems = items.map(
(item: { priceId: string; quantity: number }) => ({
price: item.priceId,
quantity: item.quantity,
})
)
// Create Stripe Checkout session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
payment_method_types: ["card"],
line_items: lineItems,
mode: 'payment',
success_url: successUrl || `${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
mode: "payment",
success_url:
successUrl ||
`${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: cancelUrl || `${request.nextUrl.origin}/checkout/cancel`,
metadata: {
// Add any additional metadata here
@ -39,12 +40,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ sessionId: session.id, url: session.url })
} catch (error) {
console.error('Error creating checkout session:', error)
console.error("Error creating checkout session:", error)
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ error: "Failed to create checkout session" },
{ status: 500 }
)
}
}

View file

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

View file

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

View file

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

View file

@ -66,7 +66,7 @@ function getContentType(pathValue: string) {
export async function GET(
_request: Request,
{ params }: { params: Promise<{ path: string[] }> },
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path: pathArray } = await params
@ -76,12 +76,18 @@ export async function GET(
if (
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 })
}
const storageObject = await getManualAssetFromStorage("thumbnails", filePath)
const storageObject = await getManualAssetFromStorage(
"thumbnails",
filePath
)
if (storageObject) {
return new NextResponse(Buffer.from(storageObject.body), {
headers: {
@ -99,7 +105,9 @@ export async function GET(
const normalizedThumbnailsDir = thumbnailsDir.replace(/\\/g, "/")
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), {
headers: {
"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, "/")
if (!resolvedPath.startsWith(normalizedThumbnailsDir)) {
return new NextResponse("Invalid path", { status: 400 })

View file

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

View file

@ -1,5 +1,6 @@
import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { buildAbsoluteUrl } from "@/lib/seo-registry"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
@ -59,10 +60,7 @@ export default async function AbandonedVendingMachinesPage() {
structuredData = generateStructuredData({
title: page.title || "Abandoned Vending Machines",
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/abandoned-vending-machines/`,
url: buildAbsoluteUrl("/blog/abandoned-vending-machines"),
datePublished: page.date,
dateModified: page.modified || page.date,
type: "WebPage",
@ -73,7 +71,7 @@ export default async function AbandonedVendingMachinesPage() {
"@type": "WebPage",
headline: page.title || "Abandoned Vending Machines",
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 { loadImageMapping } from "@/lib/wordpress-content"
import { buildAbsoluteUrl } from "@/lib/seo-registry"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
@ -28,7 +29,7 @@ export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({
title: DISPLAY_TITLE,
description: page.seoDescription || page.excerpt || "",
description: DISPLAY_DESCRIPTION,
excerpt: page.excerpt,
date: page.date,
modified: page.modified,
@ -68,11 +69,10 @@ export default async function BestVendingMachineSupplierPage() {
try {
structuredData = generateStructuredData({
title: DISPLAY_TITLE,
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/best-vending-machine-supplier-in-salt-lake-city-utah/`,
description: DISPLAY_DESCRIPTION,
url: buildAbsoluteUrl(
"/blog/best-vending-machine-supplier-in-salt-lake-city-utah"
),
datePublished: page.date,
dateModified: page.modified || page.date,
type: "WebPage",
@ -82,8 +82,10 @@ export default async function BestVendingMachineSupplierPage() {
"@context": "https://schema.org",
"@type": "WebPage",
headline: DISPLAY_TITLE,
description: page.seoDescription || "",
url: `https://rockymountainvending.com/best-vending-machine-supplier-in-salt-lake-city-utah/`,
description: DISPLAY_DESCRIPTION,
url: buildAbsoluteUrl(
"/blog/best-vending-machine-supplier-in-salt-lake-city-utah"
),
}
}

View file

@ -1,76 +1,80 @@
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 { Breadcrumbs } from '@/components/breadcrumbs';
import type { Metadata } from 'next';
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 { DropdownPageShell } from "@/components/dropdown-page-shell"
import type { Metadata } from "next"
const WORDPRESS_SLUG = 'reviews';
const WORDPRESS_SLUG = "reviews"
export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
return {
title: 'Page Not Found | Rocky Mountain Vending',
};
title: "Page Not Found | Rocky Mountain Vending",
}
}
return generateSEOMetadata({
title: page.title || 'Reviews',
description: page.seoDescription || page.excerpt || '',
title: page.title || "Reviews",
description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt,
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
});
path: "/blog/reviews",
})
}
export default async function ReviewsPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
notFound();
notFound()
}
let imageMapping: any = {};
let imageMapping: any = {}
try {
imageMapping = loadImageMapping();
imageMapping = loadImageMapping()
} catch (e) {
imageMapping = {};
imageMapping = {}
}
const content = page.content ? (
<div className="max-w-none">
{cleanWordPressContent(String(page.content), {
{cleanWordPressContent(String(page.content), {
imageMapping,
pageTitle: page.title
pageTitle: page.title,
})}
</div>
) : (
<p className="text-muted-foreground">No content available.</p>
);
)
let structuredData;
let structuredData
try {
structuredData = generateStructuredData({
title: page.title || 'Reviews',
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/reviews/`,
title: page.title || "Reviews",
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/reviews/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
});
type: "WebPage",
})
} catch (e) {
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Reviews',
description: page.seoDescription || '',
"@context": "https://schema.org",
"@type": "WebPage",
headline: page.title || "Reviews",
description: page.seoDescription || "",
url: `https://rockymountainvending.com/reviews/`,
};
}
}
return (
@ -79,21 +83,41 @@ export default async function ReviewsPage() {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<Breadcrumbs
items={[
{ label: 'Blog', href: '/blog' },
{ label: page.title || 'Reviews', href: '/blog/reviews' },
<DropdownPageShell
breadcrumbs={[
{ label: "Blog", href: "/blog" },
{ 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) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering Reviews page:', error);
if (process.env.NODE_ENV === "development") {
console.error("Error rendering Reviews page:", error)
}
notFound();
notFound()
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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 { ContactPage } from "@/components/contact-page"
import type { Metadata } from "next"
@ -15,14 +15,10 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
return generateSEOMetadata({
title: page.title || "Contact Us",
description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt,
return generateRegistryMetadata("contactUs", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
path: "/contact-us",
})
}
@ -34,28 +30,10 @@ export default async function ContactUsPage() {
notFound()
}
let structuredData
try {
structuredData = generateStructuredData({
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/`,
}
}
const structuredData = generateRegistryStructuredData("contactUs", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
return (
<>

View file

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

View file

@ -1,76 +1,59 @@
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 { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { 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> {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
return {
title: 'Page Not Found | Rocky Mountain Vending',
};
title: "Page Not Found | Rocky Mountain Vending",
}
}
return generateSEOMetadata({
title: page.title || 'Healthy Vending Options',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
return generateRegistryMetadata("healthyOptions", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
});
})
}
export default async function HealthyOptionsPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
notFound();
notFound()
}
let imageMapping: any = {};
let imageMapping: any = {}
try {
imageMapping = loadImageMapping();
imageMapping = loadImageMapping()
} catch (e) {
imageMapping = {};
imageMapping = {}
}
const content = page.content ? (
<div className="max-w-none">
{cleanWordPressContent(String(page.content), {
{cleanWordPressContent(String(page.content), {
imageMapping,
pageTitle: page.title
pageTitle: page.title,
})}
</div>
) : (
<p className="text-muted-foreground">No content available.</p>
);
)
let structuredData;
try {
structuredData = generateStructuredData({
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/`,
};
}
const structuredData = generateRegistryStructuredData("healthyOptions", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
return (
<>
@ -78,26 +61,41 @@ export default async function HealthyOptionsPage() {
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">
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Healthy Vending Options'}</h1>
</header>
{content}
</article>
<DropdownPageShell
breadcrumbs={[
{ label: "Food & Beverage" },
{ label: "Healthy Options" },
]}
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) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering Healthy Options page:', error);
if (process.env.NODE_ENV === "development") {
console.error("Error rendering Healthy Options page:", error)
}
notFound();
notFound()
}
}

View file

@ -1,76 +1,60 @@
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 { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { 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> {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
return {
title: 'Page Not Found | Rocky Mountain Vending',
};
title: "Page Not Found | Rocky Mountain Vending",
}
}
return generateSEOMetadata({
title: page.title || 'Food & Beverage Suppliers',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
return generateRegistryMetadata("suppliers", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
});
})
}
export default async function SuppliersPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
notFound();
notFound()
}
let imageMapping: any = {};
let imageMapping: any = {}
try {
imageMapping = loadImageMapping();
imageMapping = loadImageMapping()
} catch (e) {
imageMapping = {};
imageMapping = {}
}
const content = page.content ? (
<div className="max-w-none">
{cleanWordPressContent(String(page.content), {
{cleanWordPressContent(String(page.content), {
imageMapping,
pageTitle: page.title
pageTitle: page.title,
})}
</div>
) : (
<p className="text-muted-foreground">No content available.</p>
);
)
let structuredData;
try {
structuredData = generateStructuredData({
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/`,
};
}
const structuredData = generateRegistryStructuredData("suppliers", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
return (
<>
@ -78,26 +62,41 @@ export default async function SuppliersPage() {
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">
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Food & Beverage Suppliers'}</h1>
</header>
{content}
</article>
<DropdownPageShell
breadcrumbs={[
{ label: "Food & Beverage" },
{ label: "Suppliers" },
]}
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) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering Suppliers page:', error);
if (process.env.NODE_ENV === "development") {
console.error("Error rendering Suppliers page:", error)
}
notFound();
notFound()
}
}

View file

@ -1,76 +1,59 @@
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 { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { 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> {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
return {
title: 'Page Not Found | Rocky Mountain Vending',
};
title: "Page Not Found | Rocky Mountain Vending",
}
}
return generateSEOMetadata({
title: page.title || 'Traditional Vending Options',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
return generateRegistryMetadata("traditionalOptions", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
});
})
}
export default async function TraditionalOptionsPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
notFound();
notFound()
}
let imageMapping: any = {};
let imageMapping: any = {}
try {
imageMapping = loadImageMapping();
imageMapping = loadImageMapping()
} catch (e) {
imageMapping = {};
imageMapping = {}
}
const content = page.content ? (
<div className="max-w-none">
{cleanWordPressContent(String(page.content), {
{cleanWordPressContent(String(page.content), {
imageMapping,
pageTitle: page.title
pageTitle: page.title,
})}
</div>
) : (
<p className="text-muted-foreground">No content available.</p>
);
)
let structuredData;
try {
structuredData = generateStructuredData({
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/`,
};
}
const structuredData = generateRegistryStructuredData("traditionalOptions", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
return (
<>
@ -78,26 +61,41 @@ export default async function TraditionalOptionsPage() {
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">
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Traditional Vending Options'}</h1>
</header>
{content}
</article>
<DropdownPageShell
breadcrumbs={[
{ label: "Food & Beverage" },
{ label: "Traditional Options" },
]}
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) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering Traditional Options page:', error);
if (process.env.NODE_ENV === "development") {
console.error("Error rendering Traditional Options page:", error)
}
notFound();
notFound()
}
}

View file

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

View file

@ -45,6 +45,7 @@ manuals/
## Adding Manuals
Simply add PDF files to the appropriate manufacturer directory. The system will:
- Automatically detect new files
- Extract categories from filenames
- 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
- PDFs are cached with long-term headers
- Supports hundreds of manuals without performance degradation

View file

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

View file

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

View file

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

View file

@ -1,11 +1,15 @@
import { fetchAllProducts } from '@/lib/stripe/products'
import { PublicInset, PublicPageHeader, PublicSurface } from '@/components/public-surface'
import { ProductGrid } from '@/components/product-grid'
import Link from 'next/link'
import { fetchAllProducts } from "@/lib/stripe/products"
import {
PublicInset,
PublicPageHeader,
PublicSurface,
} from "@/components/public-surface"
import { ProductGrid } from "@/components/product-grid"
import Link from "next/link"
export const metadata = {
title: 'Products | Rocky Mountain Vending',
description: 'Shop our selection of vending machines and equipment',
title: "Products | Rocky Mountain Vending",
description: "Shop our selection of vending machines and equipment",
}
export default async function ProductsPage() {
@ -16,13 +20,14 @@ export default async function ProductsPage() {
products = await fetchAllProducts()
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('STRIPE_SECRET_KEY')) {
error = 'Our product catalog is temporarily unavailable. Please contact us for current machine options.'
if (err.message.includes("STRIPE_SECRET_KEY")) {
error =
"Our product catalog is temporarily unavailable. Please contact us for current machine options."
} else {
error = 'Failed to load products. Please try again later.'
error = "Failed to load products. Please try again later."
}
} 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
</p>
<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>
</div>
<PublicInset className="mx-auto max-w-md text-sm text-muted-foreground">
<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>
</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 type { Metadata } from "next"
export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({
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",
})
return generateRegistryMetadata("reviews")
}
export default function Reviews() {
const structuredData = generateStructuredData({
title: "Customer Reviews",
description:
"Browse Rocky Mountain Vending reviews and the live Google review feed",
url: "https://rockymountainvending.com/reviews/",
type: "WebPage",
})
const structuredData = generateRegistryStructuredData("reviews")
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 Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicSurface,
} from "@/components/public-surface"
const videos = [
{
id: 'dgE8nyaxdJI',
title: 'Seaga HY900 Overview',
category: 'all'
id: "dgE8nyaxdJI",
title: "Seaga HY900 Overview",
category: "all",
},
{
id: 'HcVuro9drHo',
title: 'Troubleshooting Vertical Drop',
category: 'troubleshooting-vertical-drop'
id: "HcVuro9drHo",
title: "Troubleshooting Vertical Drop",
category: "troubleshooting-vertical-drop",
},
{
id: '-FGJVfZSMAg',
title: 'Loading Cans',
category: 'loading-cans'
id: "-FGJVfZSMAg",
title: "Loading Cans",
category: "loading-cans",
},
{
id: '-AzbNKq9nHg',
title: 'Loading Bottles',
category: 'loading-bottles'
id: "-AzbNKq9nHg",
title: "Loading Bottles",
category: "loading-bottles",
},
{
id: 'LeKX2zJzFMY',
title: 'Changing Can to Bottle',
category: 'changing-can-to-bottle'
}
id: "LeKX2zJzFMY",
title: "Changing Can to Bottle",
category: "changing-can-to-bottle",
},
]
const filterCategories = [
{ value: '', label: 'All' },
{ value: 'troubleshooting-vertical-drop', label: 'Troubleshooting Vertical Drop' },
{ value: 'loading-cans', label: 'Loading Cans' },
{ value: 'loading-bottles', label: 'Loading Bottles' },
{ value: 'changing-can-to-bottle', label: 'Changing Can to Bottle' }
{ value: "", label: "All" },
{
value: "troubleshooting-vertical-drop",
label: "Troubleshooting Vertical Drop",
},
{ 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() {
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [selectedCategory, setSelectedCategory] = useState<string>("")
const filteredVideos = videos.filter(video =>
selectedCategory === '' ||
selectedCategory === 'all' ||
video.category === selectedCategory
const filteredVideos = videos.filter(
(video) =>
selectedCategory === "" ||
selectedCategory === "all" ||
video.category === selectedCategory
)
return (
<article className="container mx-auto px-4 py-8 md:py-12 max-w-6xl">
<header className="mb-8 md:mb-12 text-center">
<h1 className="text-4xl md:text-5xl font-bold mb-4 tracking-tight text-balance">
Seaga HY 900 Support
</h1>
</header>
<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="public-page">
<Breadcrumbs
className="mb-6"
items={[
{ label: "Blog", href: "/blog" },
{ label: "Seaga HY 900 Support", href: "/seaga-hy900-support" },
]}
/>
<div className="mb-12">
<div className="aspect-video w-full max-w-4xl mx-auto mb-8">
<PublicPageHeader
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
className="w-full h-full rounded-lg"
className="h-full w-full"
src={`https://www.youtube.com/embed/${videos[0].id}`}
title={videos[0].title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
</PublicSurface>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-6 text-center">Video Tutorials</h2>
{/* Filter Buttons */}
<div className="mb-8 flex flex-wrap justify-center gap-3">
<section className="mt-12">
<div className="mx-auto mb-6 max-w-3xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
Video Tutorials
</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) => (
<Button
key={category.value}
onClick={() => setSelectedCategory(category.value)}
variant={selectedCategory === category.value ? 'default' : 'outline'}
variant={
selectedCategory === category.value ? "default" : "outline"
}
size="sm"
className="min-h-11 rounded-full"
>
{category.label}
</Button>
))}
</div>
</PublicInset>
{/* Video Gallery */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{filteredVideos.map((video) => (
<Card
key={video.id}
className="overflow-hidden hover:shadow-lg transition-shadow"
<PublicSurface
key={video.id}
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
className="w-full h-full"
className="h-full w-full"
src={`https://www.youtube.com/embed/${video.id}`}
title={video.title}
frameBorder="0"
@ -114,14 +148,15 @@ export default function SeagaHY900SupportPage() {
allowFullScreen
/>
</div>
<CardContent className="p-4">
<h3 className="font-semibold text-base">{video.title}</h3>
</CardContent>
</Card>
<div className="px-1 pb-1 pt-4">
<h3 className="text-lg font-semibold tracking-tight text-foreground">
{video.title}
</h3>
</div>
</PublicSurface>
))}
</div>
</section>
</article>
</div>
)
}

View file

@ -1,11 +1,15 @@
import type { Metadata } from "next"
import Image from "next/image"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { businessConfig } from "@/lib/seo-config"
import { Phone, CheckCircle2, Shield, Clock, MapPin } from "lucide-react"
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 { generateSEOMetadata } from "@/lib/seo"
@ -66,13 +70,15 @@ export default function MovingServicesPage() {
{/* Image Gallery Section */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-8 tracking-tight text-balance">
Professional Vending Machine Moving in Action
</h2>
<div className="grid gap-8 md:grid-cols-3">
{/* Image 1 */}
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
<CardContent className="p-0">
<PublicSurface className="p-5 md:p-7">
<PublicSectionHeader
eyebrow="In the Field"
title="Professional vending machine moving in action"
description="These examples show the way we protect machines, packaging, and the path around them when a move needs to happen cleanly."
className="mb-6"
/>
<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]">
<Image
src="/images/vending-machine-moving-service-1.png"
@ -91,12 +97,8 @@ export default function MovingServicesPage() {
sizes, ensuring your equipment arrives damage-free.
</p>
</div>
</CardContent>
</Card>
{/* Image 2 */}
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
<CardContent className="p-0">
</PublicInset>
<PublicInset className="overflow-hidden p-0">
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
<Image
src="/images/vending-machine-moving-service-2.png"
@ -115,12 +117,8 @@ export default function MovingServicesPage() {
throughout the relocation process.
</p>
</div>
</CardContent>
</Card>
{/* Image 3 */}
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
<CardContent className="p-0">
</PublicInset>
<PublicInset className="overflow-hidden p-0">
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
<Image
src="/images/vending-machine-moving-service-3.webp"
@ -139,22 +137,25 @@ export default function MovingServicesPage() {
heavy vending machine transport.
</p>
</div>
</CardContent>
</Card>
</div>
</PublicInset>
</div>
</PublicSurface>
</section>
{/* Specialized Moving Capabilities Section */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-8 tracking-tight text-balance">
Our Specialized Moving Capabilities
</h2>
<div className="grid gap-6 md:grid-cols-2">
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors">
<CardContent className="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" />
<PublicSurface className="p-5 md:p-7">
<PublicSectionHeader
eyebrow="Capabilities"
title="What we handle during a vending move"
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."
className="mb-6"
/>
<div className="grid gap-4 md:grid-cols-2">
<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>
<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
risking strain or tipping.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</PublicInset>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors">
<CardContent className="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" />
<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>
<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
maneuver through confined areas.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</PublicInset>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors">
<CardContent className="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">
<Shield 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">
<Shield className="w-6 h-6 text-primary" />
</div>
<div>
<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
transit.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</PublicInset>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors">
<CardContent className="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">
<Shield 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">
<Shield className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold mb-2">
@ -228,16 +223,14 @@ export default function MovingServicesPage() {
safeguard floors, walls, doors, and elevators from scratches
or dents.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</PublicInset>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors md:col-span-2">
<CardContent className="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" />
<PublicInset className="h-full p-6 md:col-span-2">
<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>
<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.
Reconnection and testing available upon request.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</PublicInset>
<Card className="border-border/50 shadow-md hover:border-secondary/50 transition-colors md:col-span-2">
<CardContent className="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">
<MapPin className="w-6 h-6 text-primary" />
<PublicInset className="h-full p-6 md:col-span-2">
<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">
<MapPin className="w-6 h-6 text-primary" />
</div>
<div>
<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
City, and surrounding areas).
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</PublicInset>
</div>
</PublicSurface>
</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"
const WORDPRESS_SLUG = "vending-machine-repairs"
import Link from "next/link"
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> {
const page = getPageBySlug(WORDPRESS_SLUG)
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",
})
return generateRegistryMetadata("services")
}
export default async function ServicesPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG)
const serviceCards = [
{
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) {
notFound()
}
export default function ServicesPage() {
const structuredData = generateRegistryStructuredData("services")
let imageMapping: any = {}
try {
imageMapping = loadImageMapping()
} catch (e) {
imageMapping = {}
}
const content = page.content ? (
<div className="max-w-none">
{cleanWordPressContent(String(page.content), {
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) }}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<div className="public-page">
<Breadcrumbs
className="mb-6"
items={[{ label: "Services", href: "/services" }]}
/>
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
<header className="mb-8">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-6">
{page.title || "Vending Machine Services"}
</h1>
</header>
{content}
</article>
</>
)
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("Error rendering Services page:", error)
}
notFound()
}
<PublicPageHeader
align="center"
eyebrow="Service Family"
title="Vending machine services that stay connected from install to ongoing support."
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."
>
<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>
</PublicPageHeader>
<section className="mt-10">
<PublicSurface className="p-5 md:p-7">
<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 Link from "next/link"
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 { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@ -18,8 +18,6 @@ import {
Package,
Wrench,
Phone,
Clock,
Shield,
ShoppingCart,
ArrowRight,
MapPin,
@ -32,6 +30,11 @@ import {
} from "lucide-react"
import { FAQSection } from "@/components/faq-section"
import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicPageHeader,
PublicSectionHeader,
PublicSurface,
} from "@/components/public-surface"
import type { Metadata } from "next"
const WORDPRESS_SLUG = "parts-and-support"
@ -47,14 +50,10 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
return generateSEOMetadata({
title: page.title || "Vending Machine Parts & Support",
description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt,
return generateRegistryMetadata("parts", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
path: "/services/parts",
})
}
@ -337,28 +336,10 @@ export default async function PartsPage() {
</div>
) : null
let structuredData
try {
structuredData = generateStructuredData({
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 structuredData = generateRegistryStructuredData("parts", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
const cleanExcerpt = page.excerpt
? cleanHtmlEntities(page.excerpt)
@ -373,6 +354,11 @@ export default async function PartsPage() {
.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 (
<>
<script
@ -389,16 +375,15 @@ export default async function PartsPage() {
{ label: "Parts", href: "/services/parts" },
]}
/>
<header className="text-center mb-6">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
{page.title || "Vending Machine Parts & Support"}
</h1>
{cleanExcerpt && (
<p className="text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto text-pretty leading-relaxed">
{cleanExcerpt}
</p>
)}
</header>
<PublicPageHeader
align="center"
eyebrow="Parts & Support"
title={page.title || "Vending Machine Parts & Support"}
description={
cleanExcerpt ||
"Replacement parts, documentation, and support for the brands and payment systems Rocky Mountain Vending works with most."
}
/>
</div>
</section>
@ -406,11 +391,11 @@ export default async function PartsPage() {
{content && (
<section className="py-12 md:py-16 bg-muted/30">
<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">
{content}
</div>
</article>
</PublicSurface>
</div>
</section>
)}
@ -420,18 +405,15 @@ export default async function PartsPage() {
{/* Services Section */}
<section className="py-12 md:py-16">
<div className="container mx-auto px-4 max-w-6xl">
<div className="text-center mb-8 md:mb-12">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance">
Parts & Support Services
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
Comprehensive parts and technical support to keep your vending
machines operational
</p>
</div>
<PublicSectionHeader
eyebrow="Service Scope"
title="Parts & support services"
description="Comprehensive parts and technical support to keep your vending machines operational."
className="mx-auto mb-8 max-w-3xl text-center md:mb-12"
/>
<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">
<CardContent className="pt-6">
<Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="p-6">
<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" />
</div>
@ -444,8 +426,8 @@ export default async function PartsPage() {
</CardContent>
</Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm">
<CardContent className="pt-6">
<Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="p-6">
<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" />
</div>
@ -458,8 +440,8 @@ export default async function PartsPage() {
</CardContent>
</Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm">
<CardContent className="pt-6">
<Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="p-6">
<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" />
</div>
@ -473,8 +455,8 @@ export default async function PartsPage() {
</CardContent>
</Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm">
<CardContent className="pt-6">
<Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="p-6">
<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" />
</div>
@ -488,8 +470,8 @@ export default async function PartsPage() {
</CardContent>
</Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm">
<CardContent className="pt-6">
<Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="p-6">
<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" />
</div>
@ -503,8 +485,8 @@ export default async function PartsPage() {
</CardContent>
</Card>
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-sm">
<CardContent className="pt-6">
<Card className={`${insetCardClass} h-full hover:border-primary/30`}>
<CardContent className="p-6">
<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" />
</div>
@ -524,22 +506,19 @@ export default async function PartsPage() {
{/* Available Parts Section */}
<section className="py-12 md:py-16 bg-muted/30">
<div className="container mx-auto px-4 max-w-6xl">
<div className="text-center mb-8 md:mb-12">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance">
Available Parts Include
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
We stock a comprehensive inventory of vending machine parts to
keep your equipment running smoothly
</p>
</div>
<Card className="border-border/50 shadow-lg">
<PublicSectionHeader
eyebrow="Inventory"
title="Available parts include"
description="We stock a comprehensive inventory of vending machine parts to keep your equipment running smoothly."
className="mx-auto mb-8 max-w-3xl text-center md:mb-12"
/>
<Card className={surfaceCardClass}>
<CardHeader>
<CardTitle className="text-2xl md:text-3xl">
Parts Inventory
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<CardContent className="px-6 pb-6 pt-2">
<div className="grid gap-4 md:grid-cols-2">
<ul className="space-y-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">
<AccordionItem
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">
Vending Machine Brands
@ -730,7 +709,7 @@ export default async function PartsPage() {
<AccordionItem
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">
Compatible Card Reader Brands
@ -763,7 +742,7 @@ export default async function PartsPage() {
<AccordionItem
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">
Compatible Bill Validator and Coin Mechanism Brands
@ -819,7 +798,7 @@ export default async function PartsPage() {
</p>
</div>
<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">
<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">
@ -836,7 +815,7 @@ export default async function PartsPage() {
</CardContent>
</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">
<div className="mb-6">
<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>
</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">
<div className="mb-6">
<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>
</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">
<div className="mb-6">
<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>
</div>
<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">
<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" />
@ -918,7 +897,7 @@ export default async function PartsPage() {
</CardContent>
</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">
<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" />
@ -931,7 +910,7 @@ export default async function PartsPage() {
</CardContent>
</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">
<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" />
@ -946,7 +925,7 @@ export default async function PartsPage() {
</CardContent>
</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">
<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" />
@ -959,7 +938,7 @@ export default async function PartsPage() {
</CardContent>
</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">
<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" />
@ -974,7 +953,7 @@ export default async function PartsPage() {
</CardContent>
</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">
<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" />
@ -998,7 +977,7 @@ export default async function PartsPage() {
{/* Call to Action */}
<section className="py-12 md:py-16 bg-background">
<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">
<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" />

View file

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

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next"
import Link from "next/link"
import { getAllLocations } from "@/lib/location-data"
import { businessConfig } from "@/lib/seo-config"
import { Breadcrumbs } from "@/components/breadcrumbs"
import { Button } from "@/components/ui/button"
import { ArrowRight, MapPin } from "lucide-react"
import {
@ -120,14 +121,21 @@ export default function ServiceAreasServicesPage() {
return (
<div className="public-page">
<PublicPageHeader
align="center"
eyebrow="Legacy Route"
title="Vending machine services by location"
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."
className="mb-10 md:mb-14"
<Breadcrumbs
className="mb-6"
items={[
{ label: "Services", href: "/services" },
{ label: "By Location", href: "/services/service-areas" },
]}
/>
<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">
<PublicSurface className="p-5 md:p-7">
<PublicSectionHeader
@ -235,8 +243,8 @@ export default function ServiceAreasServicesPage() {
</div>
<PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center">
<p className="text-sm leading-6 text-muted-foreground">
This route stays available for legacy links, but the primary
service-area experience lives on the main{" "}
This route stays available to keep the Services family connected,
but the primary service-area experience lives on the main{" "}
<Link
href="/service-areas"
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 { isAdminUiEnabled } from "@/lib/server/admin-auth";
import { PublicPageHeader, PublicSurface } from "@/components/public-surface";
import { redirect } from "next/navigation"
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
export default function SignInPage() {
if (!isAdminUiEnabled()) {
redirect("/");
redirect("/")
}
return (
@ -19,13 +19,16 @@ export default function SignInPage() {
/>
<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">
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>
</PublicSurface>
</div>
</div>
</div>
);
)
}

View file

@ -1,26 +1,32 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
CheckCircle,
XCircle,
AlertCircle,
ExternalLink,
import Link from "next/link"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
CheckCircle,
XCircle,
AlertCircle,
ExternalLink,
Package,
ShoppingCart,
CreditCard,
Settings,
Zap
} from 'lucide-react'
Zap,
} from "lucide-react"
export default async function StripeSetupPage() {
// Test Stripe connectivity
const testResponse = await fetch('http://localhost:3000/api/stripe/test', {
method: 'POST',
cache: 'no-store'
const testResponse = await fetch("http://localhost:3000/api/stripe/test", {
method: "POST",
cache: "no-store",
})
const testResult = testResponse.ok ? await testResponse.json() : null
const stripeWorking = testResult?.success
@ -31,31 +37,48 @@ export default async function StripeSetupPage() {
<div className="text-center space-y-4">
<div className="flex items-center justify-center gap-2">
<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>
<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>
</div>
{/* 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">
<div className="flex items-center gap-3">
{stripeWorking ? (
<>
<CheckCircle className="h-8 w-8 text-green-600" />
<div>
<h3 className="text-lg font-semibold text-green-800">Stripe Integration Active</h3>
<p className="text-green-700">Your Stripe account is connected and ready for products.</p>
<h3 className="text-lg font-semibold text-green-800">
Stripe Integration Active
</h3>
<p className="text-green-700">
Your Stripe account is connected and ready for products.
</p>
</div>
</>
) : (
<>
<XCircle className="h-8 w-8 text-red-600" />
<div>
<h3 className="text-lg font-semibold text-red-800">Setup Required</h3>
<p className="text-red-700">Complete the steps below to activate Stripe integration.</p>
<h3 className="text-lg font-semibold text-red-800">
Setup Required
</h3>
<p className="text-red-700">
Complete the steps below to activate Stripe integration.
</p>
</div>
</>
)}
@ -80,7 +103,7 @@ export default async function StripeSetupPage() {
</CardContent>
</Card>
</Link>
<Link href="/products">
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="p-6 flex flex-col items-center text-center">
@ -95,7 +118,7 @@ export default async function StripeSetupPage() {
</CardContent>
</Card>
</Link>
<Link href="/admin">
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="p-6 flex flex-col items-center text-center">
@ -132,7 +155,8 @@ export default async function StripeSetupPage() {
<div className="flex-1">
<h3 className="font-medium">Add Products to Stripe</h3>
<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>
<div className="flex items-center gap-2 mt-2">
{testResult?.products?.total ? (
@ -140,9 +164,7 @@ export default async function StripeSetupPage() {
{testResult.products.total} products
</Badge>
) : (
<Badge variant="secondary">
No products yet
</Badge>
<Badge variant="secondary">No products yet</Badge>
)}
<Link href="https://dashboard.stripe.com/products/new">
<Button size="sm" variant="outline">
@ -162,7 +184,8 @@ export default async function StripeSetupPage() {
<div className="flex-1">
<h3 className="font-medium">Enable Payment Methods</h3>
<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>
<div className="flex items-center gap-2 mt-2">
{stripeWorking && testResult?.paymentMethods ? (
@ -170,9 +193,7 @@ export default async function StripeSetupPage() {
{testResult.paymentMethods.total} methods
</Badge>
) : (
<Badge variant="secondary">
Check Stripe Dashboard
</Badge>
<Badge variant="secondary">Check Stripe Dashboard</Badge>
)}
{stripeWorking ? (
<Badge variant="outline" className="text-green-600">
@ -197,7 +218,8 @@ export default async function StripeSetupPage() {
<div className="flex-1">
<h3 className="font-medium">Webhook Configuration</h3>
<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>
<div className="flex items-center gap-2 mt-2">
{process.env.STRIPE_WEBHOOK_SECRET ? (
@ -267,19 +289,29 @@ export default async function StripeSetupPage() {
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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>
<div>
<div className="text-sm text-muted-foreground">Business Name</div>
<div className="font-medium">{testResult.account.name || 'Not set'}</div>
<div className="text-sm text-muted-foreground">
Business Name
</div>
<div className="font-medium">
{testResult.account.name || "Not set"}
</div>
</div>
<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 className="text-sm text-muted-foreground">Charges Enabled</div>
<div className="text-sm text-muted-foreground">
Charges Enabled
</div>
<div className="font-medium">
{testResult.account.charges_enabled ? (
<Badge variant="default">Yes</Badge>
@ -289,7 +321,9 @@ export default async function StripeSetupPage() {
</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">
{testResult.account.payouts_enabled ? (
<Badge variant="default">Yes</Badge>
@ -299,7 +333,9 @@ export default async function StripeSetupPage() {
</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>
</div>
@ -338,12 +374,13 @@ export default async function StripeSetupPage() {
</ol>
</div>
</div>
<div className="pt-4 border-t">
<div className="flex items-center justify-between">
<div>
<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>
</div>
<div className="flex gap-2">
@ -364,7 +401,7 @@ export default async function StripeSetupPage() {
}
export const metadata = {
title: 'Stripe Setup | Rocky Mountain Vending',
description: 'Complete your Stripe integration setup and add products to your store',
title: "Stripe Setup | Rocky Mountain Vending",
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 { TermsAndConditionsPage } from '@/components/terms-and-conditions-page';
import type { Metadata } from 'next';
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { TermsAndConditionsPage } from "@/components/terms-and-conditions-page"
import type { Metadata } from "next"
export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({
title: 'Terms & Conditions | Rocky Mountain Vending',
description: 'Terms and Conditions for Rocky Mountain Vending',
return generateRegistryMetadata("terms", {
robots: {
index: false,
follow: false,
},
});
})
}
export default function TermsAndConditions() {
const structuredData = generateStructuredData({
title: 'Terms & Conditions',
description: 'Terms and Conditions for Rocky Mountain Vending',
url: 'https://rockymountainvending.com/terms-and-conditions/',
type: 'WebPage',
});
const structuredData = generateRegistryStructuredData("terms")
return (
<>
@ -29,13 +22,5 @@ export default function TermsAndConditions() {
/>
<TermsAndConditionsPage />
</>
);
)
}

View file

@ -1,30 +1,33 @@
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 { PublicPageHeader, PublicSurface } from '@/components/public-surface'
import { GetFreeMachineCta } from '@/components/get-free-machine-cta'
import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import type { ImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import type { Metadata } from "next"
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
import { Breadcrumbs } from "@/components/breadcrumbs"
const WORDPRESS_SLUG = 'vending-machines-for-sale-in-utah'
const WORDPRESS_SLUG = "vending-machines-for-sale-in-utah"
export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
return {
title: 'Page Not Found | Rocky Mountain Vending',
title: "Page Not Found | Rocky Mountain Vending",
}
}
return generateSEOMetadata({
title: page.title || 'Vending Machines for Sale in Utah',
description: page.seoDescription || page.excerpt || '',
title: page.title || "Vending Machines for Sale in Utah",
description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt,
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
path: "/vending-machines/machines-for-sale",
})
}
@ -36,7 +39,7 @@ export default async function MachinesForSalePage() {
notFound()
}
let imageMapping: Record<string, string> = {}
let imageMapping: ImageMapping = {}
try {
imageMapping = loadImageMapping()
} catch {
@ -46,44 +49,71 @@ export default async function MachinesForSalePage() {
const structuredData = (() => {
try {
return generateStructuredData({
title: page.title || 'Vending Machines for Sale in Utah',
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || 'https://rockymountainvending.com/vending-machines/machines-for-sale/',
title: page.title || "Vending Machines for Sale in Utah",
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
"https://rockymountainvending.com/vending-machines/machines-for-sale/",
datePublished: page.date,
dateModified: page.modified || page.date,
type: 'WebPage',
type: "WebPage",
})
} catch {
return {
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Vending Machines for Sale in Utah',
description: page.seoDescription || '',
url: 'https://rockymountainvending.com/vending-machines/machines-for-sale/',
"@context": "https://schema.org",
"@type": "WebPage",
headline: page.title || "Vending Machines for Sale in Utah",
description: page.seoDescription || "",
url: "https://rockymountainvending.com/vending-machines/machines-for-sale/",
}
}
})()
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">
<Breadcrumbs
className="mb-6"
items={[
{ label: "Vending Machines", href: "/vending-machines" },
{
label: "Machines for Sale",
href: "/vending-machines/machines-for-sale",
},
]}
/>
<PublicPageHeader
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."
/>
<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>
<section className="mt-12 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<PublicSurface>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">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="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
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">
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>
<div className="mt-6">
<GetFreeMachineCta buttonLabel="Get Free Placement" />
@ -91,10 +121,16 @@ export default async function MachinesForSalePage() {
</PublicSurface>
<PublicSurface className="flex items-center justify-center text-center">
<div className="max-w-xl">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary/80">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="text-sm font-semibold uppercase tracking-[0.18em] text-primary/80">
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">
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>
</div>
</PublicSurface>
@ -103,8 +139,8 @@ export default async function MachinesForSalePage() {
</>
)
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering Machines for Sale page:', error)
if (process.env.NODE_ENV === "development") {
console.error("Error rendering Machines for Sale page:", error)
}
notFound()
}

View file

@ -1,30 +1,47 @@
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'
import { VendingMachinesShowcase } from '@/components/vending-machines-showcase'
import { FeatureCard } from '@/components/feature-card'
import type { Metadata } from 'next'
import { PublicPageHeader, PublicSurface } from '@/components/public-surface'
import { GetFreeMachineCta } from '@/components/get-free-machine-cta'
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { VendingMachinesShowcase } from "@/components/vending-machines-showcase"
import { FeatureCard } from "@/components/feature-card"
import type { Metadata } from "next"
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
import { Breadcrumbs } from "@/components/breadcrumbs"
export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({
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.',
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.",
path: "/vending-machines/machines-we-use",
})
}
export default async function MachinesWeUsePage() {
try {
const structuredData = generateStructuredData({
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.',
url: 'https://rockymountainvending.com/vending-machines/machines-we-use/',
type: 'WebPage',
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.",
url: "https://rockymountainvending.com/vending-machines/machines-we-use/",
type: "WebPage",
})
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">
<Breadcrumbs
className="mb-6"
items={[
{ label: "Vending Machines", href: "/vending-machines" },
{
label: "Machines We Use",
href: "/vending-machines/machines-we-use",
},
]}
/>
<PublicPageHeader
align="center"
eyebrow="Equipment"
@ -61,9 +78,13 @@ export default async function MachinesWeUsePage() {
<section className="mt-12 grid gap-6 lg:grid-cols-2">
<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">
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>
<div className="mt-6 grid gap-6 md:grid-cols-2">
<FeatureCard
@ -85,10 +106,16 @@ export default async function MachinesWeUsePage() {
</div>
</PublicSurface>
<PublicSurface>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">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="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
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">
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>
<div className="mt-6 flex flex-wrap gap-3">
<GetFreeMachineCta buttonLabel="Get Free Placement" />
@ -103,15 +130,19 @@ export default async function MachinesWeUsePage() {
</>
)
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering Machines We Use page:', error)
if (process.env.NODE_ENV === "development") {
console.error("Error rendering Machines We Use page:", error)
}
return (
<div className="container mx-auto px-4 py-10 md:py-14">
<PublicSurface>
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground md:text-5xl">Error Loading Page</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' && (
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground md:text-5xl">
Error Loading Page
</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">
{error instanceof Error ? error.message : String(error)}
</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 type { Metadata } from "next"
export async function generateMetadata(): Promise<Metadata> {
return generateSEOMetadata({
title: "Vending Machines | Rocky Mountain Vending",
return generateRegistryMetadata("vendingMachines", {
description:
"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() {
const structuredData = generateStructuredData({
title: "Vending Machines",
const structuredData = generateRegistryStructuredData("vendingMachines", {
description:
"Compare snack, beverage, and combo vending machines for Utah businesses",
url: "https://rockymountainvending.com/vending-machines/",
type: "WebPage",
"Compare snack, beverage, and combo vending machines for Utah businesses, including payment options, layouts, and placement or purchase paths.",
})
return (

View file

@ -1,80 +1,62 @@
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 { WhoWeServePage } from '@/components/who-we-serve-page';
import type { Metadata } from 'next';
import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { WhoWeServePage } from "@/components/who-we-serve-page"
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> {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
return {
title: 'Page Not Found | Rocky Mountain Vending',
};
title: "Page Not Found | Rocky Mountain Vending",
}
}
return generateSEOMetadata({
title: page.title || 'Warehouse Vending Solutions',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
return generateRegistryMetadata("warehouses", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
});
})
}
export default async function WarehousesPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG);
const page = getPageBySlug(WORDPRESS_SLUG)
if (!page) {
notFound();
notFound()
}
// Load image mapping (optional, won't break if it fails)
let imageMapping: any = {};
let imageMapping: any = {}
try {
imageMapping = loadImageMapping();
imageMapping = loadImageMapping()
} catch (e) {
imageMapping = {};
imageMapping = {}
}
// Clean and render WordPress content as styled React components
const content = page.content ? (
<div className="max-w-none">
{cleanWordPressContent(String(page.content), {
{cleanWordPressContent(String(page.content), {
imageMapping,
pageTitle: page.title
pageTitle: page.title,
})}
</div>
) : (
<p className="text-muted-foreground">No content available.</p>
);
)
// Generate structured data
let structuredData;
try {
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/`,
};
}
const structuredData = generateRegistryStructuredData("warehouses", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
return (
<>
@ -82,14 +64,17 @@ export default async function WarehousesPage() {
type="application/ld+json"
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) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering Warehouses page:', error);
if (process.env.NODE_ENV === "development") {
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