deploy: unify public UI and mobile chat

This commit is contained in:
DMleadgen 2026-04-04 07:46:46 -06:00
parent 1c1c01069c
commit 0be731e474
Signed by: matt
GPG key ID: C2720CF8CD701894
22 changed files with 1837 additions and 1408 deletions

View file

@ -10,20 +10,20 @@
--card-foreground: oklch(0.178 0.014 275.627);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.178 0.014 275.627);
--primary: #29A047; /* Primary brand color (green from logo) */
--primary: #29a047; /* Primary brand color (green from logo) */
--primary-foreground: oklch(0.989 0.003 106.423);
--primary-dark: #1d7a35; /* Darker primary for gradients and hover states */
--secondary: #54595F; /* Secondary color (gray) */
--secondary: #54595f; /* Secondary color (gray) */
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.961 0.004 106.423);
--muted-foreground: oklch(0.556 0.014 275.627);
--accent: #C4142C; /* Accent color (red - matches link hover) */
--accent: #c4142c; /* Accent color (red - matches link hover) */
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0.004 106.423);
--input: oklch(0.922 0.004 106.423);
--ring: #29A047; /* Primary color for focus rings */
--ring: #29a047; /* Primary color for focus rings */
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@ -38,26 +38,33 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Link Colors - Master Style Guide */
--link-color: var(--foreground);
--link-hover-color: #c4142c; /* Red: rgb(196, 20, 44) */
--link-hover-color-dark: #a01020; /* Darker red for gradients and hover states */
--link-hover-bg: rgba(196, 20, 44, 0.1);
--header-bg: #ffffff;
--footer-bg: #fef3e0;
--shadow: 0 2px 4px rgba(0,0,0,0.05);
--header-bg: #ffffff;
--footer-bg: #fef3e0;
--shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
/* Custom brand colors */
--yellow: #FCBA09;
--orange: #F79611;
--mountain-bubbles: #FCBA0924; /* Yellow with transparency */
/* Custom brand colors */
--yellow: #fcba09;
--orange: #f79611;
--mountain-bubbles: #fcba0924; /* Yellow with transparency */
--public-shell-max: 80rem;
--public-section-space: clamp(4.5rem, 7vw, 7rem);
--public-surface-radius: 2rem;
--public-inset-radius: 1.5rem;
--public-surface-shadow: 0 22px 56px rgba(15, 23, 42, 0.08);
--public-surface-shadow-hover: 0 28px 72px rgba(15, 23, 42, 0.12);
--header-height: 5.25rem;
/* Increased spacing variables */
--spacing-xs: 0.75rem;
--spacing-sm: 1.25rem;
}
/* Increased spacing variables */
--spacing-xs: 0.75rem;
--spacing-sm: 1.25rem;
}
.dark {
--background: oklch(0.145 0.01 275.627);
@ -92,7 +99,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
/* Link Colors - Dark Mode */
--link-color: var(--foreground);
--link-hover-color: #ff4d6d; /* Lighter red for dark mode visibility */
@ -101,8 +108,11 @@
}
@theme inline {
--font-sans: var(--font-inter), "Inter", "Inter Fallback", system-ui, -apple-system, sans-serif;
--font-mono: var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", monospace;
--font-sans:
var(--font-inter), "Inter", "Inter Fallback", system-ui, -apple-system,
sans-serif;
--font-mono:
var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -147,44 +157,70 @@
}
body {
@apply bg-background text-foreground;
font-family: var(--font-inter), "Inter", system-ui, -apple-system, sans-serif;
font-family:
var(--font-inter),
"Inter",
system-ui,
-apple-system,
sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
scroll-behavior: smooth; /* Added smooth scroll behavior for Apple-like experience */
scroll-padding-top: calc(var(--header-height) + 1.25rem);
}
/* Global Link Styling - Master Style Guide */
/* All hyperlinks should highlight in red on hover */
a {
color: var(--link-color);
text-decoration: none;
transition: color 0.2s ease, background-color 0.2s ease;
text-decoration-color: transparent;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.18em;
transition:
color 0.2s ease,
text-decoration-color 0.2s ease,
opacity 0.2s ease;
}
a:hover,
a:focus {
color: var(--link-hover-color);
background-color: var(--link-hover-bg);
background-color: transparent;
}
a:active {
color: var(--link-hover-color);
}
/* Next.js Link components inherit the same styling */
a[href] {
color: var(--link-color);
transition: color 0.2s ease, background-color 0.2s ease;
transition:
color 0.2s ease,
text-decoration-color 0.2s ease,
opacity 0.2s ease;
}
a[href]:hover,
a[href]:focus {
color: var(--link-hover-color);
background-color: var(--link-hover-bg);
background-color: transparent;
}
a:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
}
/* Hide Next.js dev tools portal */
nextjs-portal,
nextjs-portal * {
@ -193,7 +229,7 @@
opacity: 0 !important;
pointer-events: none !important;
}
/* WordPress content styling */
.prose strong,
article strong,
@ -201,13 +237,13 @@
font-weight: 600;
color: var(--foreground);
}
.prose em,
article em,
.wordpress-content em {
font-style: italic;
}
.prose ul,
.prose ol,
article ul,
@ -218,7 +254,7 @@
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.prose li,
article li,
.wordpress-content li {
@ -239,6 +275,26 @@
color: var(--link-hover-color);
}
.public-page {
margin-inline: auto;
width: 100%;
max-width: var(--public-shell-max);
padding-inline: 1rem;
padding-block: 2.5rem 3.75rem;
}
@media (min-width: 640px) {
.public-page {
padding-inline: 1.25rem;
}
}
@media (min-width: 768px) {
.public-page {
padding-block: 3rem 4.5rem;
}
}
.wordpress-content h1,
.wordpress-content h2,
.wordpress-content h3,
@ -271,7 +327,7 @@
width: 100%;
height: auto;
}
.wordpress-content > div img,
.wordpress-content figure img {
@apply mx-auto block;
@ -285,44 +341,49 @@
.wordpress-content > div {
@apply space-y-6;
}
/* Visual Separation for Page Sections */
section {
@apply relative;
}
/* Main content wrapper for visual separation from header */
main {
@apply relative;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
background: var(--background);
}
/* Improved spacing and readability */
p {
line-height: 1.7;
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
letter-spacing: -0.02em;
line-height: 1.2;
color: var(--foreground);
}
/* Footer Styling - Only apply to top-level footer, not article footers */
body > div > footer {
background: var(--footer-bg);
box-shadow: var(--shadow);
}
/* Hide scrollbar for horizontal scrolling galleries */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
display: none; /* Chrome, Safari and Opera */
}
/* Focus visible styling for keyboard navigation - Following Vercel Web Design Guidelines */

View file

