deploy: publish unpublished site updates
This commit is contained in:
parent
d496a58935
commit
96ad13d6a9
333 changed files with 16960 additions and 9789 deletions
|
|
@ -73,8 +73,11 @@ jspm_packages/
|
|||
tmp/
|
||||
temp/
|
||||
.pnpm-store/
|
||||
.formatting-backups/
|
||||
.cursor/
|
||||
.playwright-cli/
|
||||
output/
|
||||
docs/
|
||||
artifacts/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
|
|
|||
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
12
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
15
.prettierignore
Normal 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
5
.prettierrc.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 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 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 && (
|
||||
<>
|
||||
<div className="public-page">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "FAQs", href: "/about/faqs" },
|
||||
]}
|
||||
/>
|
||||
<FAQSchema
|
||||
faqs={faqs}
|
||||
pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`}
|
||||
pageUrl={pageUrl}
|
||||
/>
|
||||
<FAQSection faqs={faqs} />
|
||||
</>
|
||||
<FAQSection faqs={faqs} className="pt-0" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
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 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,
|
||||
|
|
@ -14,9 +20,9 @@ import {
|
|||
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>
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getStripeClient } from '@/lib/stripe/client'
|
||||
import { requireAdminToken } from '@/lib/server/admin-auth'
|
||||
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'
|
||||
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,9 +30,10 @@ 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)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ 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
|
||||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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
|
||||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -17,13 +17,13 @@ export async function POST(request: Request) {
|
|||
// 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
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()
|
||||
|
||||
|
|
@ -16,49 +16,44 @@ async function handler(req: NextRequest) {
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,35 +32,35 @@ 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 {
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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
40
app/robots.txt/route.ts
Normal 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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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="public-page">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Seaga HY 900 Support", href: "/seaga-hy900-support" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-8 text-center">
|
||||
<p className="text-lg mb-6">Please watch the videos to learn more about your HY 900</p>
|
||||
<Button asChild>
|
||||
<Link href="/manuals/Seaga/seaga-hy900-owners-manual.pdf">
|
||||
Owner's Manual
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<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 owner’s manual when you need the reference file."
|
||||
/>
|
||||
|
||||
<div className="mb-12">
|
||||
<div className="aspect-video w-full max-w-4xl mx-auto mb-8">
|
||||
<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'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>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
<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're working on
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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
|
||||
<PublicSurface
|
||||
key={video.id}
|
||||
className="overflow-hidden hover:shadow-lg transition-shadow"
|
||||
className="overflow-hidden p-4 transition-all hover:-translate-y-0.5 hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<div className="aspect-video relative">
|
||||
<div className="relative aspect-video overflow-hidden rounded-[1.5rem] border border-border/60 bg-background">
|
||||
<iframe
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
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 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,
|
||||
|
|
@ -11,14 +17,14 @@ import {
|
|||
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
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -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>
|
||||
|
|
@ -343,7 +379,8 @@ export default async function StripeSetupPage() {
|
|||
<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
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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're a business looking for placement instead of a purchase, we can help you find the right setup for your location.
|
||||
If you're a business looking for placement instead of a
|
||||
purchase, we can help you find the right setup for your
|
||||
location.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<GetFreeMachineCta buttonLabel="Get Free Placement" />
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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're looking for free placement at your business, tell us about your location and we'll help you choose the right mix.
|
||||
If you're looking for free placement at your business, tell
|
||||
us about your location and we'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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -1,46 +1,44 @@
|
|||
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
|
||||
|
|
@ -48,33 +46,17 @@ export default async function WarehousesPage() {
|
|||
<div className="max-w-none">
|
||||
{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
5
artifacts/README.md
Normal 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
Loading…
Reference in a new issue