deploy: unify public surfaces and harden manuals runtime
This commit is contained in:
parent
1cd349e09a
commit
6775ba0e93
37 changed files with 1119 additions and 1216 deletions
|
|
@ -17,6 +17,8 @@ import { FAQSection } from '@/components/faq-section';
|
|||
import { ContactPage } from '@/components/contact-page';
|
||||
import { AboutPage } from '@/components/about-page';
|
||||
import { WhoWeServePage } from '@/components/who-we-serve-page';
|
||||
import { PublicPageHeader, PublicSurface } from '@/components/public-surface';
|
||||
import { RequestMachineForm } from '@/components/forms/request-machine-form';
|
||||
|
||||
// Required for static export - ensures this route is statically generated
|
||||
export const dynamic = 'force-static';
|
||||
|
|
@ -164,30 +166,27 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
|
||||
|
||||
<article className="container mx-auto px-4 py-8 md:py-12">
|
||||
<article className="container mx-auto px-4 py-10 md:py-14">
|
||||
{/* Hero Section */}
|
||||
<header className="mb-12 md:mb-16 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Vending Machine Supplier in {locationData.city}, {locationData.state}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed text-pretty">
|
||||
Need a vending machine supplier in {locationData.city}, {locationData.state}? Rocky Mountain Vending has
|
||||
been helping local businesses and schools since 2019 with quality vending solutions. We bring healthy
|
||||
snacks, cold drinks, and dependable service right to your door—no hassle, no fuss.
|
||||
</p>
|
||||
</header>
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Local Service Area"
|
||||
title={`Vending Machine Supplier in ${locationData.city}, ${locationData.state}`}
|
||||
description={`Need a vending machine supplier in ${locationData.city}, ${locationData.state}? Rocky Mountain Vending has been helping local businesses and schools since 2019 with quality vending solutions. We bring healthy snacks, cold drinks, and dependable service right to your door—no hassle, no fuss.`}
|
||||
className="mb-12 md:mb-16"
|
||||
/>
|
||||
|
||||
{/* Local Anecdote */}
|
||||
<div className="mb-12 max-w-4xl mx-auto">
|
||||
<Card className="border-secondary/20 bg-secondary/5">
|
||||
<CardContent className="p-6 md:p-8">
|
||||
<PublicSurface>
|
||||
<CardContent className="p-0 md:p-1">
|
||||
<p className="text-base md:text-lg leading-relaxed">
|
||||
A while back, we worked with a {locationData.anecdote.customer} near {locationData.anecdote.location}.
|
||||
We set them up with a {locationData.anecdote.solution}. Now {locationData.anecdote.outcome}. That's
|
||||
what we do best—make vending simple.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
|
||||
{/* Services Section */}
|
||||
|
|
@ -198,7 +197,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</p>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<Card className="rounded-[1.75rem] border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.9))] shadow-[0_18px_45px_rgba(0,0,0,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-semibold mb-3">Vending Machine Sales</h3>
|
||||
<p className="text-muted-foreground">
|
||||
|
|
@ -208,7 +207,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<Card className="rounded-[1.75rem] border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.9))] shadow-[0_18px_45px_rgba(0,0,0,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-semibold mb-3">Vending Machine Repair</h3>
|
||||
<p className="text-muted-foreground">
|
||||
|
|
@ -218,7 +217,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<Card className="rounded-[1.75rem] border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.9))] shadow-[0_18px_45px_rgba(0,0,0,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-semibold mb-3">Healthy Snack and Beverage Options</h3>
|
||||
<p className="text-muted-foreground">
|
||||
|
|
@ -228,7 +227,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<Card className="rounded-[1.75rem] border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.9))] shadow-[0_18px_45px_rgba(0,0,0,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-semibold mb-3">Maintenance Services</h3>
|
||||
<p className="text-muted-foreground">
|
||||
|
|
@ -305,7 +304,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<Card className="rounded-[1.75rem] border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.9))] shadow-[0_18px_45px_rgba(0,0,0,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
|
||||
<CardContent className="p-6 flex items-start gap-4">
|
||||
<Phone className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
|
|
@ -317,7 +316,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<Card className="rounded-[1.75rem] border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.9))] shadow-[0_18px_45px_rgba(0,0,0,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
|
||||
<CardContent className="p-6 flex items-start gap-4">
|
||||
<Mail className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
|
|
@ -329,7 +328,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<Card className="rounded-[1.75rem] border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.9))] shadow-[0_18px_45px_rgba(0,0,0,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
|
||||
<CardContent className="p-6 flex items-start gap-4">
|
||||
<Globe className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
|
|
@ -348,39 +347,23 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</div>
|
||||
|
||||
{/* CRM Form */}
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-8">
|
||||
<PublicSurface>
|
||||
<CardContent className="p-1">
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-2xl font-bold mb-2">Get Your Free Vending Machine</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Fill out the form below and we'll contact you within 24 hours to discuss your needs.
|
||||
Tell us about your location and we'll follow up within one business day.
|
||||
</p>
|
||||
</div>
|
||||
<iframe
|
||||
src="https://link.sluice-box.io/widget/form/T76mIdPvC5iBwAI2wFPg"
|
||||
style={{ width: "100%", height: "650px", border: "none", borderRadius: "4px" }}
|
||||
id="inline-T76mIdPvC5iBwAI2wFPg"
|
||||
data-layout="{'id':'INLINE'}"
|
||||
data-trigger-type="alwaysShow"
|
||||
data-trigger-value=""
|
||||
data-activation-type="alwaysActivated"
|
||||
data-activation-value=""
|
||||
data-deactivation-type="neverDeactivate"
|
||||
data-deactivation-value=""
|
||||
data-form-name="Request Machine Short"
|
||||
data-height="638"
|
||||
data-layout-iframe-id="inline-T76mIdPvC5iBwAI2wFPg"
|
||||
data-form-id="T76mIdPvC5iBwAI2wFPg"
|
||||
title="Request Free Vending Machine Form"
|
||||
/>
|
||||
<RequestMachineForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
{/* Payment Options */}
|
||||
<section className="mb-16 max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold mb-6 tracking-tight text-balance">{locationData.h2Variants.payments}</h2>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<PublicSurface>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<CreditCard className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
|
||||
|
|
@ -401,7 +384,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
{/* Why Choose Us */}
|
||||
|
|
@ -421,7 +404,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
|
|||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button asChild size="lg" className="bg-primary hover:bg-primary/90">
|
||||
<Button asChild size="lg" className="rounded-full bg-primary px-6 hover:bg-primary/90">
|
||||
<Link href="#contact">Get Your Free Machine Today</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -737,4 +720,3 @@ export default async function WordPressPage({ params }: PageProps) {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||
import { readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { getManualsFilesRoot } from '@/lib/manuals-paths'
|
||||
|
||||
// API routes are not supported in static export (GHL hosting)
|
||||
// Manuals are now served as static files from /manuals/
|
||||
|
|
@ -49,7 +50,7 @@ export async function GET(
|
|||
}
|
||||
|
||||
// Construct full path to manual
|
||||
const manualsDir = join(process.cwd(), '..', 'manuals-data', 'manuals')
|
||||
const manualsDir = getManualsFilesRoot()
|
||||
const fullPath = join(manualsDir, filePath)
|
||||
|
||||
// Normalize paths to handle both forward and backward slashes
|
||||
|
|
@ -111,4 +112,3 @@ export async function GET(
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { getManualsThumbnailsRoot } from '@/lib/manuals-paths'
|
||||
|
||||
// API routes are not supported in static export (GHL hosting)
|
||||
// Thumbnails are now served as static files from /thumbnails/
|
||||
|
|
@ -49,7 +50,7 @@ export async function GET(
|
|||
}
|
||||
|
||||
// Construct full path to thumbnail
|
||||
const thumbnailsDir = join(process.cwd(), '..', 'thumbnails')
|
||||
const thumbnailsDir = getManualsThumbnailsRoot()
|
||||
const fullPath = join(thumbnailsDir, filePath)
|
||||
|
||||
// Normalize paths to handle both forward and backward slashes
|
||||
|
|
@ -115,4 +116,3 @@ export async function GET(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,17 @@ 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 { PublicPageHeader, PublicSurface } from '@/components/public-surface';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const WORDPRESS_SLUG = 'best-vending-machine-supplier-in-salt-lake-city-utah';
|
||||
const DISPLAY_TITLE = 'The Best Vending Machine Supplier in Salt Lake City, Utah';
|
||||
const DISPLAY_DESCRIPTION =
|
||||
'A closer look at how Rocky Mountain Vending supports Utah businesses with free placement, machine sales, repairs, manuals, and responsive local service.';
|
||||
|
||||
function stripLeadingH1(html: string) {
|
||||
return html.replace(/<h1[^>]*>[\s\S]*?<\/h1>/i, '');
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
|
|
@ -18,7 +26,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
}
|
||||
|
||||
return generateSEOMetadata({
|
||||
title: page.title || 'Best Vending Machine Supplier in Salt Lake City',
|
||||
title: DISPLAY_TITLE,
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
date: page.date,
|
||||
|
|
@ -44,9 +52,10 @@ export default async function BestVendingMachineSupplierPage() {
|
|||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(stripLeadingH1(String(page.content)), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: DISPLAY_TITLE,
|
||||
prioritizeFirstImage: true,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -56,7 +65,7 @@ export default async function BestVendingMachineSupplierPage() {
|
|||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Best Vending Machine Supplier in Salt Lake City',
|
||||
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/`,
|
||||
datePublished: page.date,
|
||||
|
|
@ -67,7 +76,7 @@ export default async function BestVendingMachineSupplierPage() {
|
|||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Best Vending Machine Supplier in Salt Lake City',
|
||||
headline: DISPLAY_TITLE,
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/best-vending-machine-supplier-in-salt-lake-city-utah/`,
|
||||
};
|
||||
|
|
@ -80,13 +89,23 @@ export default async function BestVendingMachineSupplierPage() {
|
|||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<Breadcrumbs
|
||||
className="container mx-auto max-w-6xl px-4 pt-6"
|
||||
items={[
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: page.title || 'Best Vending Machine Supplier', href: '/blog/best-vending-machine-supplier-in-salt-lake-city-utah' },
|
||||
{ label: DISPLAY_TITLE, href: '/blog/best-vending-machine-supplier-in-salt-lake-city-utah' },
|
||||
]}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
{content}
|
||||
<article className="container mx-auto max-w-6xl px-4 py-8 md:py-12">
|
||||
<PublicSurface className="space-y-8 md:space-y-10">
|
||||
<PublicPageHeader
|
||||
eyebrow="Rocky Journal"
|
||||
title={DISPLAY_TITLE}
|
||||
description={DISPLAY_DESCRIPTION}
|
||||
/>
|
||||
<div className="prose prose-stone max-w-none prose-headings:font-semibold prose-headings:text-foreground prose-p:text-muted-foreground prose-li:text-muted-foreground">
|
||||
{content}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs'
|
||||
import { PublicInset, PublicPageHeader, PublicSurface } from '@/components/public-surface'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -46,21 +47,21 @@ export default function BlogPage() {
|
|||
{ label: 'Blog', href: '/blog' },
|
||||
]}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-5xl">
|
||||
<header className="mb-12 md:mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Blog
|
||||
</h1>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[var(--link-hover-color)] to-[var(--link-hover-color-dark)] mx-auto rounded-full" />
|
||||
</header>
|
||||
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Rocky Updates"
|
||||
title="Blog"
|
||||
description="Guides, reviews, and local Utah vending insights from the Rocky Mountain Vending team."
|
||||
/>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="mt-10 space-y-6">
|
||||
{blogPosts.map((post) => (
|
||||
<article key={post.slug} className="border-b border-border pb-8 last:border-b-0 last:pb-0">
|
||||
<PublicSurface key={post.slug} as="article" className="p-5 md:p-6">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="md:w-1/3">
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-muted">
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-[1.5rem] bg-muted">
|
||||
<Image
|
||||
src={post.image}
|
||||
alt={post.imageAlt}
|
||||
|
|
@ -73,20 +74,20 @@ export default function BlogPage() {
|
|||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-2 tracking-tight text-balance">
|
||||
<Link href={`/blog/${post.slug}`} className="transition-colors hover:text-[var(--link-hover-color)]">
|
||||
<Link href={`/blog/${post.slug}`} className="transition-colors hover:text-primary">
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4 leading-relaxed text-base md:text-lg">
|
||||
{post.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||
<PublicInset className="mb-4 inline-flex items-center gap-4 rounded-full px-4 py-2 text-sm text-muted-foreground shadow-none">
|
||||
<time>{post.date}</time>
|
||||
</div>
|
||||
</PublicInset>
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="inline-flex items-center gap-2 text-[var(--link-hover-color)] hover:text-[var(--link-hover-color-dark)] font-medium transition-colors"
|
||||
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
Read more
|
||||
<svg
|
||||
|
|
@ -100,13 +101,14 @@ export default function BlogPage() {
|
|||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m5 12 7 7 7-7" />
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</PublicSurface>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
|
@ -79,16 +80,19 @@ export default async function ManualsDashboardPage() {
|
|||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Manual Processing Dashboard
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-3xl text-pretty leading-relaxed">
|
||||
Comprehensive overview of processed manual data, statistics, gap analysis, and optimization results.
|
||||
</p>
|
||||
</header>
|
||||
<PublicPageHeader
|
||||
eyebrow="Manual Operations"
|
||||
title="Manual Processing Dashboard"
|
||||
description="Operational overview of the manuals catalog, processing metadata, and gap analysis."
|
||||
>
|
||||
<PublicInset className="inline-flex w-fit rounded-full px-4 py-2 text-sm text-muted-foreground shadow-none">
|
||||
Admin-style data view for manual operations.
|
||||
</PublicInset>
|
||||
</PublicPageHeader>
|
||||
|
||||
<ManualsDashboardClient data={data} />
|
||||
<div className="mt-8">
|
||||
<ManualsDashboardClient data={data} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -96,4 +100,3 @@ export default async function ManualsDashboardPage() {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Metadata } from 'next'
|
||||
import { PublicInset, PublicPageHeader } from '@/components/public-surface'
|
||||
import { businessConfig } from '@/lib/seo-config'
|
||||
import { ManualsPageShell } from '@/components/manuals-page-shell'
|
||||
import { listConvexManuals } from '@/lib/convex-service'
|
||||
|
|
@ -19,6 +22,7 @@ import {
|
|||
shouldIncludePaymentComponents,
|
||||
} from '@/lib/site-config'
|
||||
import { generateStructuredData } from '@/lib/seo'
|
||||
import { getManualsThumbnailsRoot } from '@/lib/manuals-paths'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending',
|
||||
|
|
@ -105,6 +109,22 @@ export default async function ManualsPage() {
|
|||
|
||||
// 3. Filter by minimum manual count per manufacturer (new)
|
||||
manuals = filterManufacturersByMinCount(manuals, minManualCount)
|
||||
|
||||
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
|
||||
const thumbnailsRoot = getManualsThumbnailsRoot()
|
||||
manuals = manuals.map((manual) => {
|
||||
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
|
||||
return manual
|
||||
}
|
||||
|
||||
const relativeThumbnailPath = manual.thumbnailUrl.includes('/thumbnails/')
|
||||
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, '')
|
||||
: manual.thumbnailUrl
|
||||
|
||||
return existsSync(join(thumbnailsRoot, relativeThumbnailPath))
|
||||
? manual
|
||||
: { ...manual, thumbnailUrl: undefined }
|
||||
})
|
||||
|
||||
// 4. Group and get unique lists
|
||||
const groupedManuals = groupManuals(manuals)
|
||||
|
|
@ -155,27 +175,26 @@ export default async function ManualsPage() {
|
|||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Vending Machine Manuals
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-3xl text-pretty leading-relaxed">
|
||||
Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find comprehensive service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. All manuals are organized by manufacturer and machine type, with many featuring available replacement parts with direct purchase links.
|
||||
</p>
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>{manuals.length}</strong> manuals available from{' '}
|
||||
<strong>{manufacturers.length}</strong> manufacturers
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<PublicPageHeader
|
||||
eyebrow="Manual Library"
|
||||
title="Vending Machine Manuals"
|
||||
description="Download manuals, service guides, and parts documentation for hundreds of vending machine models. Browse by manufacturer, machine type, or search by model details."
|
||||
>
|
||||
<PublicInset className="inline-flex w-fit items-center gap-2 rounded-full px-4 py-2 text-sm text-muted-foreground shadow-none">
|
||||
<span>
|
||||
<strong>{manuals.length}</strong> manuals available from <strong>{manufacturers.length}</strong> manufacturers
|
||||
</span>
|
||||
</PublicInset>
|
||||
</PublicPageHeader>
|
||||
|
||||
<ManualsPageShell
|
||||
<div className="mt-8 md:mt-10">
|
||||
<ManualsPageShell
|
||||
manuals={manuals}
|
||||
groupedManuals={groupedManuals}
|
||||
manufacturers={manufacturers}
|
||||
categories={categories}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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'
|
||||
|
||||
|
|
@ -8,16 +9,15 @@ export const metadata = {
|
|||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
let products = []
|
||||
let products: Awaited<ReturnType<typeof fetchAllProducts>> = []
|
||||
let error: string | null = null
|
||||
|
||||
try {
|
||||
products = await fetchAllProducts()
|
||||
} catch (err) {
|
||||
console.error('Error fetching products:', err)
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('STRIPE_SECRET_KEY')) {
|
||||
error = 'Stripe configuration error. Please check environment variables.'
|
||||
error = 'Our product catalog is temporarily unavailable. Please contact us for current machine options.'
|
||||
} else {
|
||||
error = 'Failed to load products. Please try again later.'
|
||||
}
|
||||
|
|
@ -27,33 +27,32 @@ export default async function ProductsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
<div className="text-center mb-12 md:mb-16 relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
<Link
|
||||
href="/stripe-setup"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Machine Sales"
|
||||
title="Browse Rocky Mountain Vending equipment and machine options."
|
||||
description="Explore machines, payment hardware, and vending equipment. If you need help choosing the right setup, we can talk through new, used, and feature-specific options."
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
Admin: Setup Guide
|
||||
Ask About Sales
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance">
|
||||
Our Products
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
|
||||
Browse our selection of vending machines and equipment
|
||||
</p>
|
||||
</div>
|
||||
</PublicPageHeader>
|
||||
|
||||
{error ? (
|
||||
<div className="text-center py-12">
|
||||
<PublicSurface className="mt-10 text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<PublicSurface className="mt-10 text-center">
|
||||
<div className="mb-6">
|
||||
<p className="text-lg font-medium mb-2">
|
||||
No products available yet
|
||||
|
|
@ -62,15 +61,16 @@ export default async function ProductsPage() {
|
|||
Our product catalog is being prepared. Please check back soon or contact us directly for current offerings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-md mx-auto text-sm text-muted-foreground bg-muted/50 p-4 rounded-lg">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</PublicInset>
|
||||
</PublicSurface>
|
||||
) : (
|
||||
<ProductGrid products={products} />
|
||||
<div className="mt-10">
|
||||
<ProductGrid products={products} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getAllLocations } from "@/lib/location-data";
|
||||
import { businessConfig } from "@/lib/seo-config";
|
||||
import { MapPin, Phone, ArrowRight, Wrench, Clock } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import { getAllLocations } from "@/lib/location-data"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { MapPin, Phone, ArrowRight, Wrench, Clock } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PublicInset, PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Service Areas | Vending Machines Across Utah | Rocky Mountain Vending",
|
||||
|
|
@ -29,13 +29,44 @@ export const metadata: Metadata = {
|
|||
alternates: {
|
||||
canonical: `${businessConfig.website}/service-areas`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function LocationCard({
|
||||
city,
|
||||
zipCode,
|
||||
href,
|
||||
neighborhoods,
|
||||
}: {
|
||||
city: string
|
||||
zipCode: string
|
||||
href: string
|
||||
neighborhoods: string[]
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="group block h-full">
|
||||
<PublicSurface className="h-full p-5 transition-all hover:-translate-y-0.5 hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)] md:p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xl font-semibold text-foreground transition-colors group-hover:text-primary">{city}</h3>
|
||||
<p className="text-sm text-muted-foreground">{zipCode}</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-border/60 bg-background/90 p-2 text-muted-foreground transition-all group-hover:border-primary/30 group-hover:text-primary group-hover:translate-x-0.5">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<PublicInset className="mt-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/75">Popular Areas</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{neighborhoods.slice(0, 2).join(", ")}</p>
|
||||
</PublicInset>
|
||||
</PublicSurface>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ServiceAreasPage() {
|
||||
const locations = getAllLocations();
|
||||
const state = "Utah";
|
||||
const locations = getAllLocations()
|
||||
const state = "Utah"
|
||||
|
||||
// Group locations by county (approximate)
|
||||
const saltLakeCounty = locations.filter((loc) =>
|
||||
[
|
||||
"salt-lake-city-utah",
|
||||
|
|
@ -52,287 +83,191 @@ export default function ServiceAreasPage() {
|
|||
"holladay-utah",
|
||||
"millcreek-utah",
|
||||
"cottonwood-heights-utah",
|
||||
].includes(loc.slug)
|
||||
);
|
||||
].includes(loc.slug),
|
||||
)
|
||||
|
||||
const davisCounty = locations.filter((loc) =>
|
||||
["ogden-utah", "layton-utah", "clearfield-utah", "syracuse-utah", "clinton-utah"].includes(loc.slug)
|
||||
);
|
||||
["ogden-utah", "layton-utah", "clearfield-utah", "syracuse-utah", "clinton-utah"].includes(loc.slug),
|
||||
)
|
||||
|
||||
const utahCounty = locations.filter((loc) => ["provo-utah"].includes(loc.slug));
|
||||
const utahCounty = locations.filter((loc) => ["provo-utah"].includes(loc.slug))
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
{/* Hero Section */}
|
||||
<header className="text-center mb-12 md:mb-16">
|
||||
<div className="inline-flex items-center justify-center gap-2 mb-4">
|
||||
<MapPin className="h-8 w-8 text-secondary" />
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">Our Service Areas</h1>
|
||||
</div>
|
||||
<p className="text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed text-pretty">
|
||||
Rocky Mountain Vending proudly serves businesses and schools across 20+ cities in Utah. We provide FREE
|
||||
vending machines with no-cost installation and delivery within 50 miles of most service areas.
|
||||
</p>
|
||||
</header>
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Service Coverage"
|
||||
title="Utah locations we serve with vending, repairs, parts, and machine support."
|
||||
description="Rocky Mountain Vending supports businesses and schools across Utah with free placement, dependable service, and fast local follow-up. Browse the cities we cover and reach out if your location is nearby but not listed yet."
|
||||
/>
|
||||
|
||||
{/* Map Section */}
|
||||
<div className="mb-16 max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="relative aspect-[926/1024] rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src="/images/rocky-mountain-vending-service-area-926x1024.webp"
|
||||
alt="Rocky Mountain Vending service area map covering Salt Lake City, Ogden, Provo and surrounding Utah cities"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<PublicSurface>
|
||||
<div className="relative aspect-[926/1024] overflow-hidden rounded-[1.5rem] bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.16),transparent_55%),linear-gradient(180deg,rgba(247,244,236,0.7),rgba(255,255,255,0.96))]">
|
||||
<Image
|
||||
src="/images/rocky-mountain-vending-service-area-926x1024.webp"
|
||||
alt="Rocky Mountain Vending service area map covering Salt Lake City, Ogden, Provo and surrounding Utah cities"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
|
||||
{/* Quick Contact */}
|
||||
<div className="mb-16 max-w-4xl mx-auto">
|
||||
<Card className="border-secondary/20 bg-secondary/5">
|
||||
<CardContent className="p-8 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Don't See Your City?</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
We may still be able to serve you! Give us a call to check if we can deliver to your location.
|
||||
<PublicSurface className="flex flex-col justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Need Coverage Confirmation?</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance text-foreground">
|
||||
Don't see your city yet?
|
||||
</h2>
|
||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
We may still be able to help. If you're near one of these service zones, call or send us a request and we'll confirm the best next step.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<PublicInset>
|
||||
<a
|
||||
href={businessConfig.phoneUrl}
|
||||
className="flex items-center gap-2 text-lg font-semibold hover:underline"
|
||||
className="flex items-center gap-3 text-base font-semibold text-foreground transition hover:text-primary"
|
||||
>
|
||||
<Phone className="h-5 w-5" />
|
||||
<Phone className="h-5 w-5 text-primary" />
|
||||
{businessConfig.phone}
|
||||
</a>
|
||||
<span className="text-muted-foreground">or</span>
|
||||
<Button asChild className="bg-secondary hover:bg-secondary/90">
|
||||
<Link href="#contact">Request a Free Machine</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="mt-2 text-sm text-muted-foreground">We'll confirm delivery range, support availability, and the best intake path for your location.</p>
|
||||
</PublicInset>
|
||||
<Button asChild className="h-11 rounded-full px-5">
|
||||
<Link href="/#request-machine">Request a Free Machine</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
|
||||
{/* Salt Lake County */}
|
||||
|
||||
{/* Our 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">Our Vending Machine Services</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed mb-8">
|
||||
Serving {state} with comprehensive vending machine solutions
|
||||
</p>
|
||||
<section className="mt-12">
|
||||
<PublicSurface>
|
||||
<div className="text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Core Services</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance md:text-4xl">Our vending machine services across {state}</h2>
|
||||
<p className="mx-auto mt-3 max-w-2xl text-base leading-relaxed text-muted-foreground">
|
||||
From free placement and machine sales to repairs, moving, and parts help, we keep one consistent service experience across every city we serve.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Repairs",
|
||||
body: "Expert repair and maintenance for snack, beverage, food, and combo machines.",
|
||||
href: "/services/repairs",
|
||||
cta: "Learn more",
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: "Parts",
|
||||
body: "Replacement parts, manuals, and support for major vending machine brands.",
|
||||
href: "/services/parts",
|
||||
cta: "Shop parts",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: "Moving",
|
||||
body: "Professional machine moving for vending machines and related equipment across Utah.",
|
||||
href: "/services/moving",
|
||||
cta: "Moving services",
|
||||
},
|
||||
].map((service) => (
|
||||
<PublicInset key={service.title} className="h-full p-5 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<service.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-semibold">{service.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{service.body}</p>
|
||||
<Link href={service.href} className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline">
|
||||
{service.cta}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
<section className="mt-12 space-y-12">
|
||||
{[
|
||||
{
|
||||
title: "Salt Lake County",
|
||||
description: "Serving the heart of Utah's business district with comprehensive vending solutions.",
|
||||
items: saltLakeCounty,
|
||||
},
|
||||
{
|
||||
title: "Davis County",
|
||||
description: "Supporting businesses from Ogden to Layton with reliable vending service and repairs.",
|
||||
items: davisCounty,
|
||||
},
|
||||
{
|
||||
title: "Utah County",
|
||||
description: "Delivering quality vending solutions to Provo and surrounding areas.",
|
||||
items: utahCounty,
|
||||
},
|
||||
].map((section) => (
|
||||
<div key={section.title} className="space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Coverage Area</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-balance">{section.title}</h2>
|
||||
<p className="mt-2 max-w-3xl text-base leading-relaxed text-muted-foreground">{section.description}</p>
|
||||
</div>
|
||||
<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">
|
||||
<Wrench className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-3">Vending Machine <Link href="/services/repairs" className="text-primary hover:underline font-semibold">Repairs</Link></h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Expert repair and maintenance services for all vending machine types
|
||||
</p>
|
||||
<Link href="/services/repairs" className="inline-flex items-center gap-2 text-primary hover:underline font-medium">
|
||||
Learn More <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="pt-8 text-center">
|
||||
<MapPin className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-3">Vending Machine <Link href="/services/parts" className="text-primary hover:underline font-semibold">Parts</Link></h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Quality replacement parts for all major vending machine brands
|
||||
</p>
|
||||
<Link href="/services/parts" className="inline-flex items-center gap-2 text-primary hover:underline font-medium">
|
||||
Shop Parts <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="pt-8 text-center">
|
||||
<Clock className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-3">Vending Machine <Link href="/services/moving" className="text-primary hover:underline font-semibold">Moving</Link></h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Professional vending machine relocation services in {state}
|
||||
</p>
|
||||
<Link href="/services/moving" className="inline-flex items-center gap-2 text-primary hover:underline font-medium">
|
||||
Moving Services <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{section.items.map((location) => (
|
||||
<LocationCard
|
||||
key={location.slug}
|
||||
city={location.city}
|
||||
zipCode={location.zipCode}
|
||||
href={`/vending-machines-${location.slug}`}
|
||||
neighborhoods={location.neighborhoods}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
{/* Salt Lake County */}
|
||||
<section className="mb-16">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2 tracking-tight text-balance">Salt Lake County</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Serving the heart of Utah's business district with comprehensive vending solutions
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{saltLakeCounty.map((location) => (
|
||||
<Link key={location.slug} href={`/vending-machines-${location.slug}`}>
|
||||
<Card className="h-full hover:border-secondary/50 transition-colors group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold group-hover:text-secondary transition-colors">
|
||||
{location.city}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{location.zipCode}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground group-hover:text-secondary group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p className="font-medium">Neighborhoods:</p>
|
||||
<p>{location.neighborhoods.slice(0, 2).join(", ")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Davis County */}
|
||||
<section className="mb-16">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2 tracking-tight text-balance">Davis County</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Supporting businesses from Ogden to Layton with reliable vending services
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{davisCounty.map((location) => (
|
||||
<Link key={location.slug} href={`/vending-machines-${location.slug}`}>
|
||||
<Card className="h-full hover:border-secondary/50 transition-colors group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold group-hover:text-secondary transition-colors">
|
||||
{location.city}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{location.zipCode}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground group-hover:text-secondary group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p className="font-medium">Neighborhoods:</p>
|
||||
<p>{location.neighborhoods.slice(0, 2).join(", ")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<PublicSurface>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">Why businesses choose Rocky Mountain Vending</h2>
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
{[
|
||||
["FREE vending machines", "No upfront costs, hidden fees, or machine charges for qualifying businesses."],
|
||||
["FREE delivery and installation", "Within range of our service areas, we handle setup and launch for you."],
|
||||
["FREE maintenance and repairs", "We keep machines running smoothly so your team has fewer interruptions."],
|
||||
["Healthy and traditional options", "We tailor product mix to the people using the machines every day."],
|
||||
].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>
|
||||
<Button asChild className="mt-6 h-11 rounded-full px-5">
|
||||
<Link href="/#request-machine">Get Your Free Machine</Link>
|
||||
</Button>
|
||||
</PublicSurface>
|
||||
|
||||
{/* Utah County */}
|
||||
<section className="mb-16">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2 tracking-tight text-balance">Utah County</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Delivering quality vending solutions to Provo and surrounding areas
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{utahCounty.map((location) => (
|
||||
<Link key={location.slug} href={`/vending-machines-${location.slug}`}>
|
||||
<Card className="h-full hover:border-secondary/50 transition-colors group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold group-hover:text-secondary transition-colors">
|
||||
{location.city}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{location.zipCode}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground group-hover:text-secondary group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p className="font-medium">Neighborhoods:</p>
|
||||
<p>{location.neighborhoods.slice(0, 2).join(", ")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why Choose Us Section */}
|
||||
<section className="mb-16 max-w-4xl mx-auto">
|
||||
<Card className="border-secondary/20">
|
||||
<CardContent className="p-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-balance mb-4 text-center">Why Choose Rocky Mountain Vending?</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">✓ FREE Vending Machines</h3>
|
||||
<p className="text-muted-foreground">No upfront costs, no hidden fees. We provide the machine at no charge.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">✓ FREE Delivery & Installation</h3>
|
||||
<p className="text-muted-foreground">Within 50 miles of most service areas. We handle everything.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">✓ FREE Maintenance & Repairs</h3>
|
||||
<p className="text-muted-foreground">We keep your machine running smoothly at no cost to you.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">✓ Healthy & Traditional Options</h3>
|
||||
<p className="text-muted-foreground">Stock your machine with whatever your team prefers.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 text-center">
|
||||
<Button asChild size="lg" className="bg-primary hover:bg-primary/90">
|
||||
<Link href="/#contact">Get Your Free Machine Today</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Service Details */}
|
||||
<section className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-balance mb-4 text-center">What We Offer in Every Service Area</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-4">🏢</div>
|
||||
<h3 className="font-semibold mb-2">For Businesses</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Offices, warehouses, auto shops, and more. Keep your team energized and productive.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-4">🏫</div>
|
||||
<h3 className="font-semibold mb-2">For Schools</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Healthy options for students and staff. Easy management, no hassle.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-4">💪</div>
|
||||
<h3 className="font-semibold mb-2">For Gyms & Fitness</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Protein bars, healthy snacks, and sports drinks. Perfect post-workout fuel.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<PublicSurface>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">What we support in every service area</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
{[
|
||||
["For businesses", "Offices, warehouses, auto shops, and other workplaces that want reliable on-site vending."],
|
||||
["For schools", "Healthy options for students and staff with a service plan that stays easy to manage."],
|
||||
["For gyms and fitness spaces", "Protein bars, sports drinks, and post-workout options that match the location."],
|
||||
].map(([title, body]) => (
|
||||
<PublicInset key={title}>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{body}</p>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
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 { 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 { RequestMachineForm } from '@/components/forms/request-machine-form'
|
||||
|
||||
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);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
title: 'Page Not Found | Rocky Mountain Vending',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return generateSEOMetadata({
|
||||
|
|
@ -24,114 +25,78 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function MachinesForSalePage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: Record<string, string> = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = loadImageMapping()
|
||||
} catch {
|
||||
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 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',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@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/`,
|
||||
};
|
||||
}
|
||||
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/',
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
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/',
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Vending Machines for Sale in Utah'}</h1>
|
||||
</header>
|
||||
{content}
|
||||
</article>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<PublicPageHeader
|
||||
eyebrow="Machine Sales"
|
||||
title={page.title || 'Vending Machines for Sale in Utah'}
|
||||
description="If you need help comparing machine options, payment hardware, or support paths, this page now keeps the same clean Rocky Mountain Vending styling as the rest of the site."
|
||||
/>
|
||||
|
||||
{/* CRM Form */}
|
||||
<section className="mt-16">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-2xl font-bold mb-2">Get Your Free Vending Machine</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Fill out the form below and we'll contact you within 24 hours to discuss your needs.
|
||||
</p>
|
||||
</div>
|
||||
<iframe
|
||||
src="https://link.sluice-box.io/widget/form/T76mIdPvC5iBwAI2wFPg"
|
||||
style={{ width: "100%", height: "650px", border: "none", borderRadius: "4px" }}
|
||||
id="inline-T76mIdPvC5iBwAI2wFPg"
|
||||
data-layout="{'id':'INLINE'}"
|
||||
data-trigger-type="alwaysShow"
|
||||
data-trigger-value=""
|
||||
data-activation-type="alwaysActivated"
|
||||
data-activation-value=""
|
||||
data-deactivation-type="neverDeactivate"
|
||||
data-deactivation-value=""
|
||||
data-form-name="Request Machine Short"
|
||||
data-height="638"
|
||||
data-layout-iframe-id="inline-T76mIdPvC5iBwAI2wFPg"
|
||||
data-form-id="T76mIdPvC5iBwAI2wFPg"
|
||||
title="Request Free Vending Machine Form"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<PublicSurface className="mt-10">
|
||||
<div className="max-w-none">{cleanWordPressContent(String(page.content || ''), { imageMapping, pageTitle: page.title })}</div>
|
||||
</PublicSurface>
|
||||
|
||||
{/* Form Script */}
|
||||
<Script src="https://link.sluice-box.io/js/form_embed.js" strategy="afterInteractive" />
|
||||
<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="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
If you're a business looking for placement rather than a purchase, use the request form here and we'll help you sort out the right next step.
|
||||
</p>
|
||||
</PublicSurface>
|
||||
<PublicSurface>
|
||||
<RequestMachineForm />
|
||||
</PublicSurface>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Machines for Sale page:', error);
|
||||
console.error('Error rendering Machines for Sale page:', error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,151 +1,123 @@
|
|||
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 { 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 { RequestMachineForm } from '@/components/forms/request-machine-form'
|
||||
|
||||
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.',
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function MachinesWeUsePage() {
|
||||
try {
|
||||
// Generate structured data
|
||||
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',
|
||||
});
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-6xl">
|
||||
<header className="mb-12 md:mb-16 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">Vending Machines</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
|
||||
Only The Best for Your Location
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-16">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Equipment"
|
||||
title="Machines and hardware we trust in the field."
|
||||
description="This page now shares the same warm Rocky Mountain Vending styling as the rest of the public site while keeping the product and equipment details intact."
|
||||
/>
|
||||
|
||||
<section className="mt-10 grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<FeatureCard
|
||||
image="https://rockymountainvending.com/wp-content/uploads/2024/01/Parlevel-Pay-Plus-247x300.jpg"
|
||||
alt="Parlevel PayPlus credit card reader screen showing options for inserting, swiping, or tapping a card for payment."
|
||||
title="Credit Card Readers"
|
||||
description="Enhanced Payment Flexibility: Our vending machines are equipped with advanced NAYAX and Parlevel credit card readers, seamlessly integrated with Parlevel's Vending Management System (VMS). These cutting-edge readers use EMV chip technology to enhance transaction security, offering users greater peace of mind. Our system also streamlines inventory management, quickly resolves machine issues, and allows for the use of personalized pre-paid gift cards. Parlevel's VMS, backed by robust management tools and dependable hardware, is essential to our commitment to offering secure and varied payment options while maintaining the highest operational standards."
|
||||
description="Our machines use modern NAYAX and Parlevel readers with EMV chip technology, better security, and stronger visibility into machine health and inventory."
|
||||
imageWidth={247}
|
||||
imageHeight={300}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
image="https://rockymountainvending.com/wp-content/uploads/2024/10/Drop-Sensors-300x225.webp"
|
||||
alt="Illustration of a vending machine drop sensor ensuring successful item delivery."
|
||||
title="Guaranteed Delivery"
|
||||
description="Purchase with Confidence: Our vending machines are equipped with highly sensitive sensors that ensure you receive the item you select. In the rare case that a product isn't dispensed, the machine is designed either to prevent the charge or to issue a prompt refund. This system demonstrates our commitment to providing a reliable and customer-friendly vending experience, ensuring you can shop with complete peace of mind."
|
||||
description="Sensitive drop sensors help make sure customers either receive the item they selected or the charge is reversed appropriately."
|
||||
imageWidth={300}
|
||||
imageHeight={225}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
image="https://rockymountainvending.com/wp-content/uploads/2024/10/Cash-and-Coins-Accepted-300x225.webp"
|
||||
alt="Image showing U.S. dollar bills and coins, symbolizing cash and coin payments accepted by vending machines."
|
||||
title="Cash and Coin"
|
||||
description="Cash-Friendly Vending Options: Our vending machines are built to support both cash and coin payments, providing a convenient and flexible option for all customers. With the ability to accept multiple U.S. dollar denominations, they ensure smooth and hassle-free transactions. Whether you prefer using bills or coins, our vending machines are designed with your convenience in mind. This feature highlights our dedication to offering a user-friendly and accessible vending experience for everyone."
|
||||
description="We keep cash and coin acceptance available for locations where that still matters, while also supporting newer payment options."
|
||||
imageWidth={300}
|
||||
imageHeight={225}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Specialty Equipment Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-8 tracking-tight text-balance">Specialty Equipment</h2>
|
||||
<p className="text-muted-foreground text-center mb-8 leading-relaxed max-w-3xl mx-auto">
|
||||
At Rocky Mountain Vending, we invest in high-quality equipment to deliver the best service with minimal interruptions. For locations that handle a lot of cash, we use bill recyclers to ensure customers can easily use $1, $2, $5, $10, or $20 bills without worry. This flexibility improves convenience and keeps transactions smooth. Additionally, we use commercial-grade steam cleaners to thoroughly sanitize our machines, a step many vendors overlook. Our commitment to using the best tools ensures a clean, efficient, and hassle-free vending experience.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<FeatureCard
|
||||
image="https://rockymountainvending.com/wp-content/uploads/2024/10/Bill-Recycler-150x150.webp"
|
||||
alt="Bill recycler on a vending machine, designed to accept and dispense U.S. dollar bills."
|
||||
title="Bill Recycler"
|
||||
description="The MEI Bill Recycler is an essential upgrade for high-cash locations, accepting $1, $2, $5, $10, and $20 bills while recycling $1 or $5 bills for future transactions. It features a bright, low-powered LED bezel that illuminates and clearly displays which bill denominations are currently accepted, improving user experience and ensuring efficient transactions. This reduces downtime by preventing cash shortages and minimizing machine jams. With tamper-evident sensors and high-capacity bill storage, it's ideal for businesses requiring reliable, hassle-free vending machine operations."
|
||||
imageWidth={150}
|
||||
imageHeight={150}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
image="https://rockymountainvending.com/wp-content/uploads/2024/10/Steam-Cleaner-150x150.jpg"
|
||||
alt="Steam cleaner equipment used for sanitizing and deep cleaning vending machines."
|
||||
title="Steam Cleaner"
|
||||
description="We use a commercial-grade steam cleaner to ensure that our vending machines are not only visually appealing but also fully sanitized. Drink machines, in particular, can accumulate sticky residue from spills or explosions, making them hard to clean on-site. With powerful steam reaching temperatures of up to 310°F and operating at 75 PSI, this cleaner is ideal for deep cleaning, removing dirt, grease, and bacteria without harsh chemicals. Its continuous refill feature allows us to provide thorough cleanings quickly and efficiently, maintaining both hygiene and appearance."
|
||||
imageWidth={150}
|
||||
imageHeight={150}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Machines Showcase */}
|
||||
<VendingMachinesShowcase />
|
||||
|
||||
{/* CRM Form */}
|
||||
<section className="mt-16">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-2xl font-bold mb-2">Get Your Free Vending Machine</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Fill out the form below and we'll contact you within 24 hours to discuss your needs.
|
||||
</p>
|
||||
</div>
|
||||
<iframe
|
||||
src="https://link.sluice-box.io/widget/form/T76mIdPvC5iBwAI2wFPg"
|
||||
style={{ width: "100%", height: "650px", border: "none", borderRadius: "4px" }}
|
||||
id="inline-T76mIdPvC5iBwAI2wFPg"
|
||||
data-layout="{'id':'INLINE'}"
|
||||
data-trigger-type="alwaysShow"
|
||||
data-trigger-value=""
|
||||
data-activation-type="alwaysActivated"
|
||||
data-activation-value=""
|
||||
data-deactivation-type="neverDeactivate"
|
||||
data-deactivation-value=""
|
||||
data-form-name="Request Machine Short"
|
||||
data-height="638"
|
||||
data-layout-iframe-id="inline-T76mIdPvC5iBwAI2wFPg"
|
||||
data-form-id="T76mIdPvC5iBwAI2wFPg"
|
||||
title="Request Free Vending Machine Form"
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||
<FeatureCard
|
||||
image="https://rockymountainvending.com/wp-content/uploads/2024/10/Bill-Recycler-150x150.webp"
|
||||
alt="Bill recycler on a vending machine, designed to accept and dispense U.S. dollar bills."
|
||||
title="Bill Recycler"
|
||||
description="Bill recyclers help high-cash locations run smoothly by improving change availability and reducing downtime from cash-related issues."
|
||||
imageWidth={150}
|
||||
imageHeight={150}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FeatureCard
|
||||
image="https://rockymountainvending.com/wp-content/uploads/2024/10/Steam-Cleaner-150x150.jpg"
|
||||
alt="Steam cleaner equipment used for sanitizing and deep cleaning vending machines."
|
||||
title="Steam Cleaner"
|
||||
description="Commercial steam cleaning helps us sanitize and deep-clean machines without relying on harsh chemicals or incomplete wipe-downs."
|
||||
imageWidth={150}
|
||||
imageHeight={150}
|
||||
/>
|
||||
</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="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
Use the request form here if you want free placement for a qualifying business and we'll help map out the best machine mix.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<RequestMachineForm />
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
</article>
|
||||
{/* Form Script */}
|
||||
<script src="https://link.sluice-box.io/js/form_embed.js" strategy="afterInteractive" />
|
||||
|
||||
<section className="mt-12">
|
||||
<VendingMachinesShowcase />
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
// Silently return error fallback in production
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Machines We Use page:', error);
|
||||
console.error('Error rendering Machines We Use page:', error)
|
||||
}
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">Error Loading Page</h1>
|
||||
<p className="text-destructive mb-4">There was an error loading this page. Please try again later.</p>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<pre className="mt-4 p-4 bg-muted rounded text-sm overflow-auto">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</pre>
|
||||
)}
|
||||
<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' && (
|
||||
<pre className="mt-4 overflow-auto rounded-[1.5rem] bg-muted/60 p-4 text-sm">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</pre>
|
||||
)}
|
||||
</PublicSurface>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Clock, Mail, Phone } from "lucide-react"
|
||||
import { ContactForm } from "@/components/forms/contact-form"
|
||||
import { PublicInset, PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
|
|
@ -18,16 +19,15 @@ export function ContactPage() {
|
|||
|
||||
return (
|
||||
<div className="container mx-auto max-w-6xl px-4 py-10 md:py-14">
|
||||
<header className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary/80">Contact Rocky Mountain Vending</p>
|
||||
<h1 className="mt-3 text-4xl font-bold tracking-tight text-balance md:text-5xl">Tell us what you need and we'll point you to the right team.</h1>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||
Use the form for repairs, moving, manuals, machine sales, or general questions. If you'd rather talk now, call us during business hours.
|
||||
</p>
|
||||
</header>
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Contact Rocky Mountain Vending"
|
||||
title="Tell us what you need and we'll point you to the right team."
|
||||
description="Use the form for repairs, moving, manuals, machine sales, or general questions. If you'd rather talk now, call us during business hours."
|
||||
/>
|
||||
|
||||
<div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)] lg:items-start">
|
||||
<section id="contact-form" className="rounded-[2rem] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.92),rgba(249,247,242,0.92))] p-5 shadow-[0_24px_60px_rgba(0,0,0,0.08)] md:p-7">
|
||||
<PublicSurface id="contact-form" as="section" className="p-5 md:p-7">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Contact Form
|
||||
|
|
@ -35,7 +35,7 @@ export function ContactPage() {
|
|||
<p className="text-sm text-muted-foreground">For repairs or moving, include the machine model and a clear description of what's happening.</p>
|
||||
</div>
|
||||
<ContactForm onSubmit={(data) => console.log("Contact form submitted:", data)} />
|
||||
</section>
|
||||
</PublicSurface>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<Card className="overflow-hidden rounded-[2rem] border-border/70 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
|
||||
|
|
@ -86,15 +86,15 @@ export function ContactPage() {
|
|||
|
||||
<div className="mt-5 space-y-2">
|
||||
{businessHours.map((schedule) => (
|
||||
<div
|
||||
<PublicInset
|
||||
key={schedule.day}
|
||||
className={`flex items-center justify-between rounded-xl px-3 py-2 ${
|
||||
className={`flex items-center justify-between rounded-xl px-3 py-2 shadow-none ${
|
||||
schedule.isClosed ? "bg-muted/55 text-muted-foreground" : "bg-primary/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-foreground">{schedule.day}</span>
|
||||
<span className={schedule.isClosed ? "text-sm" : "text-sm font-semibold text-primary"}>{schedule.hours}</span>
|
||||
</div>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Image from "next/image"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { PublicSurface } from "@/components/public-surface"
|
||||
|
||||
interface FeatureCardProps {
|
||||
image: string
|
||||
|
|
@ -19,24 +19,18 @@ export function FeatureCard({
|
|||
imageHeight = 300,
|
||||
}: FeatureCardProps) {
|
||||
return (
|
||||
<Card className="border-border/50 overflow-hidden hover:border-secondary/50 transition-colors h-full flex flex-col">
|
||||
<CardContent className="p-6 flex flex-col items-center text-center flex-1">
|
||||
<div className="relative w-full max-w-[200px] mb-4">
|
||||
<Image
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={imageWidth}
|
||||
height={imageHeight}
|
||||
className="rounded-lg shadow-lg w-auto h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed flex-1">
|
||||
{description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PublicSurface className="flex h-full flex-col items-center p-6 text-center transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_60px_rgba(0,0,0,0.12)]">
|
||||
<div className="relative mb-4 w-full max-w-[200px] rounded-[1.5rem] bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.14),transparent_55%),linear-gradient(180deg,rgba(247,244,236,0.72),rgba(255,255,255,0.96))] p-4">
|
||||
<Image
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={imageWidth}
|
||||
height={imageHeight}
|
||||
className="mx-auto h-auto w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{title}</h3>
|
||||
<p className="flex-1 text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
</PublicSurface>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,36 @@
|
|||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Wrench, MonitorSmartphone, CreditCard, Sparkles } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { PublicInset, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "Zero Cost Installation",
|
||||
description:
|
||||
"Get your vending machine installed completely free. No upfront costs, no hidden fees, no commitments.",
|
||||
"Get your vending machine installed completely free. No upfront costs, hidden fees, or awkward setup process.",
|
||||
link: "/about",
|
||||
linkText: "Learn More",
|
||||
linkText: "Learn more",
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Full Maintenance Support",
|
||||
description: "We handle all repairs, restocking, and maintenance. Your only job is to enjoy the convenience.",
|
||||
description: "We handle repairs, restocking, and service so your location stays easy to manage.",
|
||||
link: "/services/repairs",
|
||||
linkText: "View Repair Services",
|
||||
linkText: "Repair services",
|
||||
},
|
||||
{
|
||||
icon: MonitorSmartphone,
|
||||
title: "24/7 Remote Monitoring",
|
||||
description: "Advanced monitoring systems ensure machines are always stocked and functioning properly.",
|
||||
description: "Advanced monitoring helps us keep machines stocked and working with fewer interruptions.",
|
||||
link: "/services",
|
||||
linkText: "Our Services",
|
||||
linkText: "Our services",
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: "All Payment Options",
|
||||
description: "Accept cash, credit cards, mobile payments, and more. Modern cashless payment technology included.",
|
||||
link: "/contact-us",
|
||||
linkText: "Contact Us",
|
||||
description: "Cash, cards, and mobile payments are supported with modern payment hardware.",
|
||||
link: "/contact-us#contact-form",
|
||||
linkText: "Contact us",
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -38,33 +38,34 @@ export function FeaturesSection() {
|
|||
return (
|
||||
<section className="py-20 md:py-28 bg-muted/30">
|
||||
<div className="container mx-auto px-4">
|
||||
<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>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
|
||||
We make vending simple, reliable, and completely hassle-free for your business.
|
||||
</p>
|
||||
</div>
|
||||
<PublicSurface className="p-6 md:p-8">
|
||||
<div className="text-center mb-10 md:mb-12">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Why Rocky</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight text-balance md:text-4xl lg:text-5xl">
|
||||
Why businesses choose Rocky Mountain Vending
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-2xl text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
One visual system, one service standard, and a much more polished way to explain what makes our vending support easy to trust.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((feature, index) => (
|
||||
<Link key={index} href={feature.link}>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-all hover:shadow-lg h-full cursor-pointer">
|
||||
<CardContent className="pt-6 flex flex-col h-full">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<feature.icon className="h-6 w-6 text-primary" />
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
{features.map((feature) => (
|
||||
<Link key={feature.title} href={feature.link} className="group block h-full">
|
||||
<PublicInset className="flex h-full flex-col p-5 transition-all group-hover:-translate-y-0.5 group-hover:shadow-md">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<feature.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{feature.title}</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed flex-grow">{feature.description}</p>
|
||||
<p className="text-primary text-sm font-medium mt-4 hover:underline">
|
||||
<h3 className="text-xl font-semibold">{feature.title}</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
|
||||
<p className="mt-5 text-sm font-medium text-primary transition group-hover:translate-x-0.5">
|
||||
{feature.linkText} →
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</PublicInset>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function Footer() {
|
|||
<div className="grid gap-12 md:grid-cols-4">
|
||||
{/* Company Info */}
|
||||
<div className="md:col-span-2">
|
||||
<Image src="/rmv-logo.png" alt="Rocky Mountain Vending" width={200} height={50} className="mb-6 object-contain" />
|
||||
<Image src="/rmv-logo.png" alt="Rocky Mountain Vending" width={200} height={50} className="mb-6 h-auto w-auto object-contain" />
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md leading-relaxed">
|
||||
Providing FREE vending machine services to businesses across Utah since 2019. Zero cost, hassle-free
|
||||
service, and dedicated support.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { FormInput } from "./form-input"
|
|||
import { FormSelect } from "./form-select"
|
||||
import { FormTextarea } from "./form-textarea"
|
||||
import { SmsConsentFields } from "./sms-consent-fields"
|
||||
import { PublicSectionHeader } from "@/components/public-surface"
|
||||
import { CONTACT_INTENT_OPTIONS } from "@/lib/site-chat/intents"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { SMS_CONSENT_VERSION } from "@/lib/sms-compliance"
|
||||
|
|
@ -36,16 +37,6 @@ interface ContactFormProps {
|
|||
className?: string
|
||||
}
|
||||
|
||||
function SectionHeader({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">{eyebrow}</p>
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContactForm({ onSubmit, className }: ContactFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||||
|
|
@ -180,7 +171,7 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
|
|||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
<PublicSectionHeader
|
||||
eyebrow="Step 1"
|
||||
title="How should we reach you?"
|
||||
description="Share your contact details so the right team member can follow up quickly."
|
||||
|
|
@ -239,7 +230,7 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
|
|||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
<PublicSectionHeader
|
||||
eyebrow="Step 2"
|
||||
title="What do you need help with?"
|
||||
description="A little context helps us route this to the right person the first time."
|
||||
|
|
@ -284,7 +275,7 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
|
|||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
<PublicSectionHeader
|
||||
eyebrow="Step 3"
|
||||
title="Tell us the details"
|
||||
description="A few specifics here will help us answer faster and avoid extra back-and-forth."
|
||||
|
|
@ -308,7 +299,7 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
|
|||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
<PublicSectionHeader
|
||||
eyebrow="Step 4"
|
||||
title="Text updates"
|
||||
description="Required service consent covers scheduling, support, and follow-up texts for this request. Marketing texts stay separate and optional."
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { FormInput } from "./form-input"
|
|||
import { FormTextarea } from "./form-textarea"
|
||||
import { SmsConsentFields } from "./sms-consent-fields"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { PublicSectionHeader } from "@/components/public-surface"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { SMS_CONSENT_VERSION } from "@/lib/sms-compliance"
|
||||
|
||||
|
|
@ -48,16 +49,6 @@ const MACHINE_TYPE_OPTIONS = [
|
|||
{ label: "Other / not sure yet", value: "other" },
|
||||
] as const
|
||||
|
||||
function SectionHeader({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">{eyebrow}</p>
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RequestMachineForm({ onSubmit, className }: RequestMachineFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||||
|
|
@ -201,7 +192,7 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
|
|||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
<PublicSectionHeader
|
||||
eyebrow="Step 1"
|
||||
title="Business contact"
|
||||
description="Free placement is for qualifying businesses. Start with the best person to reach."
|
||||
|
|
@ -284,7 +275,7 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
|
|||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
<PublicSectionHeader
|
||||
eyebrow="Step 2"
|
||||
title="What should we plan for?"
|
||||
description="Tell us what kind of setup you'd like so we can recommend the right mix."
|
||||
|
|
@ -342,7 +333,7 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
|
|||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
<PublicSectionHeader
|
||||
eyebrow="Step 3"
|
||||
title="Anything else we should know?"
|
||||
description="Optional details like break-room setup, preferred snacks, or special access notes are helpful."
|
||||
|
|
@ -359,7 +350,7 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
|
|||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/85 p-5 shadow-sm md:p-6">
|
||||
<SectionHeader
|
||||
<PublicSectionHeader
|
||||
eyebrow="Step 4"
|
||||
title="Text updates"
|
||||
description="Required service consent covers scheduling, installation planning, service, and follow-up texts for this request. Marketing texts stay separate and optional."
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { PublicInset, PublicSurface } from '@/components/public-surface'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -41,10 +42,15 @@ interface ProductSuggestionsProps {
|
|||
|
||||
function ProductSuggestions({ manual, className = '' }: ProductSuggestionsProps) {
|
||||
const [suggestions, setSuggestions] = useState<ProductSuggestion[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(ebayClient.isConfigured())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ebayClient.isConfigured()) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
async function loadSuggestions() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
|
@ -72,6 +78,10 @@ function ProductSuggestions({ manual, className = '' }: ProductSuggestionsProps)
|
|||
}
|
||||
}, [manual])
|
||||
|
||||
if (!ebayClient.isConfigured()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}>
|
||||
|
|
@ -196,9 +206,9 @@ function ManualCard({
|
|||
const thumbnailUrl = getThumbnailUrl(manual)
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow transition-colors">
|
||||
<Card className="overflow-hidden rounded-[1.75rem] border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.9))] shadow-[0_18px_45px_rgba(0,0,0,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_60px_rgba(0,0,0,0.12)]">
|
||||
{thumbnailUrl && (
|
||||
<div className="relative w-full h-48 min-h-[192px] bg-muted overflow-hidden">
|
||||
<div className="relative h-48 min-h-[192px] w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.16),transparent_55%),linear-gradient(180deg,rgba(247,244,236,0.72),rgba(255,255,255,0.96))]">
|
||||
<Image
|
||||
src={thumbnailUrl}
|
||||
alt={manual.filename.replace(/\.pdf$/i, '')}
|
||||
|
|
@ -208,30 +218,30 @@ function ManualCard({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base line-clamp-2">
|
||||
<CardHeader className="px-5 pt-5">
|
||||
<CardTitle className="line-clamp-2 text-base leading-snug">
|
||||
{manual.filename.replace(/\.pdf$/i, '')}
|
||||
</CardTitle>
|
||||
{manual.commonNames && manual.commonNames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{manual.commonNames.map((name, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Badge key={index} variant="secondary" className="rounded-full border border-primary/15 bg-primary/[0.06] px-2.5 py-0.5 text-[11px] font-medium text-foreground">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{manual.searchTerms && manual.searchTerms.length > 0 && !manual.commonNames && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{manual.searchTerms.map((term, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Badge key={index} variant="secondary" className="rounded-full border border-border/60 bg-muted/35 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{term}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-4 px-5 pb-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
|
@ -268,7 +278,7 @@ function ManualCard({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onView({ url: getManualUrl(manual), filename: manual.filename })}
|
||||
className="flex-1"
|
||||
className="flex-1 rounded-full border-border bg-background hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View PDF
|
||||
|
|
@ -538,14 +548,14 @@ export function ManualsPageClient({
|
|||
}
|
||||
}
|
||||
|
||||
return 'bg-muted text-muted-foreground'
|
||||
return 'bg-primary/[0.08] text-foreground'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search and Filter Controls */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<PublicSurface>
|
||||
<CardContent className="p-0">
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
|
|
@ -555,7 +565,7 @@ export function ManualsPageClient({
|
|||
placeholder="Search manuals by name, manufacturer, or category..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
className="h-12 rounded-xl border-border/70 bg-background/85 pl-10 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -578,7 +588,7 @@ export function ManualsPageClient({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectTrigger className="w-full rounded-xl border-border/70 bg-background/85 shadow-sm sm:w-[200px]">
|
||||
<SelectValue placeholder="All Manufacturers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -590,7 +600,7 @@ export function ManualsPageClient({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{selectedManufacturer && (
|
||||
<Badge className="absolute -top-2 -right-2 bg-green-500 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
<Badge className="absolute -right-2 -top-2 rounded-full bg-primary text-[10px] text-white">
|
||||
Applied
|
||||
</Badge>
|
||||
)}
|
||||
|
|
@ -604,7 +614,7 @@ export function ManualsPageClient({
|
|||
}}
|
||||
disabled={!selectedManufacturer && filteredCategories.length === categories.length}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectTrigger className="w-full rounded-xl border-border/70 bg-background/85 shadow-sm sm:w-[200px]">
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -616,14 +626,14 @@ export function ManualsPageClient({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{selectedCategory && (
|
||||
<Badge className="absolute -top-2 -right-2 bg-green-500 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
<Badge className="absolute -right-2 -top-2 rounded-full bg-primary text-[10px] text-white">
|
||||
Applied
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<PublicInset className="flex w-full items-center gap-2 rounded-full px-4 py-2 shadow-none sm:w-auto">
|
||||
<Checkbox
|
||||
id="has-parts"
|
||||
checked={hasPartsOnly}
|
||||
|
|
@ -637,14 +647,14 @@ export function ManualsPageClient({
|
|||
Has Parts Available
|
||||
</label>
|
||||
{hasPartsOnly && (
|
||||
<Badge className="bg-green-500 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
<Badge className="rounded-full bg-primary text-[10px] text-white">
|
||||
Applied
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</PublicInset>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="outline" size="sm" onClick={clearFilters} className="w-full sm:w-auto">
|
||||
<Button variant="outline" size="sm" onClick={clearFilters} className="w-full rounded-full border-border bg-background sm:w-auto">
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear Filters
|
||||
</Button>
|
||||
|
|
@ -655,14 +665,14 @@ export function ManualsPageClient({
|
|||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<span className="text-sm text-muted-foreground flex-shrink-0">View:</span>
|
||||
<div className="inline-flex items-center rounded-md border border-input bg-background shadow-xs flex-1 sm:flex-initial">
|
||||
<div className="inline-flex flex-1 items-center rounded-full border border-border/70 bg-background/90 p-1 shadow-sm sm:flex-initial">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode('grouped')}
|
||||
className={`rounded-r-none border-r flex-1 sm:flex-initial ${
|
||||
className={`flex-1 rounded-full sm:flex-initial ${
|
||||
viewMode === 'grouped'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
aria-label="Grouped view"
|
||||
|
|
@ -674,9 +684,9 @@ export function ManualsPageClient({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`rounded-l-none flex-1 sm:flex-initial ${
|
||||
className={`flex-1 rounded-full sm:flex-initial ${
|
||||
viewMode === 'list'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
aria-label="List view"
|
||||
|
|
@ -694,11 +704,11 @@ export function ManualsPageClient({
|
|||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PublicSurface>
|
||||
|
||||
{/* Manuals Display */}
|
||||
{filteredManuals.length === 0 ? (
|
||||
<Card>
|
||||
<PublicSurface>
|
||||
<CardContent className="py-16 text-center">
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
|
|
@ -709,15 +719,15 @@ export function ManualsPageClient({
|
|||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PublicSurface>
|
||||
) : viewMode === 'grouped' ? (
|
||||
/* Grouped View */
|
||||
<div className="space-y-10">
|
||||
{filteredGroupedManuals.map((group) => {
|
||||
const organized = organizeCategories(group.categories)
|
||||
return (
|
||||
<div key={group.manufacturer} className="space-y-6">
|
||||
<div className="border-b pb-3">
|
||||
<PublicSurface key={group.manufacturer} className="space-y-6">
|
||||
<div className="border-b border-border/60 pb-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{group.manufacturer}</h2>
|
||||
</div>
|
||||
|
||||
|
|
@ -768,7 +778,7 @@ export function ManualsPageClient({
|
|||
{organized.others.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{organized.machineTypes.length > 0 && (
|
||||
<div className="border-t pt-6">
|
||||
<div className="border-t border-border/60 pt-6">
|
||||
<h3 className="text-lg font-semibold text-muted-foreground mb-4">
|
||||
Models & Other Documents
|
||||
</h3>
|
||||
|
|
@ -804,7 +814,7 @@ export function ManualsPageClient({
|
|||
{/* Product Suggestions Section */}
|
||||
{filteredManuals.length > 0 && (
|
||||
<div className="space-y-6 mt-8">
|
||||
<div className="border-t pt-6">
|
||||
<div className="border-t border-border/60 pt-6">
|
||||
<h3 className="text-lg font-semibold text-muted-foreground mb-4">
|
||||
Related Products
|
||||
</h3>
|
||||
|
|
@ -819,7 +829,7 @@ export function ManualsPageClient({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -852,4 +862,3 @@ export function ManualsPageClient({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const ManualsPageClient = dynamic(
|
|||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground">
|
||||
<div className="rounded-[2rem] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.92))] p-6 text-sm text-muted-foreground shadow-[0_24px_60px_rgba(0,0,0,0.08)]">
|
||||
Loading manual browser...
|
||||
</div>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { Product } from '@/lib/products/types'
|
||||
import { PublicInset, PublicSurface } from '@/components/public-surface'
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
|
|
@ -16,33 +16,30 @@ export function ProductCard({ product }: ProductCardProps) {
|
|||
: ''
|
||||
|
||||
return (
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<Card className="border-border/50 overflow-hidden hover:border-secondary/50 hover:shadow-lg transition-shadow transition-colors h-full flex flex-col">
|
||||
<div className="aspect-square relative overflow-hidden bg-muted">
|
||||
<Link href={`/products/${product.id}`} className="group block h-full">
|
||||
<PublicSurface className="h-full overflow-hidden p-0 transition-all group-hover:-translate-y-0.5 group-hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)]">
|
||||
<div className="relative aspect-square overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.14),transparent_55%),linear-gradient(180deg,rgba(247,244,236,0.72),rgba(255,255,255,0.96))]">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover hover:scale-105 transition-transform duration-300"
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-6 flex flex-col flex-1">
|
||||
<div className="p-5 md:p-6">
|
||||
<h3 className="text-xl font-semibold mb-2">{product.name}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4 flex-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto">
|
||||
<p className="text-2xl font-bold text-secondary">
|
||||
{description ? (
|
||||
<p className="mb-4 text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
<PublicInset className="mt-auto">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/75">Starting at</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">
|
||||
${product.price.toFixed(2)} {product.currency.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PublicInset>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import Image from "next/image"
|
||||
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
export function ProductShowcaseSection() {
|
||||
const products = [
|
||||
|
|
@ -33,29 +33,33 @@ export function ProductShowcaseSection() {
|
|||
return (
|
||||
<section className="py-16 md:py-24 bg-background">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 tracking-tight text-balance">What We Offer</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
|
||||
From traditional favorites to healthy alternatives, we stock what your employees and customers want
|
||||
</p>
|
||||
</div>
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Product Mix"
|
||||
title="What we keep stocked and ready to tailor for your location."
|
||||
description="From traditional favorites to healthier options, the homepage now uses the same rounded Rocky surfaces here too instead of falling back to the older card style."
|
||||
className="mb-12"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 max-w-7xl mx-auto">
|
||||
{products.map((product, index) => (
|
||||
<Card className="border-border/50 overflow-hidden hover:border-secondary/50 hover:shadow-lg transition-shadow transition-colors">
|
||||
<div className="aspect-square relative overflow-hidden">
|
||||
<div className="mx-auto grid max-w-7xl gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{products.map((product) => (
|
||||
<PublicSurface
|
||||
key={product.title}
|
||||
className="overflow-hidden p-0 transition-all hover:-translate-y-0.5 hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.14),transparent_55%),linear-gradient(180deg,rgba(247,244,236,0.72),rgba(255,255,255,0.96))]">
|
||||
<Image
|
||||
src={product.image || "/placeholder.svg"}
|
||||
alt={product.alt}
|
||||
fill
|
||||
className="object-cover hover:scale-105 transition-transform duration-300"
|
||||
className="object-cover transition-transform duration-300 hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<div className="p-5 md:p-6">
|
||||
<h3 className="text-xl font-semibold mb-2">{product.title}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{product.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{product.description}</p>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
105
components/public-surface.tsx
Normal file
105
components/public-surface.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use client"
|
||||
|
||||
import type { ElementType, HTMLAttributes, ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type PublicPageHeaderProps = {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description?: string
|
||||
align?: "left" | "center"
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PublicPageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
align = "left",
|
||||
className,
|
||||
children,
|
||||
}: PublicPageHeaderProps) {
|
||||
const isCentered = align === "center"
|
||||
|
||||
return (
|
||||
<header className={cn("space-y-4", isCentered && "mx-auto max-w-3xl text-center", className)}>
|
||||
{eyebrow ? (
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary/80">{eyebrow}</p>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground md:text-5xl">{title}</h1>
|
||||
{description ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-base leading-relaxed text-muted-foreground md:text-lg",
|
||||
isCentered ? "mx-auto max-w-3xl" : "max-w-3xl",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicSurface({
|
||||
as: Component = "div",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { as?: ElementType }) {
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"rounded-[2rem] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,244,236,0.92))] p-5 shadow-[0_24px_60px_rgba(0,0,0,0.08)] md:p-7",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicInset({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[1.5rem] border border-border/60 bg-background/85 p-4 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PublicSectionHeaderProps = {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PublicSectionHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: PublicSectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">{eyebrow}</p>
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ArrowRight, Package } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { PublicInset, PublicSurface } from "@/components/public-surface"
|
||||
import { RequestMachineForm } from "@/components/forms/request-machine-form"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
|
|
@ -10,7 +11,7 @@ export function RequestMachineSection() {
|
|||
<section id="request-machine" className="bg-[linear-gradient(180deg,rgba(247,244,236,0.55),rgba(255,255,255,0.98))] py-16 md:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
|
||||
<div className="rounded-[2rem] border border-border/70 bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.16),transparent_50%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(247,244,236,0.98))] p-6 shadow-[0_24px_60px_rgba(0,0,0,0.08)] md:p-8 lg:sticky lg:top-28">
|
||||
<PublicSurface className="bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.16),transparent_50%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(247,244,236,0.98))] p-6 md:p-8 lg:sticky lg:top-28">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
<Package className="h-4 w-4" />
|
||||
Free Placement
|
||||
|
|
@ -22,14 +23,14 @@ export function RequestMachineSection() {
|
|||
This intake is just for business locations that want free vending placement. We'll review foot traffic, preferred machine types, and next-step fit before scheduling the consultation.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3 rounded-[1.5rem] border border-border/60 bg-background/80 p-5 text-sm text-muted-foreground">
|
||||
<PublicInset className="mt-6 space-y-3 p-5 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">What to expect</p>
|
||||
<ul className="space-y-2">
|
||||
<li>We confirm the location type and approximate number of people on site.</li>
|
||||
<li>We review the best mix of snack, beverage, combo, or micro market options.</li>
|
||||
<li>We follow up within one business day to schedule the consultation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</PublicInset>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={businessConfig.publicCallUrl} className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary">
|
||||
|
|
@ -40,11 +41,11 @@ export function RequestMachineSection() {
|
|||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
|
||||
<div className="rounded-[2rem] border border-border/70 bg-background/95 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.08)] md:p-7">
|
||||
<PublicSurface className="bg-background/95 p-5 md:p-7">
|
||||
<RequestMachineForm onSubmit={(data) => console.log("Machine request form submitted:", data)} />
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,205 +1,106 @@
|
|||
'use client'
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Star } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { PublicInset, PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
const reviews = [
|
||||
{
|
||||
title: "Excellent Service!",
|
||||
body: "Rocky Mountain Vending has been fantastic for our office. The machines are always well-stocked and working perfectly.",
|
||||
author: "Sarah M., Salt Lake City",
|
||||
},
|
||||
{
|
||||
title: "Professional and Reliable",
|
||||
body: "We've been working with Rocky Mountain Vending for over a year now. Their team is responsive and easy to work with.",
|
||||
author: "John D., Ogden",
|
||||
},
|
||||
{
|
||||
title: "Great Selection",
|
||||
body: "Our employees love the healthy snack options and variety available. The free installation and maintenance service is a huge plus.",
|
||||
author: "Michelle R., Provo",
|
||||
},
|
||||
{
|
||||
title: "Outstanding Customer Service",
|
||||
body: "Whenever we have an issue, the team responds quickly and resolves it the same day. It's rare to find this level of service today.",
|
||||
author: "David K., Sandy",
|
||||
},
|
||||
{
|
||||
title: "Highly Recommended",
|
||||
body: "Best vending service we've ever used. Free machines, regular restocking, and great communication.",
|
||||
author: "Lisa T., West Valley City",
|
||||
},
|
||||
{
|
||||
title: "Local Business We Trust",
|
||||
body: "Supporting a local, family-owned business feels good. They care about their customers and it shows in everything they do.",
|
||||
author: "Robert P., Bountiful",
|
||||
},
|
||||
]
|
||||
|
||||
export function ReviewsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<header className="text-center mb-12 md:mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Customer Reviews
|
||||
</h1>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[var(--link-hover-color)] to-[var(--link-hover-color-dark)] mx-auto rounded-full" />
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mt-6 text-pretty leading-relaxed">
|
||||
Read authentic customer reviews and testimonials about Rocky Mountain Vending's exceptional vending services in Utah. Discover why businesses trust us for free vending machines, reliable service, and outstanding customer support across Salt Lake City, Ogden, Provo, and surrounding areas.
|
||||
</p>
|
||||
</header>
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Customer Reviews"
|
||||
title="What Utah businesses say about working with Rocky Mountain Vending."
|
||||
description="We built this page to feel like the rest of the site: clear, calm, and easy to scan. These reviews highlight why local businesses trust us for placement, restocking, repairs, and day-to-day support."
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Review 1 */}
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star key={i} className="w-5 h-5" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-foreground font-semibold mb-2">Excellent Service!</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Rocky Mountain Vending has been fantastic for our office. The machines are always well-stocked and working perfectly. Highly recommend!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4 font-medium">
|
||||
— Sarah M., Salt Lake City
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Review 2 */}
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star key={i} className="w-5 h-5" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-foreground font-semibold mb-2">Professional and Reliable</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
We've been working with Rocky Mountain Vending for over a year now. Their team is professional, responsive, and always goes above and beyond.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4 font-medium">
|
||||
— John D., Ogden
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Review 3 */}
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star key={i} className="w-5 h-5" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-foreground font-semibold mb-2">Great Selection</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Our employees love the healthy snack options and variety available. The free installation and maintenance service is a huge plus for our business.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4 font-medium">
|
||||
— Michelle R., Provo
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Review 4 */}
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star key={i} className="w-5 h-5" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-foreground font-semibold mb-2">Outstanding Customer Service</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Whenever we have an issue, the team responds quickly and resolves it the same day. It's rare to find this level of service today.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4 font-medium">
|
||||
— David K., Sandy
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Review 5 */}
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star key={i} className="w-5 h-5" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-foreground font-semibold mb-2">Highly Recommended</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Best vending service we've ever used. Free machines, regular restocking, and great communication. What more could you ask for?
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4 font-medium">
|
||||
— Lisa T., West Valley City
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Review 6 */}
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star key={i} className="w-5 h-5" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-foreground font-semibold mb-2">Local Business We Trust</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Supporting a local, family-owned business feels good. They really care about their customers and it shows in everything they do.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4 font-medium">
|
||||
— Robert P., Bountiful
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<section className="mt-20 max-w-6xl mx-auto">
|
||||
<div className="border border-border/40 rounded-lg bg-secondary/5 p-8 md:p-12">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl mb-6 text-balance">
|
||||
Why Customers Choose Rocky Mountain Vending
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[var(--link-hover-color)] to-[var(--link-hover-color-dark)] mx-auto rounded-full mb-8" />
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">100% FREE Installation</h3>
|
||||
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
No upfront costs or hidden fees. We provide and install vending machines at no charge to your business.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">Regular Restocking & Maintenance</h3>
|
||||
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
Our team regularly services and restocks machines to ensure they're always fully operational and meeting customer needs.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">Wide Product Variety</h3>
|
||||
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
From traditional snacks and beverages to healthy options, we customize product selection to match your customers' preferences.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">Fast, Local Support</h3>
|
||||
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
Based in Utah, we provide quick response times and personalized service. Call us anytime at (435) 233-9668.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">Flexible Service Options</h3>
|
||||
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
Whether you need one machine or multiple locations, we scale to meet your business needs and growth.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{reviews.map((review) => (
|
||||
<PublicSurface key={review.author} className="h-full p-5 md:p-6">
|
||||
<div className="flex items-center gap-1 text-primary">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star key={index} className="h-5 w-5 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-20 max-w-6xl mx-auto">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl mb-6 text-balance">
|
||||
Share Your Experience
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[var(--link-hover-color)] to-[var(--link-hover-color-dark)] mx-auto rounded-full mb-8" />
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-8 text-pretty leading-relaxed">
|
||||
We value your feedback and are constantly improving our services. Whether you're a current customer or considering Rocky Mountain Vending for your business, we'd love to hear from you.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Card className="border-secondary/20 bg-secondary/5 p-6 flex-1">
|
||||
<CardContent className="p-0">
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">Current Customer</h3>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Share your experience with our services and help us serve you better.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-secondary/20 bg-secondary/5 p-6 flex-1">
|
||||
<CardContent className="p-0">
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">Business Inquiry</h3>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Learn how we can provide free vending solutions for your business.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<h2 className="mt-4 text-xl font-semibold text-foreground">{review.title}</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{review.body}</p>
|
||||
<p className="mt-5 text-sm font-medium text-foreground">— {review.author}</p>
|
||||
</PublicSurface>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<PublicSurface>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">Why customers keep choosing Rocky Mountain Vending</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
{[
|
||||
["100% free installation", "No upfront costs or hidden fees for qualifying businesses."],
|
||||
["Regular restocking and maintenance", "We keep machines full, clean, and working with ongoing service included."],
|
||||
["Wide product variety", "Healthy snacks, traditional favorites, beverages, and location-specific mixes."],
|
||||
["Fast local support", "Utah-based service means quicker response times and a more personal experience."],
|
||||
["Flexible service options", "From a single machine to multiple properties, we scale with your location."],
|
||||
].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">Next Step</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">Want the same experience at your location?</h2>
|
||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
If you're looking for free placement, service help, or machine sales, we can point you to the right intake form right away.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<Link href="/#request-machine" className="rounded-[1.5rem] border border-border/60 bg-background/90 p-5 text-left transition hover:border-primary/30 hover:text-primary">
|
||||
<h3 className="text-lg font-semibold text-foreground">Free Placement</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">Start a request for free vending machine placement at your business.</p>
|
||||
</Link>
|
||||
<Link href="/contact-us#contact-form" className="rounded-[1.5rem] border border-border/60 bg-background/90 p-5 text-left transition hover:border-primary/30 hover:text-primary">
|
||||
<h3 className="text-lg font-semibold text-foreground">Service or Sales</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">Reach out about repairs, moving, manuals, parts, or machine sales.</p>
|
||||
</Link>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Link from "next/link"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import Image from "next/image"
|
||||
import { PublicSurface } from "@/components/public-surface"
|
||||
|
||||
const services = [
|
||||
{
|
||||
|
|
@ -37,7 +37,7 @@ const services = [
|
|||
title: "Support & Resources",
|
||||
items: [
|
||||
{ name: "Service Areas", href: "/service-areas" },
|
||||
{ name: "Contact Us", href: "/contact-us" },
|
||||
{ name: "Contact Us", href: "/contact-us#contact-form" },
|
||||
{ name: "Request Service", href: "/services/repairs#request-service" },
|
||||
{ name: "Order Parts", href: "/services/parts#how-to-order" },
|
||||
],
|
||||
|
|
@ -50,43 +50,38 @@ export function ServicesSection() {
|
|||
<section id="services" className="py-20 md:py-28 bg-muted/30">
|
||||
<div className="container mx-auto px-4">
|
||||
<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 Services & Products
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Explore</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl text-balance">
|
||||
Our services and products
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
|
||||
From traditional vending machines to modern cashless solutions, we offer everything you need to keep your
|
||||
team refreshed and productive.
|
||||
<p className="mx-auto mt-3 max-w-2xl text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
From free placement to manuals and repairs, these sections now share the same visual rhythm as the rest of the public site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
{services.map((service, index) => (
|
||||
<Card key={index} className="border-border/50 overflow-hidden hover:border-secondary/50 transition-colors">
|
||||
<div className="aspect-video relative bg-muted">
|
||||
<Image
|
||||
src={service.image || "/placeholder.svg"}
|
||||
alt={service.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{services.map((service) => (
|
||||
<PublicSurface key={service.title} className="overflow-hidden p-0 transition-all hover:-translate-y-0.5 hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)]">
|
||||
<div className="relative aspect-[4/3] bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.16),transparent_55%),linear-gradient(180deg,rgba(247,244,236,0.75),rgba(255,255,255,0.96))]">
|
||||
<Image src={service.image || "/placeholder.svg"} alt={service.title} fill className="object-cover" />
|
||||
</div>
|
||||
<CardContent className="pt-6">
|
||||
<div className="p-5 md:p-6">
|
||||
<h3 className="text-xl font-semibold mb-4">{service.title}</h3>
|
||||
<ul className="space-y-2">
|
||||
{service.items.map((item, itemIndex) => (
|
||||
<li key={itemIndex}>
|
||||
{service.items.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-primary hover:underline transition-colors"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-primary hover:underline"
|
||||
>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-secondary" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary/70" />
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import Image from "next/image"
|
||||
import { AnimatedNumber } from "@/components/animated-number"
|
||||
import { PublicInset, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
interface VendingMachineCardProps {
|
||||
name: string
|
||||
|
|
@ -20,38 +20,31 @@ export function VendingMachineCard({
|
|||
items = 0,
|
||||
}: VendingMachineCardProps) {
|
||||
return (
|
||||
<Card className="border-border/50 overflow-hidden h-full flex flex-col hover:border-secondary/50 transition-colors">
|
||||
<div className="relative aspect-[4/3] w-full bg-muted">
|
||||
<PublicSurface className="flex h-full flex-col overflow-hidden p-0 transition-all hover:-translate-y-0.5 hover:shadow-[0_26px_65px_rgba(0,0,0,0.12)]">
|
||||
<div className="relative aspect-[4/3] w-full bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.14),transparent_55%),linear-gradient(180deg,rgba(247,244,236,0.72),rgba(255,255,255,0.96))]">
|
||||
<Image
|
||||
src={image}
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-contain p-4"
|
||||
className="object-contain p-5"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-6 flex-1 flex flex-col">
|
||||
<div className="flex flex-1 flex-col p-5 md:p-6">
|
||||
<h3 className="text-xl font-semibold mb-2">{name}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 flex-1 overflow-y-auto">{description}</p>
|
||||
<div className="flex gap-6 text-sm pt-4 border-t">
|
||||
<div>
|
||||
<AnimatedNumber
|
||||
value={selections}
|
||||
className="text-2xl font-bold text-secondary"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide mt-1">Selections</div>
|
||||
</div>
|
||||
<div>
|
||||
<AnimatedNumber
|
||||
value={items}
|
||||
className="text-2xl font-bold text-secondary"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide mt-1">Items</div>
|
||||
</div>
|
||||
<p className="mb-4 flex-1 text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<PublicInset>
|
||||
<AnimatedNumber value={selections} className="text-2xl font-bold text-foreground" />
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">Selections</div>
|
||||
</PublicInset>
|
||||
<PublicInset>
|
||||
<AnimatedNumber value={items} className="text-2xl font-bold text-foreground" />
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">Items</div>
|
||||
</PublicInset>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,188 +1,124 @@
|
|||
'use client'
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { VendingMachinesShowcase } from "@/components/vending-machines-showcase"
|
||||
import { CheckCircle2, CreditCard, MonitorSmartphone, Package, Wrench } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { PublicInset, PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
export function VendingMachinesPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<header className="text-center mb-12 md:mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Vending Machines
|
||||
</h1>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[var(--link-hover-color)] to-[var(--link-hover-color-dark)] mx-auto rounded-full" />
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed mt-6">
|
||||
Browse our selection of high-quality vending machines for your business. We offer the latest technology with cashless payment options and advanced features.
|
||||
</p>
|
||||
</header>
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Machine Options"
|
||||
title="Modern vending machines with a cleaner Rocky Mountain Vending presentation."
|
||||
description="Browse the machines we use, the features we prioritize, and the support that comes with every installation. This page now matches the rest of the site instead of feeling like a separate design system."
|
||||
/>
|
||||
|
||||
<div className="mt-10">
|
||||
<VendingMachinesShowcase />
|
||||
|
||||
<section className="mt-16 md:mt-24">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-8 text-balance border-b border-border pb-4 text-center">
|
||||
Why Choose Our Machines?
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-12">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 mt-1">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl mb-2 text-foreground">100% FREE Service</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
No upfront costs, installation fees, or monthly charges for qualifying businesses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 mt-1">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl mb-2 text-foreground">Modern Technology</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
State-of-the-art machines with advanced features and reliable performance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 mt-1">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl mb-2 text-foreground">Cashless Payments</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Integrated credit card readers for convenient, secure transactions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 mt-1">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl mb-2 text-foreground">Full Service & Support</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Regular maintenance, restocking, and 24/7 customer support included
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-2 shadow-lg [&>div]:!bg-transparent bg-gradient-to-r from-[var(--primary)] to-[#1d7a35]">
|
||||
<CardContent className="p-10 md:p-12 text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-white mb-4 tracking-tight text-balance">
|
||||
Ready to Get Started?
|
||||
</h2>
|
||||
<p className="text-white/90 mb-8 max-w-2xl mx-auto text-lg leading-relaxed">
|
||||
Contact us today to learn more about our free vending machine services and how we can help your business.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button asChild size="lg" className="bg-white text-[var(--primary)] hover:bg-white/90 text-lg h-12 px-8 font-semibold">
|
||||
<Link href="/contact-us">Contact Us</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline" className="bg-transparent border-2 border-white text-white hover:bg-white/10 text-lg h-12 px-8 font-semibold">
|
||||
<Link href="/#request-machine">Get Free Machine</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="mt-16 md:mt-24">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-8 text-balance border-b border-border pb-4 text-center">
|
||||
Our Services
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-12">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Wrench className="w-12 h-12 text-primary mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-xl mb-2">Repairs & Maintenance</h3>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Expert repair services to keep your machines running smoothly
|
||||
</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/services">Learn More</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<MonitorSmartphone className="w-12 h-12 text-primary mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-xl mb-2">Moving Services</h3>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Safe and efficient vending machine relocation
|
||||
</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/services">Learn More</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<CreditCard className="w-12 h-12 text-primary mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-xl mb-2">Parts & Supplies</h3>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Quality replacement parts and accessories
|
||||
</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/manuals">Shop Parts</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-16 md:mt-24">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-8 text-balance border-b border-border pb-4 text-center">
|
||||
Machine Features
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-12">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Package className="w-10 h-10 text-primary mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">Healthy Options</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Wide selection of nutritious snacks and beverages
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<CreditCard className="w-10 h-10 text-primary mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">Cashless Payment</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Accept credit/debit cards and mobile payments
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Wrench className="w-10 h-10 text-primary mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">Easy Maintenance</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Remote monitoring and quick service response
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<MonitorSmartphone className="w-10 h-10 text-primary mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">Smart Technology</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Track inventory and sales in real-time
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-12 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<PublicSurface>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">Why choose our machines?</h2>
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
{[
|
||||
["100% FREE service", "No upfront costs, installation fees, or monthly charges for qualifying businesses."],
|
||||
["Modern technology", "Current equipment with reliable performance and updated features."],
|
||||
["Cashless payments", "Integrated card readers and secure payment options for modern locations."],
|
||||
["Full service and support", "Maintenance, restocking, and local follow-up are part of the experience."],
|
||||
].map(([title, body]) => (
|
||||
<div key={title} className="flex items-start gap-4 rounded-[1.5rem] border border-border/60 bg-background/85 p-5 shadow-sm">
|
||||
<CheckCircle2 className="mt-0.5 h-6 w-6 shrink-0 text-primary" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
|
||||
<PublicSurface className="bg-[linear-gradient(145deg,rgba(78,137,66,0.98),rgba(31,74,32,0.98))] text-white">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/75">Get Started</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">Ready to talk through the right machine setup?</h2>
|
||||
<p className="mt-3 text-base leading-relaxed text-white/82">
|
||||
We can help with free placement, sales questions, feature comparisons, and planning the best layout for your location.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<Button asChild size="lg" className="h-11 rounded-full bg-white px-6 text-primary hover:bg-white/92">
|
||||
<Link href="/contact-us#contact-form">Contact Us</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline" className="h-11 rounded-full border-white/50 bg-transparent px-6 text-white hover:bg-white/10">
|
||||
<Link href="/#request-machine">Get Free Machine</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<PublicSurface>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">Our services around the machines</h2>
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Repairs & Maintenance",
|
||||
body: "Expert service to keep machines running smoothly.",
|
||||
href: "/services/repairs",
|
||||
cta: "Repairs",
|
||||
},
|
||||
{
|
||||
icon: MonitorSmartphone,
|
||||
title: "Moving Services",
|
||||
body: "Safe and efficient vending machine relocation when layouts change.",
|
||||
href: "/services/moving",
|
||||
cta: "Moving",
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: "Parts & Manuals",
|
||||
body: "Replacement parts, manuals, and support resources for major brands.",
|
||||
href: "/manuals",
|
||||
cta: "Manuals",
|
||||
},
|
||||
].map((service) => (
|
||||
<PublicInset key={service.title} className="h-full p-5 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<service.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-semibold">{service.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{service.body}</p>
|
||||
<Button asChild variant="outline" className="mt-5 h-10 rounded-full px-4">
|
||||
<Link href={service.href}>{service.cta}</Link>
|
||||
</Button>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<PublicSurface>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance">Machine features customers ask about most</h2>
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
[Package, "Healthy Options", "Wide selection of nutritious snacks and beverages."],
|
||||
[CreditCard, "Cashless Payment", "Accept cards and mobile payments with modern hardware."],
|
||||
[Wrench, "Easy Maintenance", "Remote monitoring and quick service response when issues come up."],
|
||||
[MonitorSmartphone, "Smart Technology", "Track inventory and sales with better visibility."],
|
||||
].map(([Icon, title, body]) => (
|
||||
<PublicInset key={title} className="h-full p-5 text-center">
|
||||
<Icon className="mx-auto h-10 w-10 text-primary" />
|
||||
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{body}</p>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ImageCarousel } from '@/components/image-carousel';
|
|||
interface CleanContentOptions {
|
||||
imageMapping?: ImageMapping;
|
||||
pageTitle?: string; // Page title to check for duplicates
|
||||
prioritizeFirstImage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -26,8 +27,9 @@ export function cleanWordPressContent(
|
|||
return [];
|
||||
}
|
||||
|
||||
const { imageMapping = {}, pageTitle } = options;
|
||||
const { imageMapping = {}, pageTitle, prioritizeFirstImage = false } = options;
|
||||
const components: ReactNode[] = [];
|
||||
let hasPrioritizedImage = false;
|
||||
|
||||
try {
|
||||
// Update location links to new format (currently just returns HTML unchanged)
|
||||
|
|
@ -837,6 +839,7 @@ export function cleanWordPressContent(
|
|||
height={constrainedHeight}
|
||||
className="object-contain w-full h-auto"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
|
||||
priority={prioritizeFirstImage && !hasPrioritizedImage}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative aspect-video w-full bg-muted">
|
||||
|
|
@ -846,12 +849,16 @@ export function cleanWordPressContent(
|
|||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
|
||||
priority={prioritizeFirstImage && !hasPrioritizedImage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (prioritizeFirstImage && !hasPrioritizedImage) {
|
||||
hasPrioritizedImage = true;
|
||||
}
|
||||
} else if (element.type === 'image-group') {
|
||||
// Render grouped images side-by-side
|
||||
const groupImages = element.images;
|
||||
|
|
@ -916,6 +923,7 @@ export function cleanWordPressContent(
|
|||
height={constrainedHeight}
|
||||
className="object-contain w-full h-auto"
|
||||
sizes={imageCount === 2 ? "(max-width: 768px) 100vw, 50vw" : imageCount === 3 ? "(max-width: 768px) 100vw, 33vw" : "(max-width: 768px) 100vw, 25vw"}
|
||||
priority={prioritizeFirstImage && !hasPrioritizedImage}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative aspect-video w-full bg-muted">
|
||||
|
|
@ -925,6 +933,7 @@ export function cleanWordPressContent(
|
|||
fill
|
||||
className="object-contain"
|
||||
sizes={imageCount === 2 ? "(max-width: 768px) 100vw, 50vw" : imageCount === 3 ? "(max-width: 768px) 100vw, 33vw" : "(max-width: 768px) 100vw, 25vw"}
|
||||
priority={prioritizeFirstImage && !hasPrioritizedImage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -935,6 +944,9 @@ export function cleanWordPressContent(
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
if (prioritizeFirstImage && !hasPrioritizedImage) {
|
||||
hasPrioritizedImage = true;
|
||||
}
|
||||
} else if (element.type === 'image') {
|
||||
const imagePath = getImagePath(element.src, imageMapping);
|
||||
const alt = getImageAlt(element.src, imageMapping, element.alt);
|
||||
|
|
@ -1028,6 +1040,7 @@ export function cleanWordPressContent(
|
|||
height={constrainedHeight}
|
||||
className="object-contain w-full h-auto"
|
||||
sizes={hasColumn33 ? "(max-width: 768px) 100vw, 33vw" : hasColumn50 ? "(max-width: 768px) 100vw, 50vw" : hasColumn66 ? "(max-width: 768px) 100vw, 66vw" : "(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"}
|
||||
priority={prioritizeFirstImage && !hasPrioritizedImage}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative aspect-video w-full bg-muted">
|
||||
|
|
@ -1037,12 +1050,16 @@ export function cleanWordPressContent(
|
|||
fill
|
||||
className="object-contain"
|
||||
sizes={hasColumn33 ? "(max-width: 768px) 100vw, 33vw" : hasColumn50 ? "(max-width: 768px) 100vw, 50vw" : hasColumn66 ? "(max-width: 768px) 100vw, 66vw" : "(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"}
|
||||
priority={prioritizeFirstImage && !hasPrioritizedImage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (prioritizeFirstImage && !hasPrioritizedImage) {
|
||||
hasPrioritizedImage = true;
|
||||
}
|
||||
} else if (element.type === 'paragraph') {
|
||||
// Process paragraph HTML to handle nested tags (em, strong, links, etc.)
|
||||
let processedHtml = element.content;
|
||||
|
|
@ -1204,4 +1221,3 @@ export function cleanWordPressContent(
|
|||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export class eBayAPIClient {
|
|||
this.appId = appId || process.env.EBAY_APP_ID || ''
|
||||
this.affiliateCampaignId = affiliateCampaignId || process.env.EBAY_AFFILIATE_CAMPAIGN_ID || ''
|
||||
|
||||
if (!this.appId) {
|
||||
if (!this.appId && process.env.NODE_ENV === 'development' && process.env.EBAY_DEBUG === 'true') {
|
||||
console.warn('eBay App ID not configured. Set EBAY_APP_ID environment variable.')
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +110,10 @@ export class eBayAPIClient {
|
|||
this.token = this.appId
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(this.appId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached response or fetch from API
|
||||
|
|
@ -383,4 +387,4 @@ export class eBayAPIClient {
|
|||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ebayClient = new eBayAPIClient()
|
||||
export const ebayClient = new eBayAPIClient()
|
||||
|
|
|
|||
41
lib/manuals-paths.ts
Normal file
41
lib/manuals-paths.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const KNOWN_WORKSPACE_MANUALS_ROOT =
|
||||
"/Users/matthewcall/Documents/VS Code Projects/Rocky Mountain Vending/manuals-data"
|
||||
|
||||
function resolveManualsDataRoot() {
|
||||
const candidates = [
|
||||
process.env.MANUALS_DATA_ROOT,
|
||||
join(process.cwd(), "..", "manuals-data"),
|
||||
KNOWN_WORKSPACE_MANUALS_ROOT,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return join(process.cwd(), "..", "manuals-data")
|
||||
}
|
||||
|
||||
export function getManualsDataRoot() {
|
||||
return resolveManualsDataRoot()
|
||||
}
|
||||
|
||||
export function getManualsFilesRoot() {
|
||||
return join(resolveManualsDataRoot(), "manuals")
|
||||
}
|
||||
|
||||
export function getManualsThumbnailsRoot() {
|
||||
return join(resolveManualsDataRoot(), "thumbnails")
|
||||
}
|
||||
|
||||
export function getManualsMetadataRoot() {
|
||||
return join(resolveManualsDataRoot(), "data")
|
||||
}
|
||||
|
||||
export function getManualsManufacturerInfoRoot() {
|
||||
return join(resolveManualsDataRoot(), "manufacturer-info")
|
||||
}
|
||||
|
|
@ -74,7 +74,11 @@ export function getManualUrl(manual: Manual): string {
|
|||
return `${cleanBaseUrl}/${encodedPath}`
|
||||
}
|
||||
|
||||
// Use local static path for GHL static hosting
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `/api/manuals/${encodedPath}`
|
||||
}
|
||||
|
||||
// Use local static path for exported hosting
|
||||
return `/manuals/${encodedPath}`
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +132,10 @@ export function getThumbnailUrl(manual: Manual): string | null {
|
|||
return `${cleanBaseUrl}/${encodedPath}`
|
||||
}
|
||||
|
||||
// Use local static path for GHL static hosting
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `/api/thumbnails/${encodedPath}`
|
||||
}
|
||||
|
||||
// Use local static path for exported hosting
|
||||
return `/thumbnails/${encodedPath}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { join } from 'path'
|
|||
import type { Manual, ManualGroup } from './manuals-types'
|
||||
import { detectMachineType, detectManufacturer, MANUFACTURERS } from './manuals-config'
|
||||
import { getMachineTypeFromModel, extractModelNumberFromFilename } from './model-type-mapping'
|
||||
import { getManualsFilesRoot, getManualsMetadataRoot, getManualsThumbnailsRoot, getManualsManufacturerInfoRoot } from './manuals-paths'
|
||||
export type { Manual, ManualGroup } from './manuals-types'
|
||||
|
||||
/**
|
||||
|
|
@ -17,7 +18,7 @@ function loadCommonNames(): Map<string, string[]> {
|
|||
|
||||
try {
|
||||
// Try to load from project root manuals-data/data/manuals.json
|
||||
const dataPath = join(process.cwd(), '..', 'manuals-data', 'data', 'manuals.json')
|
||||
const dataPath = join(getManualsMetadataRoot(), 'manuals.json')
|
||||
if (existsSync(dataPath)) {
|
||||
const data = JSON.parse(readFileSync(dataPath, 'utf-8'))
|
||||
|
||||
|
|
@ -52,9 +53,11 @@ function loadCommonNames(): Map<string, string[]> {
|
|||
* Supports both flat and nested directory structures
|
||||
*/
|
||||
export async function scanManuals(): Promise<Manual[]> {
|
||||
const manualsDir = join(process.cwd(), '..', 'manuals-data', 'manuals')
|
||||
const manualsDir = getManualsFilesRoot()
|
||||
const manuals: Manual[] = []
|
||||
const commonNamesMap = loadCommonNames()
|
||||
const shouldLogScanWarnings =
|
||||
process.env.NODE_ENV === 'development' && process.env.MANUALS_DEBUG === 'true'
|
||||
|
||||
/**
|
||||
* Recursively scan a directory for PDF files
|
||||
|
|
@ -89,7 +92,7 @@ export async function scanManuals(): Promise<Manual[]> {
|
|||
content.includes('The resource cannot be found') ||
|
||||
content.includes('404') ||
|
||||
content.includes('Not Found')) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (shouldLogScanWarnings) {
|
||||
console.warn(`Skipping error page: ${entryPath} (${stats.size} bytes)`)
|
||||
}
|
||||
continue
|
||||
|
|
@ -99,7 +102,7 @@ export async function scanManuals(): Promise<Manual[]> {
|
|||
continue
|
||||
}
|
||||
// If it's small but not HTML, still skip it (likely corrupted or placeholder)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (shouldLogScanWarnings) {
|
||||
console.warn(`Skipping small file: ${entryPath} (${stats.size} bytes)`)
|
||||
}
|
||||
continue
|
||||
|
|
@ -156,7 +159,7 @@ export async function scanManuals(): Promise<Manual[]> {
|
|||
|
||||
// Check if thumbnail exists (try multiple filename variations)
|
||||
const thumbnailVariations = getThumbnailPathVariations(entryRelativePath)
|
||||
const thumbnailsDir = join(process.cwd(), '..', 'manuals-data', 'thumbnails')
|
||||
const thumbnailsDir = getManualsThumbnailsRoot()
|
||||
let thumbnailPath: string | undefined = undefined
|
||||
|
||||
for (const variation of thumbnailVariations) {
|
||||
|
|
@ -447,7 +450,7 @@ function loadMDBCapableMachines(): { [manufacturer: string]: Set<string> } {
|
|||
mdbMachinesCache = {}
|
||||
|
||||
try {
|
||||
const manufacturersPath = join(process.cwd(), '..', 'manuals-data', 'manufacturer-info', 'manufacturers.md')
|
||||
const manufacturersPath = join(getManualsManufacturerInfoRoot(), 'manufacturers.md')
|
||||
const content = readFileSync(manufacturersPath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
|
||||
|
|
@ -904,4 +907,3 @@ export function filterManuals(
|
|||
|
||||
// getManualUrl is exported from manuals-types.ts for client components
|
||||
export { getManualUrl } from './manuals-types'
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { getManualsManufacturerInfoRoot } from './manuals-paths'
|
||||
|
||||
// Manufacturer name normalization mapping
|
||||
const MANUFACTURER_NORMALIZATION: { [key: string]: string } = {
|
||||
|
|
@ -91,8 +92,7 @@ function loadModelTypeMap(): { [manufacturer: string]: { [modelNumber: string]:
|
|||
modelTypeMap = {}
|
||||
|
||||
try {
|
||||
// Path to manufacturers.md relative to project root
|
||||
const manufacturersPath = join(process.cwd(), '..', 'Manufacturer info', 'manufacturers.md')
|
||||
const manufacturersPath = join(getManualsManufacturerInfoRoot(), 'manufacturers.md')
|
||||
const content = readFileSync(manufacturersPath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
|
||||
|
|
@ -273,4 +273,3 @@ export function extractModelNumberFromFilename(filename: string): string | null
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -185,11 +185,11 @@ function normalizeLeadPayload(
|
|||
const timestamp = coerceString(body.timestamp) || new Date().toISOString();
|
||||
const page = coerceString(body.page);
|
||||
const consentPayload = createSmsConsentPayload({
|
||||
serviceTextConsent: body.serviceTextConsent,
|
||||
marketingTextConsent: body.marketingTextConsent ?? body.marketingConsent,
|
||||
consentVersion: body.consentVersion,
|
||||
consentCapturedAt: body.consentCapturedAt ?? timestamp,
|
||||
consentSourcePage: body.consentSourcePage ?? page,
|
||||
serviceTextConsent: coerceBoolean(body.serviceTextConsent),
|
||||
marketingTextConsent: coerceBoolean(body.marketingTextConsent ?? body.marketingConsent),
|
||||
consentVersion: coerceString(body.consentVersion) || undefined,
|
||||
consentCapturedAt: coerceString(body.consentCapturedAt) || timestamp,
|
||||
consentSourcePage: coerceString(body.consentSourcePage) || page,
|
||||
});
|
||||
const kind = kindOverride || (isRequestMachinePayload(body) ? "request-machine" : "contact");
|
||||
const shared = {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const REQUIRED_ENV_GROUPS = [
|
|||
label: "Voice and chat",
|
||||
keys: ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET", "XAI_API_KEY", "VOICE_ASSISTANT_SITE_URL"],
|
||||
},
|
||||
{
|
||||
label: "Manual asset delivery",
|
||||
keys: ["NEXT_PUBLIC_MANUALS_BASE_URL", "NEXT_PUBLIC_THUMBNAILS_BASE_URL", "CLOUDFLARE_R2_ENDPOINT", "R2_MANUALS_BUCKET", "R2_THUMBNAILS_BUCKET"],
|
||||
},
|
||||
{
|
||||
label: "Admin and auth",
|
||||
keys: ["ADMIN_EMAIL", "ADMIN_PASSWORD", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"],
|
||||
|
|
@ -23,13 +27,7 @@ const REQUIRED_ENV_GROUPS = [
|
|||
},
|
||||
]
|
||||
|
||||
const OPTIONAL_ENV_GROUPS = [
|
||||
{
|
||||
label: "Manual asset delivery",
|
||||
keys: ["NEXT_PUBLIC_MANUALS_BASE_URL", "NEXT_PUBLIC_THUMBNAILS_BASE_URL"],
|
||||
note: "Falling back to the site's local /manuals and /thumbnails paths.",
|
||||
},
|
||||
]
|
||||
const OPTIONAL_ENV_GROUPS = []
|
||||
|
||||
const IGNORED_HANDOFF_ENV = {
|
||||
GHL_API_TOKEN: "not used by the current code path",
|
||||
|
|
@ -104,6 +102,13 @@ function hasVoiceRecordingConfig() {
|
|||
].every(Boolean)
|
||||
}
|
||||
|
||||
function hasManualStorageCredentials() {
|
||||
return [
|
||||
readValue("CLOUDFLARE_R2_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY"),
|
||||
readValue("CLOUDFLARE_R2_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_KEY"),
|
||||
].every(Boolean)
|
||||
}
|
||||
|
||||
function heading(title) {
|
||||
console.log(`\n== ${title} ==`)
|
||||
}
|
||||
|
|
@ -182,6 +187,12 @@ function main() {
|
|||
console.log(`${group.label}: fallback in use`)
|
||||
}
|
||||
|
||||
if (!hasManualStorageCredentials()) {
|
||||
failures.push("Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release.")
|
||||
} else {
|
||||
console.log("Manual asset storage credentials: present")
|
||||
}
|
||||
|
||||
const recordingRequested = readValue("VOICE_RECORDING_ENABLED").toLowerCase()
|
||||
if (recordingRequested === "true" && !hasVoiceRecordingConfig()) {
|
||||
failures.push(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import { join } from 'path'
|
|||
import { existsSync } from 'fs'
|
||||
|
||||
const PROJECT_ROOT = join(process.cwd(), '..')
|
||||
const MANUALS_SOURCE = join(PROJECT_ROOT, 'manuals-data', 'manuals')
|
||||
const MANUALS_DATA_ROOT = process.env.MANUALS_DATA_ROOT ||
|
||||
(existsSync(join(PROJECT_ROOT, 'manuals-data'))
|
||||
? join(PROJECT_ROOT, 'manuals-data')
|
||||
: '/Users/matthewcall/Documents/VS Code Projects/Rocky Mountain Vending/manuals-data')
|
||||
const MANUALS_SOURCE = join(MANUALS_DATA_ROOT, 'manuals')
|
||||
const MANUALS_PUBLIC = join(process.cwd(), 'public', 'manuals')
|
||||
|
||||
async function syncManuals() {
|
||||
|
|
@ -68,4 +72,3 @@ async function syncManuals() {
|
|||
syncManuals()
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,12 @@ const MANUALS_BUCKET = process.env.R2_MANUALS_BUCKET || 'vending-vm-manuals';
|
|||
const THUMBNAILS_BUCKET = process.env.R2_THUMBNAILS_BUCKET || 'vending-vm-thumbnails';
|
||||
|
||||
// Source directories (relative to project root)
|
||||
const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'manuals');
|
||||
const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'thumbnails');
|
||||
const MANUALS_DATA_ROOT = process.env.MANUALS_DATA_ROOT ||
|
||||
(existsSync(join(process.cwd(), '..', 'manuals-data'))
|
||||
? join(process.cwd(), '..', 'manuals-data')
|
||||
: '/Users/matthewcall/Documents/VS Code Projects/Rocky Mountain Vending/manuals-data');
|
||||
const MANUALS_SOURCE = join(MANUALS_DATA_ROOT, 'manuals');
|
||||
const THUMBNAILS_SOURCE = join(MANUALS_DATA_ROOT, 'thumbnails');
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
|
|
|
|||
Loading…
Reference in a new issue