@ -1,80 +1,44 @@
export const dynamic = "force-dynamic"
import { existsSync } from 'fs'
import { join } from 'path'
import { Metadata } from 'next'
import { businessConfig } from '@/lib/seo-config'
import { ManualsPageExperience } from '@/components/manuals-page-experience'
import { listConvexManuals } from '@/lib/convex-service'
import { scanManuals } from '@/lib/manuals'
import { selectManualsForSite } from '@/lib/manuals-site-selection'
import { generateStructuredData } from '@/lib/seo'
import { getManualsThumbnailsRoot } from '@/lib/manuals-paths'
import { existsSync } from "fs"
import { join } from "path"
import { Metadata } from "next"
import { businessConfig } from "@/lib/seo-config"
import { ManualsPageExperience } from "@/components/manuals-page-experience"
import { listConvexManuals } from "@/lib/convex-service"
import { scanManuals } from "@/lib/manuals"
import { selectManualsForSite } from "@/lib/manuals-site-selection"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
export const metadata: Metadata = {
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending',
export const metadata: Metadata = generateSEOMetadata({
title: "Vending Machine Manuals | Rocky Mountain Vending",
description:
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.',
"Browse vending machine manuals, service guides, and support documentation for snack, beverage, combo, coffee, and food machines.",
path: "/manuals",
keywords: [
'vending machine manuals',
'vending machine PDF',
'vending machine service manual',
'vending machine parts catalog',
'vending machine repair manual',
'vending machine installation guide',
'vending machine troubleshooting guide',
'Royal Vendors manual',
'Dixie-Narco manual',
'Vendo manual',
'Crane vending manual',
'BevMax manual',
'Merchant Series manual',
'AP vending manual',
'GPL vending manual',
'Seaga vending manual',
'USI vending manual',
'snack machine manual',
'beverage machine manual',
'combo vending machine manual',
'coffee vending machine manual',
'food vending machine manual',
'frozen food vending manual',
'vending machine parts',
'vending machine replacement parts',
'vending machine wiring diagram',
'vending machine maintenance',
"vending machine manuals",
"vending machine PDF",
"vending machine service manual",
"vending machine repair manual",
"vending machine troubleshooting guide",
"Royal Vendors manual",
"Dixie-Narco manual",
"Vendo manual",
"Crane vending manual",
"Seaga vending manual",
],
openGraph: {
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending',
description:
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from leading manufacturers. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines.',
type: 'website',
url: `${businessConfig.website}/manuals`,
images: [
{
url: `${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`,
width: 926,
height: 1024,
alt: 'Rocky Mountain Vending Manuals',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Vending Machine Manuals | Download PDF Guides',
description:
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, and more. Find service manuals, parts catalogs, installation instructions, and troubleshooting guides.',
},
alternates: {
canonical: `${businessConfig.website}/manuals`,
},
}
})
export default async function ManualsPage() {
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
const convexManuals = await listConvexManuals()
const allManuals = convexManuals.length > 0 ? convexManuals : await scanManuals()
let manuals = convexManuals.length > 0 ? convexManuals : selectManualsForSite(allManuals).manuals
const allManuals =
convexManuals.length > 0 ? convexManuals : await scanManuals()
let manuals =
convexManuals.length > 0
? convexManuals
: selectManualsForSite(allManuals).manuals
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
const thumbnailsRoot = getManualsThumbnailsRoot()
@ -83,43 +47,43 @@ export default async function ManualsPage() {
return manual
}
const relativeThumbnailPath = manual.thumbnailUrl.includes('/thumbnails/')
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, '')
const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/")
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "")
: manual.thumbnailUrl
return existsSync(join(thumbnailsRoot, relativeThumbnailPath))
? manual
: { ...manual, thumbnailUrl: undefined }
})
// Generate structured data for SEO
const structuredData = generateStructuredData({
title: 'Vending Machine Manuals',
title: "Vending Machine Manuals",
description:
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.',
"Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.",
url: `${businessConfig.website}/manuals`,
type: 'WebPage',
type: "WebPage",
})
// Add CollectionPage schema for better SEO
const collectionSchema = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Vending Machine Manuals',
"@context": "https://schema.org",
"@type": "CollectionPage",
name: "Vending Machine Manuals",
description:
'A comprehensive collection of vending machine manuals, service guides, and parts documentation from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Includes service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. Many manuals feature available replacement parts with direct purchase links.',
"A comprehensive collection of vending machine manuals, service guides, and parts documentation from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Includes service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. Many manuals feature available replacement parts with direct purchase links.",
url: `${businessConfig.website}/manuals`,
mainEntity: {
'@type': 'ItemList',
"@type": "ItemList",
numberOfItems: manuals.length,
itemListElement: manuals.slice(0, 50).map((manual, index) => ({
'@type': 'ListItem',
"@type": "ListItem",
position: index + 1,
item: {
'@type': 'DigitalDocument',
name: manual.filename.replace(/\.pdf$/i, ''),
"@type": "DigitalDocument",
name: manual.filename.replace(/\.pdf$/i, ""),
description: `${manual.manufacturer} ${manual.category} Manual`,
encodingFormat: 'application/pdf',
encodingFormat: "application/pdf",
},
})),
},
@ -135,7 +99,7 @@ export default async function ManualsPage() {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
/>
<div className="container mx-auto px-4 py-8 md:py-12">
<div className="public-page">
<ManualsPageExperience initialManuals={manuals} />
</div>
</>

View file

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

View file

@ -100,7 +100,7 @@ export default function ServiceAreasPage() {
)
return (
<div className="container mx-auto px-4 py-10 md:py-14">
<div className="public-page">
<PublicPageHeader
align="center"
eyebrow="Service Coverage"
@ -129,8 +129,8 @@ export default function ServiceAreasPage() {
Don&apos;t see your city yet?
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
If you&apos;re close to one of our current routes, we may still
be able to help. Reach out and we&apos;ll confirm whether your
If you&apos;re close to one of our current routes, we may still be
able to help. Reach out and we&apos;ll confirm whether your
location fits our current coverage and service schedule.
</p>
</div>
@ -169,8 +169,8 @@ export default function ServiceAreasPage() {
</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 help Utah businesses keep vending available
without having to manage the machines themselves.
parts help, we help Utah businesses keep vending available without
having to manage the machines themselves.
</p>
</div>

View file

@ -23,7 +23,7 @@ export const metadata: Metadata = generateSEOMetadata({
export default function MovingServicesPage() {
return (
<div className="container mx-auto max-w-6xl px-4 py-10 md:py-14">
<div className="public-page">
<Breadcrumbs
className="mb-6"
items={[
@ -40,30 +40,28 @@ export default function MovingServicesPage() {
{/* Introduction Section */}
<section className="mb-12">
<Card className="border-border/50 shadow-md">
<CardContent className="p-6 md:p-8">
<div className="prose prose-lg max-w-none">
<p className="text-muted-foreground leading-relaxed mb-4">
At Rocky Mountain Vending LLC, we specialize in the safe and
efficient relocation of vending machines of all types and sizes
from compact snack machines to full-size refrigerated beverage
and combo units. Whether you're rearranging equipment within a
building, moving to a new location, or removing an old machine,
our experienced team handles every detail to minimize downtime
and protect your investment.
</p>
<p className="text-muted-foreground leading-relaxed mb-4">
Vending machines are heavy (often 400900+ lbs), delicate, and
require specialized handling to avoid damage to internal
components like compressors, electronics, glass fronts, or
refrigeration systems. Attempting a DIY move can lead to costly
repairs, injuries, or property damage. We use proven techniques
and professional-grade equipment to ensure a smooth, damage-free
process every time.
</p>
</div>
</CardContent>
</Card>
<PublicSurface className="p-6 md:p-8">
<div className="prose prose-lg max-w-none">
<p className="text-muted-foreground leading-relaxed mb-4">
At Rocky Mountain Vending LLC, we specialize in the safe and
efficient relocation of vending machines of all types and sizes
from compact snack machines to full-size refrigerated beverage and
combo units. Whether you're rearranging equipment within a
building, moving to a new location, or removing an old machine,
our experienced team handles every detail to minimize downtime and
protect your investment.
</p>
<p className="text-muted-foreground leading-relaxed mb-4">
Vending machines are heavy (often 400900+ lbs), delicate, and
require specialized handling to avoid damage to internal
components like compressors, electronics, glass fronts, or
refrigeration systems. Attempting a DIY move can lead to costly
repairs, injuries, or property damage. We use proven techniques
and professional-grade equipment to ensure a smooth, damage-free
process every time.
</p>
</div>
</PublicSurface>
</section>
{/* Image Gallery Section */}
@ -75,10 +73,7 @@ export default function MovingServicesPage() {
{/* Image 1 */}
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
<CardContent className="p-0">
<div
className="relative w-full bg-muted"
style={{ height: "600px" }}
>
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
<Image
src="/images/vending-machine-moving-service-1.png"
alt="Vending machine securely packaged for transport with dark green protective blankets, clear shrink wrap, bright yellow straps, and yellow corner protectors on wooden pallet - professional moving service in Utah"
@ -102,10 +97,7 @@ export default function MovingServicesPage() {
{/* Image 2 */}
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
<CardContent className="p-0">
<div
className="relative w-full bg-muted"
style={{ height: "600px" }}
>
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
<Image
src="/images/vending-machine-moving-service-2.png"
alt="Vending machine wrapped in dark blue protective blankets and clear shrink wrap with yellow straps, foam padding, and safety markings secured on wooden pallets for safe transport"
@ -129,10 +121,7 @@ export default function MovingServicesPage() {
{/* Image 3 */}
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
<CardContent className="p-0">
<div
className="relative w-full bg-muted"
style={{ height: "600px" }}
>
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
<Image
src="/images/vending-machine-moving-service-3.webp"
alt="Utah vending machine moving professionals transporting commercial vending equipment with secure transport methods and fully insured relocation services"
@ -292,57 +281,52 @@ export default function MovingServicesPage() {
<h2 className="text-3xl font-bold mb-8 tracking-tight text-balance">
Why Choose Us for Your Vending Move?
</h2>
<Card className="border-border/70">
<CardContent className="p-6 md:p-8">
<ul className="space-y-4">
<li className="flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">
Years of hands-on vending industry experience
</strong>{" "}
we understand the unique vulnerabilities of snack, drink,
combo, and refrigerated machines.
</span>
</li>
<li className="flex items-start gap-3">
<Shield className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">Fully insured</strong> for
complete peace of mind.
</span>
</li>
<li className="flex items-start gap-3">
<Clock className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">
Minimal disruption:
</strong>{" "}
Fast, coordinated service scheduled around your business
hours.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">
One-machine or multi-machine jobs
</strong>{" "}
handled efficiently.
</span>
</li>
<li className="flex items-start gap-3">
<Shield className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">
Commitment to safety
</strong>{" "}
for our team, your staff, and your equipment.
</span>
</li>
</ul>
</CardContent>
</Card>
<PublicSurface className="p-6 md:p-8">
<ul className="space-y-4">
<li className="flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">
Years of hands-on vending industry experience
</strong>{" "}
we understand the unique vulnerabilities of snack, drink,
combo, and refrigerated machines.
</span>
</li>
<li className="flex items-start gap-3">
<Shield className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">Fully insured</strong> for
complete peace of mind.
</span>
</li>
<li className="flex items-start gap-3">
<Clock className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">Minimal disruption:</strong>{" "}
Fast, coordinated service scheduled around your business hours.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">
One-machine or multi-machine jobs
</strong>{" "}
handled efficiently.
</span>
</li>
<li className="flex items-start gap-3">
<Shield className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">
<strong className="text-foreground">
Commitment to safety
</strong>{" "}
for our team, your staff, and your equipment.
</span>
</li>
</ul>
</PublicSurface>
</section>
{/* CTA Section */}

View file

@ -1,6 +1,9 @@
import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import {
generateRegistryMetadata,
generateRegistryStructuredData,
} from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { ServicesSection } from "@/components/services-section"
@ -18,7 +21,11 @@ import {
import { RepairsImageCarousel } from "@/components/repairs-image-carousel"
import { ContactForm } from "@/components/forms/contact-form"
import { Breadcrumbs } from "@/components/breadcrumbs"
import { PublicInset, PublicSurface } from "@/components/public-surface"
import {
PublicInset,
PublicPageHeader,
PublicSurface,
} from "@/components/public-surface"
import Image from "next/image"
import Link from "next/link"
import { ArrowRight } from "lucide-react"
@ -38,14 +45,10 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
return generateSEOMetadata({
title: page.title || "Vending Machine Repairs",
description: page.seoDescription || page.excerpt || "",
excerpt: page.excerpt,
return generateRegistryMetadata("repairs", {
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
path: "/services/repairs",
})
}
@ -140,28 +143,10 @@ export default async function RepairsPage() {
<p className="text-muted-foreground">No content available.</p>
)
let structuredData
try {
structuredData = generateStructuredData({
title: page.title || "Vending Machine Repairs",
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/services/repairs/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: "WebPage",
})
} catch (e) {
structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
headline: page.title || "Vending Machine Repairs",
description: page.seoDescription || "",
url: `https://rockymountainvending.com/services/repairs/`,
}
}
const structuredData = generateRegistryStructuredData("repairs", {
datePublished: page.date,
dateModified: page.modified || page.date,
})
return (
<>
@ -179,11 +164,15 @@ export default async function RepairsPage() {
{ label: "Repairs", href: "/services/repairs" },
]}
/>
<header className="mb-8 text-center">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
{page.title || "Vending Machine Repairs and Service"}
</h1>
<p className="text-lg text-muted-foreground max-w-3xl mx-auto leading-relaxed mb-8">
<PublicPageHeader
align="center"
className="mb-8"
title={page.title || "Vending Machine Repairs and Service"}
description={
"Rocky Mountain Vending delivers expert vending machine repair and maintenance services to keep your business thriving."
}
>
<p className="mx-auto max-w-3xl text-base leading-relaxed text-muted-foreground md:text-lg">
Rocky Mountain Vending delivers expert{" "}
<Link
href="/services/repairs"
@ -210,11 +199,11 @@ export default async function RepairsPage() {
</Link>{" "}
services, contact us today for fast, professional solutions!
</p>
</header>
</PublicPageHeader>
{/* Images Carousel */}
<div className="max-w-4xl mx-auto">
<PublicSurface className="mx-auto max-w-4xl p-4 md:p-5">
<RepairsImageCarousel />
</div>
</PublicSurface>
</div>
</section>
@ -576,10 +565,10 @@ export default async function RepairsPage() {
If you don't see your brand listed, feel free to reach out! We
may still be able to service it, but there are some brands we
may not support.{" "}
<Link
href="/contact-us"
className="text-primary hover:underline"
>
<Link
href="/contact-us"
className="text-primary hover:underline"
>
Contact us
</Link>
, and we'll let you know how we can help.

View file

@ -2,9 +2,14 @@ import type { Metadata } from "next"
import Link from "next/link"
import { getAllLocations } from "@/lib/location-data"
import { businessConfig } from "@/lib/seo-config"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ArrowRight, MapPin } from "lucide-react"
import {
PublicInset,
PublicPageHeader,
PublicSectionHeader,
PublicSurface,
} from "@/components/public-surface"
export const metadata: Metadata = {
title: "Utah Service Areas | Rocky Mountain Vending",
@ -92,209 +97,156 @@ export default function ServiceAreasServicesPage() {
},
]
const counties = [
{
title: "Salt Lake County",
description:
"Serving 14 cities in Salt Lake County with reliable vending services.",
locations: saltLakeCounty,
},
{
title: "Davis County",
description:
"Supporting businesses from Ogden to Layton with reliable vending services.",
locations: davisCounty,
},
{
title: "Utah County",
description:
"Serving Provo and surrounding areas with quality vending services.",
locations: utahCounty,
},
]
return (
<div className="container mx-auto px-4 py-8 md:py-12">
<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 Machine Services by Location
</h1>
<p className="text-lg text-muted-foreground max-w-3xl mx-auto text-pretty leading-relaxed">
Rocky Mountain Vending provides comprehensive vending machine services
across 20 service areas in Utah. Find services available in your city
below.
</p>
</header>
<div className="public-page">
<PublicPageHeader
align="center"
eyebrow="Legacy Route"
title="Vending machine services by location"
description="Rocky Mountain Vending provides vending machine sales, service, repairs, and healthy option planning across our Utah coverage area. Browse by city below or head to the main service-areas page for the canonical experience."
className="mb-10 md:mb-14"
/>
{/* Services Overview */}
<section className="mb-16 max-w-4xl mx-auto">
<h2 className="text-3xl font-bold mb-8 text-center tracking-tight text-balance">
Our Services
</h2>
<div className="grid gap-6 md:grid-cols-2">
{services.map((service, index) => (
<Card key={index}>
<CardContent className="p-6">
<section className="mx-auto mb-12 max-w-5xl">
<PublicSurface className="p-5 md:p-7">
<PublicSectionHeader
eyebrow="Service Overview"
title="Our services"
description="Every city we cover can be supported with the same core vending services, tailored to the way the location actually operates."
className="mb-6"
/>
<div className="grid gap-4 md:grid-cols-2">
{services.map((service, index) => (
<PublicInset key={index} className="h-full">
<h3 className="text-xl font-semibold mb-3">{service.title}</h3>
<p className="text-muted-foreground">{service.description}</p>
</CardContent>
</Card>
))}
</div>
<p className="text-muted-foreground leading-7">
{service.description}
</p>
</PublicInset>
))}
</div>
</PublicSurface>
</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 14 cities in Salt Lake County with reliable vending services
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{saltLakeCounty.map((location) => (
<Card
key={location.slug}
className="h-full hover:border-secondary/50 transition-colors"
>
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="space-y-10 md:space-y-12">
{counties.map((county) => (
<section key={county.title} className="mx-auto max-w-6xl">
<div className="mb-6">
<PublicSectionHeader
eyebrow="Coverage"
title={county.title}
description={county.description}
/>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{county.locations.map((location) => (
<PublicSurface
key={location.slug}
className="flex h-full flex-col p-5"
>
<div className="mb-4">
<h3 className="text-xl font-semibold mb-1">
{location.city}
</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-4 w-4" />
<p className="text-sm text-muted-foreground flex items-center gap-1.5">
<MapPin className="h-4 w-4 text-primary" />
{location.zipCode}
</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
Services Available:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Vending Machine Sales</li>
<li> Repair Services</li>
<li> Healthy Options</li>
<li> Maintenance</li>
</ul>
</div>
<Link href={`/vending-machines-${location.slug}`}>
<Button variant="outline" className="w-full group">
View Services
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</CardContent>
</Card>
))}
</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
<div className="mb-5">
<p className="text-sm font-medium text-foreground mb-2">
Services Available
</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>Vending machine sales</li>
<li>Repair services</li>
<li>Healthy snack and beverage options</li>
<li>Maintenance and restocking</li>
</ul>
</div>
<Link
href={`/vending-machines-${location.slug}`}
className="mt-auto"
>
<Button
variant="outline"
className="h-11 w-full rounded-full group"
>
View Services
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
</PublicSurface>
))}
</div>
</section>
))}
</div>
<section className="mx-auto mt-12 max-w-4xl">
<PublicSurface className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-balance">
Ready to get started?
</h2>
<p className="text-muted-foreground">
Supporting businesses from Ogden to Layton with reliable vending
services
<p className="mx-auto mt-4 max-w-2xl text-lg leading-8 text-muted-foreground">
All services are available across our coverage area. Reach out and
we&apos;ll help you figure out the right next step for your
location.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{davisCounty.map((location) => (
<Card
key={location.slug}
className="h-full hover:border-secondary/50 transition-colors"
>
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-semibold mb-1">
{location.city}
</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-4 w-4" />
{location.zipCode}
</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
Services Available:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Vending Machine Sales</li>
<li> Repair Services</li>
<li> Healthy Options</li>
<li> Maintenance</li>
</ul>
</div>
<Link href={`/vending-machines-${location.slug}`}>
<Button variant="outline" className="w-full group">
View Services
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</section>
{/* 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">
Serving Provo and surrounding areas with quality vending services
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{utahCounty.map((location) => (
<Card
key={location.slug}
className="h-full hover:border-secondary/50 transition-colors"
>
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-semibold mb-1">
{location.city}
</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-4 w-4" />
{location.zipCode}
</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
Services Available:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Vending Machine Sales</li>
<li> Repair Services</li>
<li> Healthy Options</li>
<li> Maintenance</li>
</ul>
</div>
<Link href={`/vending-machines-${location.slug}`}>
<Button variant="outline" className="w-full group">
View Services
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</section>
{/* Call to Action */}
<section className="max-w-4xl mx-auto text-center py-12 bg-muted/30 rounded-lg">
<h2 className="text-3xl font-bold mb-4 tracking-tight text-balance">
Ready to Get Started?
</h2>
<p className="text-lg text-muted-foreground mb-6 text-pretty leading-relaxed">
All services are available across all 20 service areas. Contact us
today to learn more about vending machine solutions for your business.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/contact-us">
<Button size="lg">Contact Us</Button>
</Link>
<Link href="/service-areas">
<Button size="lg" variant="outline">
View All Service Areas
</Button>
</Link>
</div>
<div className="mt-6 flex flex-col justify-center gap-3 sm:flex-row">
<Link href="/contact-us">
<Button size="lg" className="min-h-11 rounded-full">
Contact Us
</Button>
</Link>
<Link href="/service-areas">
<Button
size="lg"
variant="outline"
className="min-h-11 rounded-full"
>
View All Service Areas
</Button>
</Link>
</div>
<PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center">
<p className="text-sm leading-6 text-muted-foreground">
This route stays available for legacy links, but the primary
service-area experience lives on the main{" "}
<Link
href="/service-areas"
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
>
Utah service areas page
</Link>
.
</p>
</PublicInset>
</PublicSurface>
</section>
</div>
)

View file

@ -1,58 +1,72 @@
'use client'
"use client"
import Image from 'next/image'
import { Card, CardContent } from "@/components/ui/card"
import Image from "next/image"
import {
PublicInset,
PublicPageHeader,
PublicSurface,
} from "@/components/public-surface"
export function AboutPage() {
return (
<div className="container mx-auto px-4 py-8 md:py-12 max-w-6xl">
{/* Header */}
<header className="text-center mb-12 md:mb-16">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">About Us</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"></div>
</header>
<div className="public-page max-w-6xl">
<PublicPageHeader
align="center"
eyebrow="Family Owned"
title="About Rocky Mountain Vending"
description="We built Rocky Mountain Vending to serve Utah businesses with responsive service, better machine care, and a relationship that still feels personal."
className="mb-10 md:mb-14"
/>
{/* Main Content - Image and Text */}
<div className="grid gap-8 md:grid-cols-3 mb-12">
{/* Left Column - Image */}
<div className="md:col-span-1">
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-lg overflow-hidden">
<CardContent className="p-0">
<PublicSurface className="overflow-hidden p-5 md:p-7">
<div className="grid gap-6 md:grid-cols-[0.95fr_1.05fr] md:gap-8">
<div>
<PublicInset className="overflow-hidden p-0">
<Image
src="https://rockymountainvending.com/wp-content/uploads/2022/06/Rebekahand-Matt-scaled.webp"
alt="Matt and Rebekah"
width={240}
height={300}
className="w-full h-auto object-cover"
width={640}
height={800}
className="h-full w-full object-cover"
priority
/>
</CardContent>
</Card>
</div>
</PublicInset>
</div>
{/* Right Column - Text Content */}
<div className="md:col-span-2">
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-lg h-full">
<CardContent className="p-6 md:p-8">
<div className="prose prose-lg max-w-none">
<p className="text-muted-foreground leading-relaxed mb-4">
When my wife, Rebekah, and I met we knew that we wanted to start a business and raise our family here in the Salt Lake Valley. We met in Bellevue Washington and as soon we got married moved straight to Salt Lake City to start Rocky Mountain Vending. Our focus is on Healthy Vending and providing the best service. Of course we can also provide traditional vending options as well.
</p>
<p className="text-muted-foreground leading-relaxed mb-4">
Rocky Mountain Vending is 100% a local family run business founded in 2019. If you are looking for the old-fashioned business relationship with all the perks of the 21st century.
</p>
<p className="text-muted-foreground leading-relaxed mb-4">
We strongly believe that business should be founded on trust, exceptional customer service, employing the latest technology, and of course having fun while we work.
</p>
<p className="text-foreground font-semibold text-lg mt-6">
~Matt
</p>
</div>
</CardContent>
</Card>
<div className="flex flex-col justify-center">
<div className="space-y-4 text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
<p>
When my wife, Rebekah, and I met, we knew we wanted to start a
business and raise our family here in the Salt Lake Valley. We
met in Bellevue, Washington and moved straight to Salt Lake City
after we got married to build Rocky Mountain Vending.
</p>
<p>
Our focus is on healthy vending and dependable service, while
still helping locations that want traditional snack and drink
options too.
</p>
<p>
Rocky Mountain Vending is a local family-run business founded in
2019. We believe business should be built on trust, exceptional
customer service, modern technology, and enjoying the work we
do.
</p>
</div>
<PublicInset className="mt-6">
<p className="text-sm leading-6 text-muted-foreground">
If you&apos;re looking for a more personal vending partner with
modern tools and real follow-through, that&apos;s exactly what
we set out to build.
</p>
<p className="mt-4 text-lg font-semibold text-foreground">
~Matt
</p>
</PublicInset>
</div>
</div>
</div>
</PublicSurface>
</div>
)
}

View file

@ -2,8 +2,11 @@
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 {
PublicInset,
PublicPageHeader,
PublicSurface,
} from "@/components/public-surface"
import { businessConfig } from "@/lib/seo-config"
export function ContactPage() {
@ -18,7 +21,7 @@ export function ContactPage() {
]
return (
<div className="container mx-auto max-w-6xl px-4 py-10 md:py-14">
<div className="public-page">
<PublicPageHeader
align="center"
eyebrow="Contact Rocky Mountain Vending"
@ -32,73 +35,110 @@ export function ContactPage() {
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
Contact Form
</div>
<p className="text-sm text-muted-foreground">For repairs or moving, include the machine model and a clear description of what's happening.</p>
<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)} />
<ContactForm
onSubmit={(data) => console.log("Contact form submitted:", data)}
/>
</PublicSurface>
<aside className="space-y-5">
<Card className="overflow-hidden rounded-[2rem] border-border/70 bg-white shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
<CardContent className="bg-white p-6">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">Direct Options</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">Reach the team directly</h2>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
We monitor calls, texts, and email throughout the business day. If you're sending repair photos or videos, text them to the number below.
</p>
<PublicSurface className="overflow-hidden p-6">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Direct Options
</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">
Reach the team directly
</h2>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
We monitor calls, texts, and email throughout the business day. If
you're sending repair photos or videos, text them to the number
below.
</p>
<div className="mt-6 space-y-4">
<a href={businessConfig.publicCallUrl} className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Phone className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Call</p>
<p className="mt-1 text-base font-medium text-foreground">{businessConfig.publicCallNumber}</p>
<p className="mt-1 text-sm text-muted-foreground">Best for immediate questions during business hours.</p>
</div>
</a>
<a href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`} className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Mail className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Email</p>
<p className="mt-1 break-all text-base font-medium text-foreground">{businessConfig.email}</p>
<p className="mt-1 text-sm text-muted-foreground">Good for longer requests and supporting details.</p>
</div>
</a>
</div>
</CardContent>
</Card>
<Card className="rounded-[2rem] border-border/70 shadow-[0_18px_45px_rgba(0,0,0,0.06)]">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Clock className="h-5 w-5" />
<div className="mt-6 space-y-4">
<a
href={businessConfig.publicCallUrl}
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
>
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Phone className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">Business Hours</p>
<h2 className="text-xl font-semibold text-foreground">When we're available</h2>
<p className="text-sm font-semibold text-foreground">Call</p>
<p className="mt-1 text-base font-medium text-foreground">
{businessConfig.publicCallNumber}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Best for immediate questions during business hours.
</p>
</div>
</div>
</a>
<div className="mt-5 space-y-2">
{businessHours.map((schedule) => (
<PublicInset
key={schedule.day}
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>
</PublicInset>
))}
<a
href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`}
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
>
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Mail className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Email</p>
<p className="mt-1 break-all text-base font-medium text-foreground">
{businessConfig.email}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Good for longer requests and supporting details.
</p>
</div>
</a>
</div>
</PublicSurface>
<PublicSurface className="p-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Clock className="h-5 w-5" />
</div>
</CardContent>
</Card>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Business Hours
</p>
<h2 className="text-xl font-semibold text-foreground">
When we're available
</h2>
</div>
</div>
<div className="mt-5 space-y-2">
{businessHours.map((schedule) => (
<PublicInset
key={schedule.day}
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>
</PublicInset>
))}
</div>
</PublicSurface>
</aside>
</div>
</div>

View file

@ -1,17 +1,25 @@
import Link from "next/link"
import Image from "next/image"
import { Facebook, Twitter, Linkedin, Youtube } from "lucide-react"
import {
Facebook,
Globe,
Linkedin,
Mail,
Phone,
Twitter,
Youtube,
} from "lucide-react"
import { Separator } from "./ui/separator"
export function Footer() {
const currentYear = new Date().getFullYear()
return (
<footer className="border-t border-border bg-muted/50 shadow-inner py-8">
<div className="container mx-auto px-4 lg:px-6 py-16 md:py-28">
<div className="grid gap-12 md:grid-cols-4">
<footer className="border-t border-border/60 bg-[linear-gradient(180deg,rgba(254,243,224,0.62),rgba(255,248,235,0.96))] py-8">
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 py-14 sm:px-5 md:py-20 lg:px-6">
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.9fr_0.9fr_1fr]">
{/* Company Info */}
<div className="md:col-span-2">
<div className="rounded-[2rem] border border-border/60 bg-white/92 p-6 shadow-[0_18px_48px_rgba(15,23,42,0.07)] lg:col-span-1">
<Link href="/" className="inline-flex">
<Image
src="/rmv-logo.png"
@ -23,26 +31,32 @@ export function Footer() {
</Link>
<p className="text-sm text-muted-foreground mb-6 max-w-md leading-relaxed">
Serving Utah businesses with free placement for qualifying
locations, machine sales, repairs, restocking, and ongoing
service since 2019.
locations, machine sales, repairs, restocking, and ongoing service
since 2019.
</p>
<div className="flex flex-col gap-3 text-sm text-muted-foreground mb-6">
<a href="tel:+14352339668" className="transition-colors">
📞 (435) 233-9668
<a
href="tel:+14352339668"
className="inline-flex items-center gap-3 transition-colors hover:text-foreground"
>
<Phone className="h-4 w-4 text-primary" />
<span>(435) 233-9668</span>
</a>
<a
href="mailto:info@rockymountainvending.com"
className="transition-colors"
className="inline-flex items-center gap-3 transition-colors hover:text-foreground"
>
info@rockymountainvending.com
<Mail className="h-4 w-4 text-primary" />
<span>info@rockymountainvending.com</span>
</a>
<a
href="https://rockymountainvending.com/"
className="transition-colors"
className="inline-flex items-center gap-3 transition-colors hover:text-foreground"
target="_blank"
rel="noopener noreferrer"
>
🌐 rockymountainvending.com
<Globe className="h-4 w-4 text-primary" />
<span>rockymountainvending.com</span>
</a>
</div>
<div className="flex flex-wrap gap-6">
@ -90,7 +104,7 @@ export function Footer() {
</div>
{/* Services */}
<div className="footer-section px-4 py-4">
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
<h3 className="font-semibold mb-5 text-base">Services</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>
@ -145,7 +159,7 @@ export function Footer() {
</div>
{/* Company */}
<div className="footer-section px-4 py-4">
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
<h3 className="font-semibold mb-5 text-base">Company</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>
@ -184,7 +198,7 @@ export function Footer() {
</div>
{/* Service Areas */}
<div className="footer-section px-4 py-4">
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
<h3 className="font-semibold mb-5 text-base">Service Areas</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>
@ -248,8 +262,8 @@ export function Footer() {
</div>
</div>
<div className="border-t border-border mt-12 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
<div className="border-t border-border/60 mt-10 pt-8">
<div className="flex flex-col items-center justify-between gap-4 text-center text-sm text-muted-foreground md:flex-row md:text-left">
<p>
© {currentYear} Rocky Mountain Vending LLC. All rights reserved.
</p>

View file

@ -15,6 +15,7 @@ import { GetFreeMachineModal } from "@/components/get-free-machine-modal"
import { Cart } from "@/components/cart"
import { CartButton } from "@/components/cart-button"
import { MobileCartButton } from "@/components/mobile-cart-button"
import { cn } from "@/lib/utils"
// Header state types following Vercel React Best Practices
type HeaderState = {
@ -126,14 +127,21 @@ export function Header() {
{ label: "FAQs", href: "/about/faqs" },
]
const desktopLinkClassName =
"rounded-full px-3 py-2 text-sm font-medium text-foreground transition hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
const mobileLinkClassName =
"flex min-h-11 items-center rounded-[1rem] px-4 text-sm font-medium text-foreground transition hover:bg-primary/6 hover:text-primary"
const mobileGroupButtonClassName =
"flex min-h-11 items-center justify-between rounded-[1rem] px-4 text-sm font-medium text-foreground transition hover:bg-primary/6 hover:text-primary"
return (
<header className="sticky top-0 z-40 w-full border-b border-border/40 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/90 shadow-sm">
<div className="w-full px-4 lg:px-6 xl:px-8 2xl:px-12">
<div className="flex h-20 items-center justify-between gap-4 lg:gap-6">
<header className="sticky top-0 z-40 w-full border-b border-border/50 bg-white/92 shadow-[0_10px_35px_rgba(15,23,42,0.06)] backdrop-blur supports-[backdrop-filter]:bg-white/80">
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 sm:px-5 lg:px-6">
<div className="flex h-[var(--header-height)] items-center justify-between gap-3 lg:gap-6">
{/* Logo */}
<Link
href="/"
className="flex items-center gap-2 flex-shrink-0 min-w-0"
className="flex min-w-0 flex-shrink-0 items-center gap-2 rounded-full"
>
<Image
src="/rmv-logo.png"
@ -146,20 +154,32 @@ export function Header() {
</Link>
{/* Desktop Navigation */}
<nav className="hidden items-center gap-4 lg:gap-6 md:flex flex-1 justify-center">
<Link href="/" className="text-sm font-medium transition-colors">
<nav className="hidden flex-1 items-center justify-center gap-1 md:flex lg:gap-2">
<Link href="/" className={desktopLinkClassName}>
Home
</Link>
{/* Who We Serve Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
Who We Serve
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuContent
align="start"
className="w-56 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
>
{whoWeServeItems.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
@ -168,13 +188,25 @@ export function Header() {
{/* Vending Machines Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
Vending Machines
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuContent
align="start"
className="w-64 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
>
{vendingMachinesItems.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
@ -183,13 +215,25 @@ export function Header() {
{/* Food and Beverage Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand whitespace-nowrap outline-none data-[state=open]:text-primary">
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 whitespace-nowrap data-[state=open]:text-primary"
)}
>
Food & Beverage
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuContent
align="start"
className="w-64 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
>
{foodBeverageItems.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
@ -198,13 +242,25 @@ export function Header() {
{/* Services Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
Services
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
<DropdownMenuContent
align="start"
className="w-72 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
>
{servicesItems.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
@ -213,13 +269,25 @@ export function Header() {
{/* Blog Posts Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
Blog Posts
<ChevronDown className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuContent
align="start"
className="w-56 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
>
{blogPostsItems.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
@ -228,55 +296,58 @@ export function Header() {
{/* About Us Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
<DropdownMenuTrigger
className={cn(
desktopLinkClassName,
"gap-1.5 data-[state=open]:text-primary"
)}
>
About Us
<ChevronDown className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuContent
align="start"
className="w-56 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
>
{aboutItems.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<DropdownMenuItem
key={item.href}
asChild
className="rounded-xl px-3 py-2.5"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Link
href="/products"
className="text-sm font-medium transition-colors"
>
<Link href="/products" className={desktopLinkClassName}>
Products
</Link>
<Link
href="/service-areas"
className="text-sm font-medium transition-colors"
>
<Link href="/service-areas" className={desktopLinkClassName}>
Service Areas
</Link>
<Link
href="/contact-us"
className="text-sm font-medium transition-colors"
>
<Link href="/contact-us" className={desktopLinkClassName}>
Contact Us
</Link>
</nav>
{/* Desktop CTA */}
<div className="hidden items-center gap-2 lg:gap-3 md:flex flex-shrink-0">
<div className="hidden flex-shrink-0 items-center gap-2 md:flex lg:gap-3">
<CartButton
onClick={() => dispatch({ type: "SET_CART", value: true })}
/>
<a
href="tel:+14352339668"
className="flex items-center gap-2 text-sm font-medium transition-colors whitespace-nowrap"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-border/60 bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
>
<Phone className="h-4 w-4 flex-shrink-0" />
<span className="hidden lg:inline">(435) 233-9668</span>
</a>
<Button
onClick={() => dispatch({ type: "SET_MODAL", value: true })}
className="bg-primary hover:bg-primary/90 whitespace-nowrap"
size="sm"
className="h-11 whitespace-nowrap rounded-full bg-primary px-5 hover:bg-primary/90"
size="lg"
>
Get Free Machine
</Button>
@ -284,9 +355,10 @@ export function Header() {
{/* Mobile Menu Button */}
<button
className="md:hidden"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-white text-foreground transition hover:border-primary/35 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 md:hidden"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
aria-label="Toggle menu"
aria-expanded={state.isMenuOpen}
>
{state.isMenuOpen ? (
<X className="h-6 w-6" />
@ -298,246 +370,252 @@ export function Header() {
{/* Mobile Navigation */}
{state.isMenuOpen && (
<nav className="flex flex-col gap-5 py-6 md:hidden border-t border-border/40">
<Link
href="/"
className="text-sm font-medium py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Home
</Link>
<nav className="border-t border-border/40 py-5 md:hidden">
<div className="rounded-[1.5rem] border border-border/60 bg-white/95 p-3 shadow-[0_18px_48px_rgba(15,23,42,0.08)]">
<div className="flex flex-col gap-2">
<Link
href="/"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Home
</Link>
{/* Who We Serve Mobile Section */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_WHO_WE_SERVE" })}
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
aria-label="Who We Serve menu"
aria-expanded={state.isWhoWeServeOpen}
aria-haspopup="true"
>
Who We Serve
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isWhoWeServeOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isWhoWeServeOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
{whoWeServeItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
{/* Who We Serve Mobile Section */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_WHO_WE_SERVE" })}
className={mobileGroupButtonClassName}
aria-label="Who We Serve menu"
aria-expanded={state.isWhoWeServeOpen}
aria-haspopup="true"
>
Who We Serve
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isWhoWeServeOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isWhoWeServeOpen && (
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
{whoWeServeItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
</div>
)}
</div>
)}
</div>
{/* Vending Machines Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_VENDING_MACHINES" })}
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
aria-label="Vending Machines menu"
aria-expanded={state.isVendingMachinesOpen}
aria-haspopup="true"
>
Vending Machines
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isVendingMachinesOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isVendingMachinesOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
{vendingMachinesItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
{/* Vending Machines Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() =>
dispatch({ type: "TOGGLE_VENDING_MACHINES" })
}
className={mobileGroupButtonClassName}
aria-label="Vending Machines menu"
aria-expanded={state.isVendingMachinesOpen}
aria-haspopup="true"
>
Vending Machines
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isVendingMachinesOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isVendingMachinesOpen && (
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
{vendingMachinesItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
</div>
)}
</div>
)}
</div>
{/* Food and Beverage Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_FOOD_BEVERAGE" })}
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
aria-label="Food & Beverage menu"
aria-expanded={state.isFoodBeverageOpen}
aria-haspopup="true"
>
Food & Beverage
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isFoodBeverageOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isFoodBeverageOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
{foodBeverageItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
{/* Food and Beverage Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_FOOD_BEVERAGE" })}
className={mobileGroupButtonClassName}
aria-label="Food & Beverage menu"
aria-expanded={state.isFoodBeverageOpen}
aria-haspopup="true"
>
Food & Beverage
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isFoodBeverageOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isFoodBeverageOpen && (
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
{foodBeverageItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
</div>
)}
</div>
)}
</div>
{/* Services Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_SERVICES" })}
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
aria-label="Services menu"
aria-expanded={state.isServicesOpen}
aria-haspopup="true"
>
Services
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isServicesOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isServicesOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
{servicesItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
{/* Services Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_SERVICES" })}
className={mobileGroupButtonClassName}
aria-label="Services menu"
aria-expanded={state.isServicesOpen}
aria-haspopup="true"
>
Services
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isServicesOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isServicesOpen && (
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
{servicesItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
</div>
)}
</div>
)}
</div>
{/* Blog Posts Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_BLOG_POSTS" })}
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
aria-label="Blog Posts menu"
aria-expanded={state.isBlogPostsOpen}
aria-haspopup="true"
>
Blog Posts
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isBlogPostsOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isBlogPostsOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
{blogPostsItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
{/* Blog Posts Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_BLOG_POSTS" })}
className={mobileGroupButtonClassName}
aria-label="Blog Posts menu"
aria-expanded={state.isBlogPostsOpen}
aria-haspopup="true"
>
Blog Posts
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isBlogPostsOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isBlogPostsOpen && (
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
{blogPostsItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
</div>
)}
</div>
)}
</div>
{/* About Us Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_ABOUT" })}
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
aria-label="About menu"
aria-expanded={state.isAboutOpen}
aria-haspopup="true"
>
About Us
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isAboutOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isAboutOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
{aboutItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
{/* About Us Mobile */}
<div className="flex flex-col gap-3">
<button
onClick={() => dispatch({ type: "TOGGLE_ABOUT" })}
className={mobileGroupButtonClassName}
aria-label="About menu"
aria-expanded={state.isAboutOpen}
aria-haspopup="true"
>
About Us
<ChevronDown
className={`h-4 w-4 transition-transform ${state.isAboutOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isAboutOpen && (
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
{aboutItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
</Link>
))}
</div>
)}
</div>
)}
</div>
<Link
href="/products"
className="text-sm font-medium py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Products
</Link>
<Link
href="/service-areas"
className="text-sm font-medium py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Service Areas
</Link>
<Link
href="/contact-us"
className="text-sm font-medium py-1 transition-colors"
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Contact Us
</Link>
<Link
href="/products"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Products
</Link>
<Link
href="/service-areas"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Service Areas
</Link>
<Link
href="/contact-us"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Contact Us
</Link>
<div className="flex flex-col gap-4 pt-4 border-t border-border/40">
<MobileCartButton
onClick={() => {
dispatch({ type: "TOGGLE_MENU" })
dispatch({ type: "SET_CART", value: true })
}}
/>
<a
href="tel:+14352339668"
className="flex items-center gap-2 text-sm font-medium transition-colors"
>
<Phone className="h-4 w-4" />
<span>(435) 233-9668</span>
</a>
<Button
onClick={() => {
dispatch({ type: "TOGGLE_MENU" })
dispatch({ type: "SET_MODAL", value: true })
}}
className="bg-primary hover:bg-primary/90 w-full"
>
Get Free Machine
</Button>
<div className="mt-3 flex flex-col gap-3 border-t border-border/40 pt-4">
<MobileCartButton
onClick={() => {
dispatch({ type: "TOGGLE_MENU" })
dispatch({ type: "SET_CART", value: true })
}}
/>
<a
href="tel:+14352339668"
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border/60 bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
>
<Phone className="h-4 w-4" />
<span>(435) 233-9668</span>
</a>
<Button
onClick={() => {
dispatch({ type: "TOGGLE_MENU" })
dispatch({ type: "SET_MODAL", value: true })
}}
className="h-11 w-full rounded-full bg-primary hover:bg-primary/90"
>
Get Free Machine
</Button>
</div>
</div>
</div>
</nav>
)}

View file

@ -1,117 +1,120 @@
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { CheckCircle2 } from "lucide-react"
import Link from "next/link"
import Image from "next/image"
import { PublicSection, PublicSurface } from "@/components/public-surface"
export function HeroSection() {
return (
<section className="relative overflow-hidden bg-background py-20 md:py-32">
<div className="container mx-auto px-4">
<div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center">
{/* Left Content */}
<div className="flex flex-col gap-6">
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs font-medium w-fit"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
<PublicSection
tone="warm"
className="relative overflow-hidden"
containerClassName="relative"
>
<div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center">
{/* Left Content */}
<div className="flex flex-col gap-6">
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs font-medium w-fit"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
Serving Utah Businesses Since 2019
</Badge>
<h1 className="text-4xl font-bold tracking-tight text-balance md:text-5xl lg:text-6xl">
Free Vending Machine Placement for Utah Businesses
</h1>
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
If your breakroom, waiting area, or staff space needs a better
snack-and-drink option, we can review the location, place the right
machine, keep it stocked, and handle service after installation.
</p>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span className="font-semibold">
Free placement for qualifying business locations
</span>
Serving Utah Businesses Since 2019
</Badge>
<h1 className="text-4xl font-bold tracking-tight text-balance md:text-5xl lg:text-6xl">
Free Vending Machine Placement for Utah Businesses
</h1>
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
If your breakroom, waiting area, or staff space needs a better
snack-and-drink option, we can review the location, place the
right machine, keep it stocked, and handle service after
installation.
</p>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span className="font-semibold">
Free placement for qualifying business locations
</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>We review the location, then handle stocking and service</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>Snack, beverage, and combo machines with cashless payment</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>Traditional favorites and healthier product options</span>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 pt-4">
<Button
asChild
size="lg"
className="bg-primary hover:bg-primary/90 text-lg h-14 px-8"
>
<Link href="#request-machine">See If Your Location Qualifies</Link>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="text-lg h-14 bg-transparent"
>
<Link href="#how-it-works">See How It Works</Link>
</Button>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>
We review the location, then handle stocking and service
</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>
Snack, beverage, and combo machines with cashless payment
</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>Traditional favorites and healthier product options</span>
</div>
<p className="text-sm text-muted-foreground pt-2">
Serving Davis, Salt Lake, and Utah counties
</p>
</div>
<div className="relative">
<div className="aspect-square relative rounded-2xl overflow-hidden bg-muted">
<Image
src="/images/vending-bay-2-scaled.webp"
alt="Modern vending machines installed at Utah business"
fill
className="object-cover"
priority
/>
</div>
<div className="flex flex-col sm:flex-row gap-4 pt-4">
<Button
asChild
size="lg"
className="bg-primary hover:bg-primary/90 text-lg h-14 px-8"
>
<Link href="#request-machine">
See If Your Location Qualifies
</Link>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="text-lg h-14 bg-transparent"
>
<Link href="#how-it-works">See How It Works</Link>
</Button>
</div>
<Card className="absolute bottom-6 left-6 right-6 md:left-auto md:right-6 md:w-64 bg-card border border-border rounded-lg p-6 shadow-lg backdrop-blur-sm">
<CardContent className="p-0">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-3xl font-bold text-primary">
3
</div>
<div className="text-xs text-muted-foreground">
Counties Served
</div>
</div>
<div>
<div className="text-3xl font-bold text-primary">6+</div>
<div className="text-xs text-muted-foreground">
Years in Business
</div>
</div>
<p className="text-sm text-muted-foreground pt-2">
Serving Davis, Salt Lake, and Utah counties
</p>
</div>
<div className="relative">
<div className="relative aspect-[0.94] overflow-hidden rounded-[2rem] border border-border/70 bg-muted shadow-[var(--public-surface-shadow)]">
<Image
src="/images/vending-bay-2-scaled.webp"
alt="Modern vending machines installed at Utah business"
fill
className="object-cover"
priority
/>
</div>
<PublicSurface className="absolute bottom-4 left-4 right-4 p-5 backdrop-blur-sm md:bottom-6 md:left-auto md:right-6 md:w-64">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-3xl font-bold text-primary">3</div>
<div className="text-xs text-muted-foreground">
Counties Served
</div>
</CardContent>
</Card>
</div>
</div>
<div>
<div className="text-3xl font-bold text-primary">6+</div>
<div className="text-xs text-muted-foreground">
Years in Business
</div>
</div>
</div>
</PublicSurface>
</div>
</div>
</section>
</PublicSection>
)
}

View file

@ -1,4 +1,9 @@
import { Card, CardContent } from "@/components/ui/card"
import {
PublicInset,
PublicPageHeader,
PublicSection,
PublicSurface,
} from "@/components/public-surface"
const steps = [
{
@ -26,49 +31,47 @@ const steps = [
export function HowItWorksSection() {
return (
<section id="how-it-works" className="py-20 md:py-28">
<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">How It Works</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
Getting the best vending machine supplier in Utah shouldn't be a difficult process. Your free machine could
be delivered in as little as 7 days depending on our schedule.
</p>
</div>
<PublicSection id="how-it-works">
<PublicPageHeader
align="center"
className="mb-12 md:mb-16"
title="How It Works"
description={
"Getting the best vending machine supplier in Utah shouldn't be a difficult process. Your free machine could be delivered in as little as 7 days depending on our schedule."
}
/>
<PublicSurface className="relative p-6 md:p-8">
<div className="relative">
{/* Connection Line - Desktop */}
<div className="hidden lg:block absolute top-24 left-[16.666%] right-[16.666%] h-0.5 bg-border" />
<div className="absolute left-[16.666%] right-[16.666%] top-16 hidden h-px bg-border/80 lg:block" />
<div className="grid gap-8 md:grid-cols-3">
<div className="grid gap-5 md:grid-cols-3 md:gap-6">
{steps.map((step, index) => (
<div key={index} className="relative">
<Card className="border-border/50 hover:border-secondary/50 transition-colors h-full">
<CardContent className="pt-8">
<div className="mb-6">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold">
{step.number}
</div>
</div>
<div className="mb-2 text-xs font-medium text-primary uppercase tracking-wider">
{step.timing}
</div>
<h3 className="text-xl font-semibold mb-3">{step.title}</h3>
<p className="text-muted-foreground text-sm leading-relaxed">{step.description}</p>
</CardContent>
</Card>
</div>
<PublicInset key={index} className="relative h-full p-5 md:p-6">
<div className="mb-5">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary text-lg font-bold text-primary-foreground shadow-sm md:h-16 md:w-16 md:text-2xl">
{step.number}
</div>
</div>
<div className="mb-2 text-xs font-medium uppercase tracking-[0.18em] text-primary">
{step.timing}
</div>
<h3 className="text-xl font-semibold mb-3">{step.title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{step.description}
</p>
</PublicInset>
))}
</div>
</div>
<div className="text-center mt-12">
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
Our process is as easy as 3 steps. We mentioned <span className="font-semibold text-foreground">FREE</span>,
right?
Our process is as easy as 3 steps. We mentioned{" "}
<span className="font-semibold text-foreground">FREE</span>, right?
</p>
</div>
</div>
</section>
</PublicSurface>
</PublicSection>
)
}

View file

@ -1,10 +1,17 @@
import type { Metadata } from "next"
import Link from "next/link"
import { ArrowRight, Clock, Globe, Mail, MapPin, Phone, Wrench } from "lucide-react"
import {
ArrowRight,
Clock,
Globe,
Mail,
MapPin,
Phone,
Wrench,
} from "lucide-react"
import { Breadcrumbs } from "@/components/breadcrumbs"
import { ReviewsSection } from "@/components/reviews-section"
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
import { Card, CardContent } from "@/components/ui/card"
import {
PublicInset,
PublicPageHeader,
@ -192,16 +199,16 @@ export function LocationLandingPage({
Vending service for businesses across {locationData.city}
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
If your business is in {locationData.neighborhoods.join(", ")},
or elsewhere in {locationData.city}, we can review the location,
If your business is in {locationData.neighborhoods.join(", ")}, or
elsewhere in {locationData.city}, we can review the location,
recommend the right machine mix, and explain what service would
look like once the machines are in place.
</p>
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
We regularly work with businesses in {countyName} that want snack,
beverage, combo, or healthier vending options without asking
their own staff to handle stocking, service calls, or day-to-day
machine issues.
beverage, combo, or healthier vending options without asking their
own staff to handle stocking, service calls, or day-to-day machine
issues.
</p>
</PublicSurface>
@ -263,19 +270,19 @@ export function LocationLandingPage({
cta: "View manuals and parts",
},
].map((service) => (
<Card key={service.title} className="h-full">
<CardContent className="p-6">
<h3 className="text-xl font-semibold">{service.title}</h3>
<p className="mt-3 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>
</CardContent>
</Card>
<PublicSurface key={service.title} className="h-full p-6">
<h3 className="text-xl font-semibold">{service.title}</h3>
<p className="mt-3 leading-7 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>
</PublicSurface>
))}
</div>
</section>
@ -294,7 +301,8 @@ export function LocationLandingPage({
<ul className="mt-6 space-y-3 text-sm text-muted-foreground">
<li className="flex items-start gap-3">
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
Neighborhood coverage includes {locationData.neighborhoods.join(", ")}.
Neighborhood coverage includes{" "}
{locationData.neighborhoods.join(", ")}.
</li>
<li className="flex items-start gap-3">
<Globe className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
@ -342,7 +350,10 @@ export function LocationLandingPage({
<Phone className="mt-1 h-5 w-5 flex-shrink-0 text-primary" />
<div>
<div className="font-semibold text-foreground">Call</div>
<a href={businessConfig.phoneUrl} className="text-muted-foreground hover:underline">
<a
href={businessConfig.phoneUrl}
className="text-muted-foreground hover:underline"
>
{businessConfig.phone}
</a>
</div>

View file

@ -5,13 +5,15 @@ export function ProductShowcaseSection() {
const products = [
{
title: "Traditional Snacks & Drinks",
description: "Popular brands your employees know and love. From chips and candy to soda and energy drinks.",
description:
"Popular brands your employees know and love. From chips and candy to soda and energy drinks.",
image: "/images/traditional-snacks-utah.webp",
alt: "Traditional snacks including M&Ms, Oreos, Reeses in vending machine",
},
{
title: "Healthy Options",
description: "Nutritious choices for health-conscious customers. Protein bars, nuts, sparkling water, and more.",
description:
"Nutritious choices for health-conscious customers. Protein bars, nuts, sparkling water, and more.",
image: "/images/healthy-vending-utah.webp",
alt: "Healthy vending options including protein bars and nutritious snacks",
},
@ -24,7 +26,8 @@ export function ProductShowcaseSection() {
},
{
title: "Specialty Products",
description: "Local favorites and specialty items. We can customize your machine with what your team wants.",
description:
"Local favorites and specialty items. We can customize your machine with what your team wants.",
image: "/images/drink-and-snack-delivery-utah-scaled.webp",
alt: "Specialty Bucked Up Energy drinks available for delivery",
},
@ -37,7 +40,7 @@ export function ProductShowcaseSection() {
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."
description="From traditional favorites to healthier options, we can tailor the product mix so the machine feels right for the people using your space."
className="mb-12"
/>
@ -57,7 +60,9 @@ export function ProductShowcaseSection() {
</div>
<div className="p-5 md:p-6">
<h3 className="text-xl font-semibold mb-2">{product.title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground">{product.description}</p>
<p className="text-sm leading-relaxed text-muted-foreground">
{product.description}
</p>
</div>
</PublicSurface>
))}

View file

@ -3,6 +3,14 @@
import type { ElementType, HTMLAttributes, ReactNode } from "react"
import { cn } from "@/lib/utils"
type PublicSectionProps = {
id?: string
tone?: "default" | "muted" | "warm"
className?: string
containerClassName?: string
children: ReactNode
}
type PublicPageHeaderProps = {
eyebrow?: string
title: string
@ -12,6 +20,36 @@ type PublicPageHeaderProps = {
children?: ReactNode
}
export function PublicSection({
id,
tone = "default",
className,
containerClassName,
children,
}: PublicSectionProps) {
return (
<section
id={id}
className={cn(
"py-[var(--public-section-space)]",
tone === "muted" && "bg-muted/30",
tone === "warm" &&
"bg-[linear-gradient(180deg,rgba(247,244,236,0.82),rgba(255,248,235,0.98))]",
className
)}
>
<div
className={cn(
"mx-auto w-full max-w-[var(--public-shell-max)] px-4 sm:px-5 lg:px-6",
containerClassName
)}
>
{children}
</div>
</section>
)
}
export function PublicPageHeader({
eyebrow,
title,
@ -23,17 +61,27 @@ export function PublicPageHeader({
const isCentered = align === "center"
return (
<header className={cn("space-y-4", isCentered && "mx-auto max-w-3xl text-center", className)}>
<header
className={cn(
"space-y-4 md:space-y-5",
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>
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.24em] 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>
<div className="space-y-3 md:space-y-4">
<h1 className="text-3xl font-bold tracking-tight text-balance text-foreground sm:text-4xl md:text-5xl lg:text-[3.4rem]">
{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",
"text-base leading-7 text-muted-foreground md:text-lg md:leading-8",
isCentered ? "mx-auto max-w-3xl" : "max-w-3xl"
)}
>
{description}
@ -54,8 +102,8 @@ export function PublicSurface({
return (
<Component
className={cn(
"rounded-[2rem] border border-border/70 bg-white p-5 shadow-[0_20px_48px_rgba(15,23,42,0.08)] md:p-7",
className,
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] p-5 shadow-[var(--public-surface-shadow)] md:p-7",
className
)}
{...props}
>
@ -72,8 +120,8 @@ export function PublicInset({
return (
<div
className={cn(
"rounded-[1.5rem] border border-border/60 bg-white p-4 shadow-sm",
className,
"rounded-[var(--public-inset-radius)] border border-border/60 bg-white/95 p-4 shadow-[0_10px_28px_rgba(15,23,42,0.06)]",
className
)}
{...props}
>
@ -96,10 +144,14 @@ export function PublicSectionHeader({
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 className={cn("space-y-2", className)}>
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
{eyebrow}
</p>
<h2 className="text-xl font-semibold tracking-tight text-foreground md:text-[1.375rem]">
{title}
</h2>
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
</div>
)
}

View file

@ -1,8 +1,13 @@
'use client'
"use client"
import * as React from 'react'
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '@/components/ui/carousel'
import Image from 'next/image'
import * as React from "react"
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from "@/components/ui/carousel"
import Image from "next/image"
export function RepairsImageCarousel() {
const [api, setApi] = React.useState<CarouselApi>()
@ -13,7 +18,7 @@ export function RepairsImageCarousel() {
setCurrent(api.selectedScrollSnap())
api.on('select', () => {
api.on("select", () => {
setCurrent(api.selectedScrollSnap())
})
}, [api])
@ -33,13 +38,13 @@ export function RepairsImageCarousel() {
setApi={setApi}
className="w-full"
opts={{
align: 'start',
align: "start",
loop: true,
}}
>
<CarouselContent>
<CarouselItem className="md:basis-1/2">
<div className="relative aspect-video rounded-lg overflow-hidden shadow-lg">
<div className="relative aspect-video overflow-hidden rounded-[1.5rem] border border-border/60 shadow-[0_16px_40px_rgba(15,23,42,0.08)]">
<Image
src="https://rockymountainvending.com/wp-content/uploads/2025/09/IMG_4660-scaled.jpeg"
alt="Vending machine repair service"
@ -50,7 +55,7 @@ export function RepairsImageCarousel() {
</div>
</CarouselItem>
<CarouselItem className="md:basis-1/2">
<div className="relative aspect-video rounded-lg overflow-hidden shadow-lg">
<div className="relative aspect-video overflow-hidden rounded-[1.5rem] border border-border/60 shadow-[0_16px_40px_rgba(15,23,42,0.08)]">
<Image
src="https://rockymountainvending.com/wp-content/uploads/2025/09/IMG_4676-scaled.jpeg"
alt="Vending machine maintenance"
@ -64,4 +69,3 @@ export function RepairsImageCarousel() {
</Carousel>
)
}

View file

@ -31,7 +31,7 @@ export function ReviewsPage() {
}, [])
return (
<div className="container mx-auto px-4 py-10 md:py-14">
<div className="public-page">
<PublicPageHeader
align="center"
eyebrow="Customer Reviews"

View file

@ -1,11 +1,19 @@
import { MapPin } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { getServiceAreaCities, serviceAreas as serviceAreasConfig } from "@/lib/seo-config"
import {
PublicInset,
PublicPageHeader,
PublicSection,
PublicSurface,
} from "@/components/public-surface"
import {
businessConfig,
serviceAreas as serviceAreasConfig,
} from "@/lib/seo-config"
import { getAllLocations } from "@/lib/location-data"
export function ServiceAreasSection() {
const serviceAreas = getServiceAreaCities()
const locations = getAllLocations()
// Build structured data for service areas
@ -13,6 +21,11 @@ export function ServiceAreasSection() {
"@context": "https://schema.org",
"@type": "Service",
serviceType: "Vending Machine Services",
provider: {
"@type": "Organization",
name: businessConfig.name,
url: businessConfig.website,
},
areaServed: serviceAreasConfig.map((area) => ({
"@type": "City",
name: area.city,
@ -22,24 +35,24 @@ export function ServiceAreasSection() {
}
return (
<section id="service-areas" className="py-16 md:py-24 bg-background">
<PublicSection id="service-areas">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(serviceAreaStructuredData) }}
dangerouslySetInnerHTML={{
__html: JSON.stringify(serviceAreaStructuredData),
}}
/>
<PublicPageHeader
align="center"
className="mb-12"
eyebrow="Service Coverage"
title="Service Areas in Utah"
description="Proudly serving businesses across Davis, Salt Lake, and Utah Counties."
/>
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center gap-2 mb-4">
<MapPin className="h-6 w-6 text-secondary" />
<h2 className="text-3xl md:text-4xl font-bold tracking-tight text-balance">Service Areas in Utah</h2>
</div>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
Proudly serving businesses across Davis, Salt Lake, and Utah Counties
</p>
</div>
<div className="grid gap-8 lg:grid-cols-2 items-center max-w-6xl mx-auto">
<div className="order-2 lg:order-1 relative aspect-[926/1024] rounded-lg shadow-lg overflow-hidden">
<PublicSurface className="grid items-center gap-6 p-5 md:p-7 lg:grid-cols-[1.05fr_0.95fr]">
<div className="order-2 lg:order-1 relative overflow-hidden rounded-[1.5rem] border border-border/60 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))]">
<div className="relative aspect-[926/1024]">
<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"
@ -47,41 +60,62 @@ export function ServiceAreasSection() {
className="object-cover"
/>
</div>
</div>
<div className="order-1 lg:order-2">
<h3 className="text-2xl font-bold mb-6">Cities We Serve</h3>
<div className="grid grid-cols-2 gap-3">
{locations.map((location) => (
<Link
key={location.slug}
href={`/vending-machines-${location.slug}`}
className="flex items-center gap-2 group"
>
<div className="h-2 w-2 rounded-full bg-secondary flex-shrink-0" />
<span className="text-foreground group-hover:text-secondary transition-colors">
{location.city}
</span>
</Link>
))}
<div className="order-1 lg:order-2">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<MapPin className="h-5 w-5" />
</div>
<div className="mt-6 space-y-3">
<p className="text-muted-foreground">
Don't see your city? Give us a call at{" "}
<a href="tel:+14352339668" className="font-semibold hover:underline">
(435) 233-9668
</a>{" "}
to check if we can serve your location!
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
City Coverage
</p>
<h3 className="text-2xl font-semibold tracking-tight text-balance">
Cities We Serve
</h3>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
{locations.map((location) => (
<Link
key={location.slug}
href={`/vending-machines-${location.slug}`}
className="group flex min-h-11 items-center gap-2 rounded-full border border-border/60 bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
>
<div className="h-2 w-2 rounded-full bg-primary/70 flex-shrink-0 transition group-hover:scale-110" />
<span className="transition-colors">{location.city}</span>
</Link>
))}
</div>
<PublicInset className="mt-6">
<p className="text-sm leading-relaxed text-muted-foreground">
Don't see your city? Give us a call at{" "}
<a
href="tel:+14352339668"
className="font-semibold text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
>
(435) 233-9668
</a>{" "}
to check if we can serve your location!
</p>
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
<Link
href="/service-areas"
className="inline-flex items-center gap-2 hover:underline font-medium"
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
View All Service Areas
</Link>
<Link
href="/contact-us#contact-form"
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
>
Ask about your location
</Link>
</div>
</div>
</PublicInset>
</div>
</div>
</section>
</PublicSurface>
</PublicSection>
)
}

View file

@ -1,13 +1,33 @@
"use client"
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
type FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import Link from "next/link"
import { AlertCircle, Loader2, MessageSquare, Phone, SquarePen, X } from "lucide-react"
import {
AlertCircle,
Loader2,
MessageSquare,
Phone,
SquarePen,
X,
} from "lucide-react"
import { usePathname } from "next/navigation"
import { AssistantAvatar } from "@/components/assistant-avatar"
import { FormInput } from "@/components/forms/form-input"
import { FormSelect } from "@/components/forms/form-select"
import { SmsConsentFields } from "@/components/forms/sms-consent-fields"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
} from "@/components/ui/drawer"
import { cn } from "@/lib/utils"
import {
getSiteChatBootstrap,
@ -59,11 +79,13 @@ type ChatApiResponse = {
limits?: ChatLimitStatus
}
const CHAT_UNAVAILABLE_MESSAGE = "Jessica is temporarily unavailable right now. Please call or use the contact form."
const CHAT_UNAVAILABLE_MESSAGE =
"Jessica is temporarily unavailable right now. Please call or use the contact form."
const SESSION_STORAGE_KEY = "rmv-site-chat-session"
const PROFILE_STORAGE_KEY = "rmv-site-chat-profile"
const PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))"
const DESKTOP_PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))"
const MOBILE_DRAWER_MAX_HEIGHT = "min(48rem, calc(100dvh - 0.5rem))"
const MOBILE_CHAT_MEDIA_QUERY = "(max-width: 767px)"
function createMessage(role: ChatRole, content: string): ChatMessage {
@ -74,7 +96,9 @@ function createMessage(role: ChatRole, content: string): ChatMessage {
}
}
function createEmptyProfileDraft(consentSourcePage: string): ChatVisitorProfile {
function createEmptyProfileDraft(
consentSourcePage: string
): ChatVisitorProfile {
return {
name: "",
phone: "",
@ -90,26 +114,43 @@ function createEmptyProfileDraft(consentSourcePage: string): ChatVisitorProfile
function normalizeProfile(
value: Partial<ChatVisitorProfile> | null | undefined,
fallbackSourcePage: string,
fallbackSourcePage: string
): ChatVisitorProfile | null {
if (!value) {
return null
}
const profile = {
name: String(value.name || "").replace(/\s+/g, " ").trim().slice(0, 80),
phone: String(value.phone || "").replace(/\s+/g, " ").trim().slice(0, 40),
email: String(value.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(),
intent: String(value.intent || "").replace(/\s+/g, " ").trim().slice(0, 80),
name: String(value.name || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80),
phone: String(value.phone || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 40),
email: String(value.email || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120)
.toLowerCase(),
intent: String(value.intent || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80),
serviceTextConsent: Boolean(value.serviceTextConsent),
marketingTextConsent: Boolean(value.marketingTextConsent),
consentVersion: String(value.consentVersion || SMS_CONSENT_VERSION).trim() || SMS_CONSENT_VERSION,
consentVersion:
String(value.consentVersion || SMS_CONSENT_VERSION).trim() ||
SMS_CONSENT_VERSION,
consentCapturedAt:
typeof value.consentCapturedAt === "string" && value.consentCapturedAt.trim()
typeof value.consentCapturedAt === "string" &&
value.consentCapturedAt.trim()
? value.consentCapturedAt
: new Date().toISOString(),
consentSourcePage:
typeof value.consentSourcePage === "string" && value.consentSourcePage.trim()
typeof value.consentSourcePage === "string" &&
value.consentSourcePage.trim()
? value.consentSourcePage.trim()
: fallbackSourcePage,
}
@ -136,7 +177,10 @@ function normalizeProfile(
function createIntroMessage(profile: ChatVisitorProfile) {
const firstName = profile.name.split(" ")[0] || profile.name
const intentLabel = profile.intent.toLowerCase()
return createMessage("assistant", `Hi ${firstName}, I've got your ${intentLabel} request. How can I help?`)
return createMessage(
"assistant",
`Hi ${firstName}, I've got your ${intentLabel} request. How can I help?`
)
}
function SupportHint({
@ -156,10 +200,13 @@ function SupportHint({
return (
<p className="text-xs leading-relaxed text-muted-foreground">
Ready to get started? Use{" "}
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
<Link
href={formHref}
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
>
{formLabel}
</Link>
{" "}and we&apos;ll help you plan the right setup.
</Link>{" "}
and we&apos;ll help you plan the right setup.
</p>
)
}
@ -168,11 +215,17 @@ function SupportHint({
return (
<p className="text-xs leading-relaxed text-muted-foreground">
For repairs or moving, text photos or videos to{" "}
<a href={smsUrl} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
<a
href={smsUrl}
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
>
{smsNumber}
</a>{" "}
or use the{" "}
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
<Link
href={formHref}
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
>
{formLabel}
</Link>
.
@ -183,10 +236,13 @@ function SupportHint({
return (
<p className="text-xs leading-relaxed text-muted-foreground">
Need more help? Use{" "}
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
<Link
href={formHref}
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
>
{formLabel}
</Link>
{" "}and our team will follow up.
</Link>{" "}
and our team will follow up.
</p>
)
}
@ -195,14 +251,17 @@ export function SiteChatWidget() {
const pathname = usePathname()
const bootstrap = useMemo(() => getSiteChatBootstrap(), [])
const intentOptions = useMemo(
() => CHAT_INTENT_OPTIONS.map((option) => ({ label: option, value: option })),
[],
() =>
CHAT_INTENT_OPTIONS.map((option) => ({ label: option, value: option })),
[]
)
const [isMobileViewport, setIsMobileViewport] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [draft, setDraft] = useState("")
const [profileDraft, setProfileDraft] = useState<ChatVisitorProfile>(() => createEmptyProfileDraft(pathname || "/"))
const [profileDraft, setProfileDraft] = useState<ChatVisitorProfile>(() =>
createEmptyProfileDraft(pathname || "/")
)
const [profile, setProfile] = useState<ChatVisitorProfile | null>(null)
const [error, setError] = useState<string | null>(null)
const [profileError, setProfileError] = useState<string | null>(null)
@ -219,14 +278,15 @@ export function SiteChatWidget() {
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleViewportChange)
return () => mediaQuery.removeEventListener("change", handleViewportChange)
return () =>
mediaQuery.removeEventListener("change", handleViewportChange)
}
mediaQuery.addListener(handleViewportChange)
return () => mediaQuery.removeListener(handleViewportChange)
}, [])
const isSuppressed = isSiteChatSuppressedRoute(pathname) || isMobileViewport
const isSuppressed = isSiteChatSuppressedRoute(pathname)
useEffect(() => {
const storedSessionId = window.localStorage.getItem(SESSION_STORAGE_KEY)
@ -241,7 +301,10 @@ export function SiteChatWidget() {
}
try {
const parsedProfile = normalizeProfile(JSON.parse(rawProfile), pathname || "/")
const parsedProfile = normalizeProfile(
JSON.parse(rawProfile),
pathname || "/"
)
if (!parsedProfile) {
window.localStorage.removeItem(PROFILE_STORAGE_KEY)
setProfileDraft(createEmptyProfileDraft(pathname || "/"))
@ -259,7 +322,7 @@ export function SiteChatWidget() {
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" })
}, [messages.length])
}, [messages.length, isOpen])
useEffect(() => {
if (!isSuppressed) {
@ -274,10 +337,10 @@ export function SiteChatWidget() {
const formLabel = getBestIntentFormLabel(activeIntent)
const profileDraftIsReady = Boolean(
profileDraft.name.trim() &&
profileDraft.phone.trim() &&
profileDraft.email.trim() &&
profileDraft.intent &&
profileDraft.serviceTextConsent,
profileDraft.phone.trim() &&
profileDraft.email.trim() &&
profileDraft.intent &&
profileDraft.serviceTextConsent
)
const handleProfileSubmit = useCallback(
@ -286,15 +349,18 @@ export function SiteChatWidget() {
const nextProfile = normalizeProfile(
{
...profileDraft,
consentCapturedAt: profileDraft.consentCapturedAt || new Date().toISOString(),
consentCapturedAt:
profileDraft.consentCapturedAt || new Date().toISOString(),
consentSourcePage: pathname || "/",
consentVersion: profileDraft.consentVersion || SMS_CONSENT_VERSION,
},
pathname || "/",
pathname || "/"
)
if (!nextProfile) {
setProfileError("Enter a valid name, phone, email, intent, and required service SMS consent.")
setProfileError(
"Enter a valid name, phone, email, intent, and required service SMS consent."
)
return
}
@ -303,9 +369,12 @@ export function SiteChatWidget() {
setMessages([createIntroMessage(nextProfile)])
setProfileError(null)
setError(null)
window.localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(nextProfile))
window.localStorage.setItem(
PROFILE_STORAGE_KEY,
JSON.stringify(nextProfile)
)
},
[pathname, profileDraft],
[pathname, profileDraft]
)
const handleProfileReset = useCallback(() => {
@ -371,25 +440,389 @@ export function SiteChatWidget() {
throw new Error(data.error || CHAT_UNAVAILABLE_MESSAGE)
}
setMessages((current) => [...current, createMessage("assistant", data.reply || "")].slice(-12))
setMessages((current) =>
[...current, createMessage("assistant", data.reply || "")].slice(-12)
)
} catch (chatError) {
const message = chatError instanceof Error ? chatError.message : CHAT_UNAVAILABLE_MESSAGE
const message =
chatError instanceof Error
? chatError.message
: CHAT_UNAVAILABLE_MESSAGE
setError(message)
setMessages((current) => [
...current,
createMessage("assistant", "I'm temporarily unavailable right now. Please call us or use the contact form."),
].slice(-12))
setMessages((current) =>
[
...current,
createMessage(
"assistant",
"I'm temporarily unavailable right now. Please call us or use the contact form."
),
].slice(-12)
)
} finally {
setIsSending(false)
}
},
[draft, isSending, messages, pathname, profile, sessionId],
[draft, isSending, messages, pathname, profile, sessionId]
)
if (isSuppressed) {
return null
}
const renderHeader = (isMobileLayout: boolean) => (
<div
className={cn(
"flex items-start justify-between border-b border-border/70 px-4 py-4",
isMobileLayout && "px-5 pt-3"
)}
>
<div className="flex items-center gap-3">
<AssistantAvatar
src={bootstrap.avatarSrc}
alt={bootstrap.assistantName}
sizeClassName="h-11 w-11"
/>
<div>
<p className="text-base font-semibold text-foreground">
{bootstrap.assistantName}
</p>
<p className="text-xs text-muted-foreground">
Text support for service, sales, and placement questions
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-white text-foreground transition hover:border-primary/50 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
aria-label="Close chat"
>
<X className="h-4 w-4" />
</button>
</div>
)
const renderProfileGate = (isMobileLayout: boolean) => (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-4",
isMobileLayout ? "pb-6 pt-4" : "py-4"
)}
>
<form onSubmit={handleProfileSubmit} className="space-y-4">
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
<p className="text-sm font-medium text-foreground">
Start with your details
</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
We use this to route the conversation to the right team member.
</p>
<div className="mt-4 space-y-4">
<FormInput
id="site-chat-name"
label="Name"
value={profileDraft.name}
onChange={(event) =>
setProfileDraft((current) => ({
...current,
name: event.target.value,
}))
}
autoComplete="name"
required
/>
<FormInput
id="site-chat-phone"
label="Phone"
value={profileDraft.phone}
onChange={(event) =>
setProfileDraft((current) => ({
...current,
phone: event.target.value,
}))
}
autoComplete="tel"
inputMode="tel"
type="tel"
required
/>
<FormInput
id="site-chat-email"
label="Email"
value={profileDraft.email}
onChange={(event) =>
setProfileDraft((current) => ({
...current,
email: event.target.value,
}))
}
autoComplete="email"
inputMode="email"
type="email"
required
/>
<FormSelect
id="site-chat-intent"
label="Intent"
value={profileDraft.intent}
onChange={(event) =>
setProfileDraft((current) => ({
...current,
intent: event.target.value,
}))
}
options={intentOptions}
placeholder="Choose one"
required
/>
</div>
</div>
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
<p className="text-sm font-medium text-foreground">Text updates</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Required service consent covers scheduling, support, repairs,
moving, and follow-up texts for this request.
</p>
<div className="mt-4">
<SmsConsentFields
idPrefix="site-chat"
mode="chat"
serviceChecked={profileDraft.serviceTextConsent}
onServiceChange={(checked) =>
setProfileDraft((current) => ({
...current,
serviceTextConsent: checked,
consentVersion: SMS_CONSENT_VERSION,
consentSourcePage: pathname || "/",
}))
}
serviceError={
profileError && !profileDraft.serviceTextConsent
? profileError
: undefined
}
/>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="submit"
disabled={!profileDraftIsReady}
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<MessageSquare className="h-4 w-4" />
Start chat
</button>
<a
href={bootstrap.callUrl}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
>
<Phone className="h-4 w-4" />
Call
</a>
</div>
<SupportHint
formHref={formHref}
formLabel={formLabel}
intent={activeIntent}
smsNumber={bootstrap.smsNumber}
smsUrl={bootstrap.smsUrl}
/>
</form>
{profileError ? (
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{profileError}</p>
</div>
</div>
) : null}
</div>
)
const renderConversation = (isMobileLayout: boolean) => (
<>
<div className="flex items-center justify-between border-b border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
<span>
Chatting as{" "}
<span className="font-medium text-foreground">{profile?.name}</span>
</span>
<button
type="button"
onClick={handleProfileReset}
className="inline-flex items-center gap-1 font-medium text-foreground transition hover:text-primary"
>
<SquarePen className="h-3.5 w-3.5" />
Edit details
</button>
</div>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-4 py-4",
isMobileLayout ? "pb-3" : "max-h-[22rem]"
)}
>
<div className="space-y-3">
{messages.map((message) => (
<div key={message.id} className="space-y-1">
<div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{message.role === "assistant"
? bootstrap.assistantName
: profile?.name}
</div>
<div
className={cn(
"max-w-[92%] rounded-2xl px-3 py-2 text-sm shadow-sm",
message.role === "assistant"
? "bg-muted text-foreground"
: "ml-auto bg-primary text-primary-foreground"
)}
>
{message.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
</div>
<div className="border-t border-border/70 bg-white px-4 py-4">
<form onSubmit={handleSubmit} className="space-y-3">
<label
htmlFor="site-chat-input"
className="text-sm font-semibold text-foreground"
>
Message
</label>
<textarea
id="site-chat-input"
data-testid="site-chat-input"
value={draft}
onChange={(event) =>
setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))
}
placeholder="Describe what you need"
rows={isMobileLayout ? 4 : 3}
disabled={isSending}
className="min-h-24 w-full rounded-2xl border border-border/70 bg-white px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60"
/>
<SupportHint
formHref={formHref}
formLabel={formLabel}
intent={profile?.intent || ""}
smsNumber={bootstrap.smsNumber}
smsUrl={bootstrap.smsUrl}
/>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="submit"
data-testid="site-chat-send"
disabled={isSending || !draft.trim()}
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquare className="h-4 w-4" />
)}
{isSending ? "Sending..." : "Send"}
</button>
<a
href={bootstrap.callUrl}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
>
<Phone className="h-4 w-4" />
Call
</a>
</div>
</form>
{error ? (
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{error}</p>
</div>
</div>
) : null}
</div>
</>
)
const renderPanel = (isMobileLayout: boolean) => (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white">
{renderHeader(isMobileLayout)}
{!profile
? renderProfileGate(isMobileLayout)
: renderConversation(isMobileLayout)}
</div>
)
if (isMobileViewport) {
return (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
{!isOpen ? (
<div
className="fixed inset-x-0 z-40 flex justify-center px-4"
style={{
bottom: "calc(env(safe-area-inset-bottom, 0px) + 0.75rem)",
}}
>
<button
type="button"
data-testid="site-chat-launcher"
onClick={() => setIsOpen(true)}
className="inline-flex min-h-14 w-full max-w-sm items-center justify-between gap-3 rounded-full border border-white/70 bg-white/96 px-4 py-3 shadow-[0_20px_60px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_24px_68px_rgba(0,0,0,0.22)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
aria-label={`Open chat with ${bootstrap.assistantName}`}
>
<div className="flex items-center gap-3 text-left">
<AssistantAvatar
src={bootstrap.avatarSrc}
alt={bootstrap.assistantName}
sizeClassName="h-10 w-10"
/>
<div>
<div className="text-sm font-semibold text-foreground">
Chat with {bootstrap.assistantName}
</div>
<div className="text-xs text-muted-foreground">
Service, sales, repairs, and placement help
</div>
</div>
</div>
<MessageSquare className="h-5 w-5 text-primary" />
</button>
</div>
) : null}
<DrawerContent
className="max-h-[85dvh] rounded-t-[1.75rem] border-border/70 bg-white px-0"
style={{ maxHeight: MOBILE_DRAWER_MAX_HEIGHT }}
>
<DrawerTitle className="sr-only">
Chat with {bootstrap.assistantName}
</DrawerTitle>
<DrawerDescription className="sr-only">
Ask about service, repairs, sales, or free placement.
</DrawerDescription>
{renderPanel(true)}
</DrawerContent>
</Drawer>
)
}
return (
<div
className="pointer-events-none fixed right-4 z-40 flex flex-col items-end gap-3"
@ -399,233 +832,9 @@ export function SiteChatWidget() {
<div
data-testid="site-chat-panel"
className="pointer-events-auto flex w-[min(24rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-[1.75rem] border border-border/70 bg-white shadow-[0_24px_80px_rgba(0,0,0,0.2)]"
style={{ maxHeight: PANEL_MAX_HEIGHT }}
style={{ maxHeight: DESKTOP_PANEL_MAX_HEIGHT }}
>
<div className="flex items-start justify-between border-b border-border/70 px-4 py-4">
<div className="flex items-center gap-3">
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-11 w-11" />
<div>
<p className="text-base font-semibold text-foreground">{bootstrap.assistantName}</p>
<p className="text-xs text-muted-foreground">Text support</p>
</div>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-white text-foreground transition hover:border-primary/50 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
aria-label="Close chat"
>
<X className="h-4 w-4" />
</button>
</div>
{!profile ? (
<div className="min-h-0 overflow-y-auto px-4 py-4">
<form onSubmit={handleProfileSubmit} className="space-y-4">
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
<p className="text-sm font-medium text-foreground">Start with your details</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
We use this to route the conversation to the right team member.
</p>
<div className="mt-4 space-y-4">
<FormInput
id="site-chat-name"
label="Name"
value={profileDraft.name}
onChange={(event) => setProfileDraft((current) => ({ ...current, name: event.target.value }))}
autoComplete="name"
required
/>
<FormInput
id="site-chat-phone"
label="Phone"
value={profileDraft.phone}
onChange={(event) => setProfileDraft((current) => ({ ...current, phone: event.target.value }))}
autoComplete="tel"
inputMode="tel"
type="tel"
required
/>
<FormInput
id="site-chat-email"
label="Email"
value={profileDraft.email}
onChange={(event) => setProfileDraft((current) => ({ ...current, email: event.target.value }))}
autoComplete="email"
inputMode="email"
type="email"
required
/>
<FormSelect
id="site-chat-intent"
label="Intent"
value={profileDraft.intent}
onChange={(event) => setProfileDraft((current) => ({ ...current, intent: event.target.value }))}
options={intentOptions}
placeholder="Choose one"
required
/>
</div>
</div>
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
<p className="text-sm font-medium text-foreground">Text updates</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Required service consent covers scheduling, support, repairs, moving, and follow-up texts for this request.
</p>
<div className="mt-4">
<SmsConsentFields
idPrefix="site-chat"
mode="chat"
serviceChecked={profileDraft.serviceTextConsent}
onServiceChange={(checked) =>
setProfileDraft((current) => ({
...current,
serviceTextConsent: checked,
consentVersion: SMS_CONSENT_VERSION,
consentSourcePage: pathname || "/",
}))
}
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
/>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="submit"
disabled={!profileDraftIsReady}
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<MessageSquare className="h-4 w-4" />
Start chat
</button>
<a
href={bootstrap.callUrl}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
>
<Phone className="h-4 w-4" />
Call
</a>
</div>
<SupportHint
formHref={formHref}
formLabel={formLabel}
intent={activeIntent}
smsNumber={bootstrap.smsNumber}
smsUrl={bootstrap.smsUrl}
/>
</form>
{profileError ? (
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{profileError}</p>
</div>
</div>
) : null}
</div>
) : (
<>
<div className="flex items-center justify-between border-b border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
<span>
Chatting as <span className="font-medium text-foreground">{profile.name}</span>
</span>
<button
type="button"
onClick={handleProfileReset}
className="inline-flex items-center gap-1 font-medium text-foreground transition hover:text-primary"
>
<SquarePen className="h-3.5 w-3.5" />
Edit details
</button>
</div>
<div className="max-h-[22rem] overflow-y-auto px-4 py-4">
<div className="space-y-3">
{messages.map((message) => (
<div key={message.id} className="space-y-1">
<div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{message.role === "assistant" ? bootstrap.assistantName : profile.name}
</div>
<div
className={cn(
"max-w-[92%] rounded-2xl px-3 py-2 text-sm shadow-sm",
message.role === "assistant"
? "bg-muted text-foreground"
: "ml-auto bg-primary text-primary-foreground",
)}
>
{message.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
</div>
<div className="border-t border-border/70 bg-white px-4 py-4">
<form onSubmit={handleSubmit} className="space-y-3">
<label htmlFor="site-chat-input" className="text-sm font-semibold text-foreground">
Message
</label>
<textarea
id="site-chat-input"
data-testid="site-chat-input"
value={draft}
onChange={(event) => setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))}
placeholder="Describe what you need"
rows={3}
disabled={isSending}
className="min-h-24 w-full rounded-2xl border border-border/70 bg-white px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60"
/>
<SupportHint
formHref={formHref}
formLabel={formLabel}
intent={profile.intent}
smsNumber={bootstrap.smsNumber}
smsUrl={bootstrap.smsUrl}
/>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="submit"
data-testid="site-chat-send"
disabled={isSending || !draft.trim()}
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSending ? <Loader2 className="h-4 w-4 animate-spin" /> : <MessageSquare className="h-4 w-4" />}
{isSending ? "Sending..." : "Send"}
</button>
<a
href={bootstrap.callUrl}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
>
<Phone className="h-4 w-4" />
Call
</a>
</div>
</form>
{error ? (
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{error}</p>
</div>
</div>
) : null}
</div>
</>
)}
{renderPanel(false)}
</div>
) : null}
@ -637,7 +846,11 @@ export function SiteChatWidget() {
className="pointer-events-auto inline-flex h-14 w-14 items-center justify-center rounded-full border border-white/70 bg-white shadow-[0_20px_60px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_24px_68px_rgba(0,0,0,0.22)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
aria-label={`Open chat with ${bootstrap.assistantName}`}
>
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-12 w-12" />
<AssistantAvatar
src={bootstrap.avatarSrc}
alt={bootstrap.assistantName}
sizeClassName="h-12 w-12"
/>
</button>
) : null}
</div>

View file

@ -1,4 +1,8 @@
import { Card, CardContent } from "@/components/ui/card"
import {
PublicInset,
PublicSection,
PublicSurface,
} from "@/components/public-surface"
export function StatsSection() {
const stats = [
@ -9,23 +13,24 @@ export function StatsSection() {
]
return (
<section className="py-16 md:py-24 bg-card/30">
<div className="container mx-auto px-4">
<PublicSection tone="muted">
<PublicSurface className="p-4 sm:p-5 md:p-6">
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
{stats.map((stat, index) => (
<Card key={index} className="border-0 shadow-none bg-transparent">
<CardContent className="p-0 text-center">
<div className="text-4xl md:text-5xl font-bold text-primary mb-2">
{stat.value}
</div>
<div className="text-sm md:text-base text-muted-foreground">
{stat.label}
</div>
</CardContent>
</Card>
{stats.map((stat) => (
<PublicInset
key={stat.label}
className="border-0 bg-transparent px-3 py-4 text-center shadow-none"
>
<div className="text-4xl md:text-5xl font-bold text-primary mb-2">
{stat.value}
</div>
<div className="text-sm md:text-base text-muted-foreground">
{stat.label}
</div>
</PublicInset>
))}
</div>
</div>
</section>
</PublicSurface>
</PublicSection>
)
}

View file

@ -44,7 +44,7 @@ export function VendingMachinesPage() {
]
return (
<div className="container mx-auto px-4 py-10 md:py-14">
<div className="public-page">
<Breadcrumbs
className="mb-6"
items={[{ label: "Vending Machines", href: "/vending-machines" }]}