diff --git a/app/globals.css b/app/globals.css
index 739203be..9bacf058 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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 */
diff --git a/app/manuals/page.tsx b/app/manuals/page.tsx
index 05cee685..dfbd5b79 100644
--- a/app/manuals/page.tsx
+++ b/app/manuals/page.tsx
@@ -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) }}
/>
-
+
>
diff --git a/app/products/[id]/page.tsx b/app/products/[id]/page.tsx
index 2bbe82e9..4a53f94b 100644
--- a/app/products/[id]/page.tsx
+++ b/app/products/[id]/page.tsx
@@ -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 (
-
-
- {/* Product Image */}
-
-
-
-
-
-
-
-
- {/* Product Details */}
-
-
{product.name}
+
+
+
+
+
+
+
+
-
+
+ {product.name}
+
+
${product.price.toFixed(2)} {product.currency.toUpperCase()}
{product.description && (
-
-
Description
+
+ Description
'),
+ __html: product.description.replace(/\n/g, " "),
}}
/>
-
+
)}
{product.metadata && Object.keys(product.metadata).length > 0 && (
-
-
Specifications
-
+
+ Specifications
+
{Object.entries(product.metadata).map(([key, value]) => (
-
-
{key}:
+
+
+ {key}
+
{value}
))}
-
+
)}
-
+
)
}
-
diff --git a/app/service-areas/page.tsx b/app/service-areas/page.tsx
index dc9f0196..fdff7e1a 100644
--- a/app/service-areas/page.tsx
+++ b/app/service-areas/page.tsx
@@ -100,7 +100,7 @@ export default function ServiceAreasPage() {
)
return (
-
+
- If you're close to one of our current routes, we may still
- be able to help. Reach out and we'll confirm whether your
+ If you're close to one of our current routes, we may still be
+ able to help. Reach out and we'll confirm whether your
location fits our current coverage and service schedule.
@@ -169,8 +169,8 @@ export default function ServiceAreasPage() {
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.
diff --git a/app/services/moving/page.tsx b/app/services/moving/page.tsx
index 1e9053cc..caf9b4b2 100644
--- a/app/services/moving/page.tsx
+++ b/app/services/moving/page.tsx
@@ -23,7 +23,7 @@ export const metadata: Metadata = generateSEOMetadata({
export default function MovingServicesPage() {
return (
-
+
-
-
-
-
- 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.
-
-
- Vending machines are heavy (often 400–900+ 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.
-
-
-
-
+
+
+
+ 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.
+
+
+ Vending machines are heavy (often 400–900+ 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.
+
+
+
{/* Image Gallery Section */}
@@ -75,10 +73,7 @@ export default function MovingServicesPage() {
{/* Image 1 */}
-
+
-
+
-
+
Why Choose Us for Your Vending Move?
-
-
-
-
-
-
-
- Years of hands-on vending industry experience
- {" "}
- — we understand the unique vulnerabilities of snack, drink,
- combo, and refrigerated machines.
-
-
-
-
-
- Fully insured for
- complete peace of mind.
-
-
-
-
-
-
- Minimal disruption:
- {" "}
- Fast, coordinated service scheduled around your business
- hours.
-
-
-
-
-
-
- One-machine or multi-machine jobs
- {" "}
- handled efficiently.
-
-
-
-
-
-
- Commitment to safety
- {" "}
- for our team, your staff, and your equipment.
-
-
-
-
-
+
+
+
+
+
+
+ Years of hands-on vending industry experience
+ {" "}
+ — we understand the unique vulnerabilities of snack, drink,
+ combo, and refrigerated machines.
+
+
+
+
+
+ Fully insured for
+ complete peace of mind.
+
+
+
+
+
+ Minimal disruption: {" "}
+ Fast, coordinated service scheduled around your business hours.
+
+
+
+
+
+
+ One-machine or multi-machine jobs
+ {" "}
+ handled efficiently.
+
+
+
+
+
+
+ Commitment to safety
+ {" "}
+ for our team, your staff, and your equipment.
+
+
+
+
{/* CTA Section */}
diff --git a/app/services/repairs/page.tsx b/app/services/repairs/page.tsx
index 7834124c..ebf232ed 100644
--- a/app/services/repairs/page.tsx
+++ b/app/services/repairs/page.tsx
@@ -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 {
}
}
- 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() {
No content available.
)
- 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" },
]}
/>
-
-
- {page.title || "Vending Machine Repairs and Service"}
-
-
+
+
Rocky Mountain Vending delivers expert{" "}
{" "}
services, contact us today for fast, professional solutions!
-
+
{/* Images Carousel */}
-
+
@@ -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.{" "}
-
+
Contact us
, and we'll let you know how we can help.
diff --git a/app/services/service-areas/page.tsx b/app/services/service-areas/page.tsx
index 0ebc13b0..33bf2ca2 100644
--- a/app/services/service-areas/page.tsx
+++ b/app/services/service-areas/page.tsx
@@ -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 (
-
-
+
+
- {/* Services Overview */}
-
-
- Our Services
-
-
- {services.map((service, index) => (
-
-
+
+
+
+
+ {services.map((service, index) => (
+
{service.title}
- {service.description}
-
-
- ))}
-
+
+ {service.description}
+
+
+ ))}
+
+
- {/* Salt Lake County */}
-
-
-
- Salt Lake County
-
-
- Serving 14 cities in Salt Lake County with reliable vending services
-
-
-
- {saltLakeCounty.map((location) => (
-
-
-
-
+
+ {counties.map((county) => (
+
+
+
+
+ {county.locations.map((location) => (
+
+
{location.city}
-
-
+
+
{location.zipCode}
-
-
-
- Services Available:
-
-
- • Vending Machine Sales
- • Repair Services
- • Healthy Options
- • Maintenance
-
-
-
-
- View Services
-
-
-
-
-
- ))}
-
-
- {/* Davis County */}
-
-
-
- Davis County
+
+
+ Services Available
+
+
+ Vending machine sales
+ Repair services
+ Healthy snack and beverage options
+ Maintenance and restocking
+
+
+
+
+
+ View Services
+
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ Ready to get started?
-
- Supporting businesses from Ogden to Layton with reliable vending
- services
+
+ All services are available across our coverage area. Reach out and
+ we'll help you figure out the right next step for your
+ location.
-
-
- {davisCounty.map((location) => (
-
-
-
-
-
- {location.city}
-
-
-
- {location.zipCode}
-
-
-
-
-
- Services Available:
-
-
- • Vending Machine Sales
- • Repair Services
- • Healthy Options
- • Maintenance
-
-
-
-
- View Services
-
-
-
-
-
- ))}
-
-
-
- {/* Utah County */}
-
-
-
- Utah County
-
-
- Serving Provo and surrounding areas with quality vending services
-
-
-
- {utahCounty.map((location) => (
-
-
-
-
-
- {location.city}
-
-
-
- {location.zipCode}
-
-
-
-
-
- Services Available:
-
-
- • Vending Machine Sales
- • Repair Services
- • Healthy Options
- • Maintenance
-
-
-
-
- View Services
-
-
-
-
-
- ))}
-
-
-
- {/* Call to Action */}
-
-
- Ready to Get Started?
-
-
- All services are available across all 20 service areas. Contact us
- today to learn more about vending machine solutions for your business.
-
-
-
- Contact Us
-
-
-
- View All Service Areas
-
-
-
+
+
+
+ Contact Us
+
+
+
+
+ View All Service Areas
+
+
+
+
+
+ This route stays available for legacy links, but the primary
+ service-area experience lives on the main{" "}
+
+ Utah service areas page
+
+ .
+
+
+
)
diff --git a/components/about-page.tsx b/components/about-page.tsx
index bb7c8632..2cc0ab61 100644
--- a/components/about-page.tsx
+++ b/components/about-page.tsx
@@ -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 (
-
- {/* Header */}
-
+
+
- {/* Main Content - Image and Text */}
-
- {/* Left Column - Image */}
-
-
-
+
+
- {/* Right Column - Text Content */}
-
-
-
-
-
- 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.
-
-
- 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.
-
-
- 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.
-
-
- ~Matt
-
-
-
-
+
+
+
+ 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.
+
+
+ Our focus is on healthy vending and dependable service, while
+ still helping locations that want traditional snack and drink
+ options too.
+
+
+ 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.
+
+
+
+
+
+ If you're looking for a more personal vending partner with
+ modern tools and real follow-through, that's exactly what
+ we set out to build.
+
+
+ ~Matt
+
+
+
-
+
)
}
-
diff --git a/components/contact-page.tsx b/components/contact-page.tsx
index 76465a01..6889eba5 100644
--- a/components/contact-page.tsx
+++ b/components/contact-page.tsx
@@ -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 (
-
+
-
For repairs or moving, include the machine model and a clear description of what's happening.
+
+ For repairs or moving, include the machine model and a clear
+ description of what's happening.
+
-
console.log("Contact form submitted:", data)} />
+ console.log("Contact form submitted:", data)}
+ />
-
-
- Direct Options
- Reach the team directly
-
- We monitor calls, texts, and email throughout the business day. If you're sending repair photos or videos, text them to the number below.
-
+
+
+ Direct Options
+
+
+ Reach the team directly
+
+
+ We monitor calls, texts, and email throughout the business day. If
+ you're sending repair photos or videos, text them to the number
+ below.
+
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
+
+
+
-
-
+
+
+ Business Hours
+
+
+ When we're available
+
+
+
+
+
+ {businessHours.map((schedule) => (
+
+
+ {schedule.day}
+
+
+ {schedule.hours}
+
+
+ ))}
+
+
diff --git a/components/footer.tsx b/components/footer.tsx
index 47f5f860..6d91b81b 100644
--- a/components/footer.tsx
+++ b/components/footer.tsx
@@ -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 (
-
-
-
+
+
+
{/* Company Info */}
-
+
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.
@@ -90,7 +104,7 @@ export function Footer() {
{/* Services */}
-
+
Services
@@ -145,7 +159,7 @@ export function Footer() {
{/* Company */}
-
+
Company
@@ -184,7 +198,7 @@ export function Footer() {
{/* Service Areas */}
-
+
Service Areas
@@ -248,8 +262,8 @@ export function Footer() {
-
-
+
+
© {currentYear} Rocky Mountain Vending LLC. All rights reserved.
diff --git a/components/header.tsx b/components/header.tsx
index 52adf4a0..d5f68843 100644
--- a/components/header.tsx
+++ b/components/header.tsx
@@ -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 (
-
-
-
+
+
+
{/* Logo */}
{/* Desktop Navigation */}
-
-
+
+
Home
{/* Who We Serve Dropdown */}
-
+
Who We Serve
-
+
{whoWeServeItems.map((item) => (
-
+
{item.label}
))}
@@ -168,13 +188,25 @@ export function Header() {
{/* Vending Machines Dropdown */}
-
+
Vending Machines
-
+
{vendingMachinesItems.map((item) => (
-
+
{item.label}
))}
@@ -183,13 +215,25 @@ export function Header() {
{/* Food and Beverage Dropdown */}
-
+
Food & Beverage
-
+
{foodBeverageItems.map((item) => (
-
+
{item.label}
))}
@@ -198,13 +242,25 @@ export function Header() {
{/* Services Dropdown */}
-
+
Services
-
+
{servicesItems.map((item) => (
-
+
{item.label}
))}
@@ -213,13 +269,25 @@ export function Header() {
{/* Blog Posts Dropdown */}
-
+
Blog Posts
-
+
{blogPostsItems.map((item) => (
-
+
{item.label}
))}
@@ -228,55 +296,58 @@ export function Header() {
{/* About Us Dropdown */}
-
+
About Us
-
+
{aboutItems.map((item) => (
-
+
{item.label}
))}
-
+
Products
-
+
Service Areas
-
+
Contact Us
{/* Desktop CTA */}
-
+
dispatch({ type: "SET_CART", value: true })}
/>
(435) 233-9668
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
@@ -284,9 +355,10 @@ export function Header() {
{/* Mobile Menu Button */}
dispatch({ type: "TOGGLE_MENU" })}
aria-label="Toggle menu"
+ aria-expanded={state.isMenuOpen}
>
{state.isMenuOpen ? (
@@ -298,246 +370,252 @@ export function Header() {
{/* Mobile Navigation */}
{state.isMenuOpen && (
-
- dispatch({ type: "TOGGLE_MENU" })}
- >
- Home
-
+
+
+
+
dispatch({ type: "TOGGLE_MENU" })}
+ >
+ Home
+
- {/* Who We Serve Mobile Section */}
-
-
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
-
-
- {state.isWhoWeServeOpen && (
-
- {whoWeServeItems.map((item) => (
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- {item.label}
-
- ))}
+ {/* Who We Serve Mobile Section */}
+
+
dispatch({ type: "TOGGLE_WHO_WE_SERVE" })}
+ className={mobileGroupButtonClassName}
+ aria-label="Who We Serve menu"
+ aria-expanded={state.isWhoWeServeOpen}
+ aria-haspopup="true"
+ >
+ Who We Serve
+
+
+ {state.isWhoWeServeOpen && (
+
+ {whoWeServeItems.map((item) => (
+ dispatch({ type: "TOGGLE_MENU" })}
+ >
+ {item.label}
+
+ ))}
+
+ )}
- )}
-
- {/* Vending Machines Mobile */}
-
-
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
-
-
- {state.isVendingMachinesOpen && (
-
- {vendingMachinesItems.map((item) => (
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- {item.label}
-
- ))}
+ {/* Vending Machines Mobile */}
+
+
+ dispatch({ type: "TOGGLE_VENDING_MACHINES" })
+ }
+ className={mobileGroupButtonClassName}
+ aria-label="Vending Machines menu"
+ aria-expanded={state.isVendingMachinesOpen}
+ aria-haspopup="true"
+ >
+ Vending Machines
+
+
+ {state.isVendingMachinesOpen && (
+
+ {vendingMachinesItems.map((item) => (
+ dispatch({ type: "TOGGLE_MENU" })}
+ >
+ {item.label}
+
+ ))}
+
+ )}
- )}
-
- {/* Food and Beverage Mobile */}
-
-
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
-
-
- {state.isFoodBeverageOpen && (
-
- {foodBeverageItems.map((item) => (
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- {item.label}
-
- ))}
+ {/* Food and Beverage Mobile */}
+
+
dispatch({ type: "TOGGLE_FOOD_BEVERAGE" })}
+ className={mobileGroupButtonClassName}
+ aria-label="Food & Beverage menu"
+ aria-expanded={state.isFoodBeverageOpen}
+ aria-haspopup="true"
+ >
+ Food & Beverage
+
+
+ {state.isFoodBeverageOpen && (
+
+ {foodBeverageItems.map((item) => (
+ dispatch({ type: "TOGGLE_MENU" })}
+ >
+ {item.label}
+
+ ))}
+
+ )}
- )}
-
- {/* Services Mobile */}
-
-
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
-
-
- {state.isServicesOpen && (
-
- {servicesItems.map((item) => (
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- {item.label}
-
- ))}
+ {/* Services Mobile */}
+
+
dispatch({ type: "TOGGLE_SERVICES" })}
+ className={mobileGroupButtonClassName}
+ aria-label="Services menu"
+ aria-expanded={state.isServicesOpen}
+ aria-haspopup="true"
+ >
+ Services
+
+
+ {state.isServicesOpen && (
+
+ {servicesItems.map((item) => (
+ dispatch({ type: "TOGGLE_MENU" })}
+ >
+ {item.label}
+
+ ))}
+
+ )}
- )}
-
- {/* Blog Posts Mobile */}
-
-
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
-
-
- {state.isBlogPostsOpen && (
-
- {blogPostsItems.map((item) => (
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- {item.label}
-
- ))}
+ {/* Blog Posts Mobile */}
+
+
dispatch({ type: "TOGGLE_BLOG_POSTS" })}
+ className={mobileGroupButtonClassName}
+ aria-label="Blog Posts menu"
+ aria-expanded={state.isBlogPostsOpen}
+ aria-haspopup="true"
+ >
+ Blog Posts
+
+
+ {state.isBlogPostsOpen && (
+
+ {blogPostsItems.map((item) => (
+ dispatch({ type: "TOGGLE_MENU" })}
+ >
+ {item.label}
+
+ ))}
+
+ )}
- )}
-
- {/* About Us Mobile */}
-
-
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
-
-
- {state.isAboutOpen && (
-
- {aboutItems.map((item) => (
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- {item.label}
-
- ))}
+ {/* About Us Mobile */}
+
+
dispatch({ type: "TOGGLE_ABOUT" })}
+ className={mobileGroupButtonClassName}
+ aria-label="About menu"
+ aria-expanded={state.isAboutOpen}
+ aria-haspopup="true"
+ >
+ About Us
+
+
+ {state.isAboutOpen && (
+
+ {aboutItems.map((item) => (
+ dispatch({ type: "TOGGLE_MENU" })}
+ >
+ {item.label}
+
+ ))}
+
+ )}
- )}
-
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- Products
-
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- Service Areas
-
-
dispatch({ type: "TOGGLE_MENU" })}
- >
- Contact Us
-
+
dispatch({ type: "TOGGLE_MENU" })}
+ >
+ Products
+
+
dispatch({ type: "TOGGLE_MENU" })}
+ >
+ Service Areas
+
+
dispatch({ type: "TOGGLE_MENU" })}
+ >
+ Contact Us
+
-
-
{
- dispatch({ type: "TOGGLE_MENU" })
- dispatch({ type: "SET_CART", value: true })
- }}
- />
-
-
- (435) 233-9668
-
- {
- dispatch({ type: "TOGGLE_MENU" })
- dispatch({ type: "SET_MODAL", value: true })
- }}
- className="bg-primary hover:bg-primary/90 w-full"
- >
- Get Free Machine
-
+
+
{
+ dispatch({ type: "TOGGLE_MENU" })
+ dispatch({ type: "SET_CART", value: true })
+ }}
+ />
+
+
+ (435) 233-9668
+
+ {
+ 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
+
+
+
)}
diff --git a/components/hero-section.tsx b/components/hero-section.tsx
index 9a488398..47291e7a 100644
--- a/components/hero-section.tsx
+++ b/components/hero-section.tsx
@@ -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 (
-
-
-
- {/* Left Content */}
-
-
-
-
-
+
+
+ {/* Left Content */}
+
+
+
+
+
+
+ Serving Utah Businesses Since 2019
+
+
+
+ Free Vending Machine Placement for Utah Businesses
+
+
+
+ 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.
+
+
+
+
+
+
+ Free placement for qualifying business locations
- Serving Utah Businesses Since 2019
-
-
-
- Free Vending Machine Placement for Utah Businesses
-
-
-
- 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.
-
-
-
-
-
-
- Free placement for qualifying business locations
-
-
-
-
- We review the location, then handle stocking and service
-
-
-
- Snack, beverage, and combo machines with cashless payment
-
-
-
- Traditional favorites and healthier product options
-
-
-
-
- See If Your Location Qualifies
-
-
- See How It Works
-
+
+
+
+ We review the location, then handle stocking and service
+
+
+
+
+
+ Snack, beverage, and combo machines with cashless payment
+
+
+
+
+ Traditional favorites and healthier product options
-
-
- Serving Davis, Salt Lake, and Utah counties
-
-
-
-
-
+
+
+
+ See If Your Location Qualifies
+
+
+
+ See How It Works
+
+
-
-
-
-
-
- 3
-
-
- Counties Served
-
-
-
-
6+
-
- Years in Business
-
-
+
+ Serving Davis, Salt Lake, and Utah counties
+
+
+
+
+
+
+
+
+
+
+
+
3
+
+ Counties Served
-
-
-
+
+
+
6+
+
+ Years in Business
+
+
+
+
-
+
)
}
diff --git a/components/how-it-works-section.tsx b/components/how-it-works-section.tsx
index 65200915..60093b9c 100644
--- a/components/how-it-works-section.tsx
+++ b/components/how-it-works-section.tsx
@@ -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 (
-
-
-
-
How It Works
-
- 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.
-
-
+
+
+
- {/* Connection Line - Desktop */}
-
+
-
+
{steps.map((step, index) => (
-
-
-
-
-
- {step.timing}
-
- {step.title}
- {step.description}
-
-
-
+
+
+
+ {step.timing}
+
+ {step.title}
+
+ {step.description}
+
+
))}
-
+
- Our process is as easy as 3 steps. We mentioned FREE ,
- right?
+ Our process is as easy as 3 steps. We mentioned{" "}
+ FREE , right?
-
-
+
+
)
}
diff --git a/components/location-landing-page.tsx b/components/location-landing-page.tsx
index d8ed86c0..e185aff2 100644
--- a/components/location-landing-page.tsx
+++ b/components/location-landing-page.tsx
@@ -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}
- 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.
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.
@@ -263,19 +270,19 @@ export function LocationLandingPage({
cta: "View manuals and parts",
},
].map((service) => (
-
-
- {service.title}
- {service.body}
-
- {service.cta}
-
-
-
-
+
+ {service.title}
+
+ {service.body}
+
+
+ {service.cta}
+
+
+
))}
@@ -294,7 +301,8 @@ export function LocationLandingPage({
- Neighborhood coverage includes {locationData.neighborhoods.join(", ")}.
+ Neighborhood coverage includes{" "}
+ {locationData.neighborhoods.join(", ")}.
@@ -342,7 +350,10 @@ export function LocationLandingPage({
diff --git a/components/product-showcase-section.tsx b/components/product-showcase-section.tsx
index 5c2c1392..f7323eea 100644
--- a/components/product-showcase-section.tsx
+++ b/components/product-showcase-section.tsx
@@ -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() {
{product.title}
-
{product.description}
+
+ {product.description}
+
))}
diff --git a/components/public-surface.tsx b/components/public-surface.tsx
index 1038eb02..d7a1d80e 100644
--- a/components/public-surface.tsx
+++ b/components/public-surface.tsx
@@ -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 (
+
+ )
+}
+
export function PublicPageHeader({
eyebrow,
title,
@@ -23,17 +61,27 @@ export function PublicPageHeader({
const isCentered = align === "center"
return (
-
+
{eyebrow ? (
- {eyebrow}
+
+ {eyebrow}
+
) : null}
-
-
{title}
+
+
+ {title}
+
{description ? (
{description}
@@ -54,8 +102,8 @@ export function PublicSurface({
return (
@@ -72,8 +120,8 @@ export function PublicInset({
return (
@@ -96,10 +144,14 @@ export function PublicSectionHeader({
className,
}: PublicSectionHeaderProps) {
return (
-
-
{eyebrow}
-
{title}
-
{description}
+
+
+ {eyebrow}
+
+
+ {title}
+
+
{description}
)
}
diff --git a/components/repairs-image-carousel.tsx b/components/repairs-image-carousel.tsx
index 83d17166..e09edba8 100644
--- a/components/repairs-image-carousel.tsx
+++ b/components/repairs-image-carousel.tsx
@@ -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
()
@@ -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,
}}
>
-
+
-
+
)
}
-
diff --git a/components/reviews-page.tsx b/components/reviews-page.tsx
index 5b4ba626..b0a51f78 100644
--- a/components/reviews-page.tsx
+++ b/components/reviews-page.tsx
@@ -31,7 +31,7 @@ export function ReviewsPage() {
}, [])
return (
-
+
({
"@type": "City",
name: area.city,
@@ -22,24 +35,24 @@ export function ServiceAreasSection() {
}
return (
-
+
+
-
-
-
-
-
Service Areas in Utah
-
-
- Proudly serving businesses across Davis, Salt Lake, and Utah Counties
-
-
-
-
+
+
-
-
Cities We Serve
-
- {locations.map((location) => (
-
-
-
- {location.city}
-
-
- ))}
+
+
+
+
-
-
- Don't see your city? Give us a call at{" "}
-
- (435) 233-9668
- {" "}
- to check if we can serve your location!
+
+
+ City Coverage
+
+ Cities We Serve
+
+
+
+
+ {locations.map((location) => (
+
+
+
{location.city}
+
+ ))}
+
+
+
+ Don't see your city? Give us a call at{" "}
+
+ (435) 233-9668
+ {" "}
+ to check if we can serve your location!
+
+
View All Service Areas →
+
+ Ask about your location
+
-
+
-
-
+
+
)
}
diff --git a/components/site-chat-widget.tsx b/components/site-chat-widget.tsx
index 86b73b57..21ec29e9 100644
--- a/components/site-chat-widget.tsx
+++ b/components/site-chat-widget.tsx
@@ -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
| 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 (
Ready to get started? Use{" "}
-
+
{formLabel}
-
- {" "}and we'll help you plan the right setup.
+ {" "}
+ and we'll help you plan the right setup.
)
}
@@ -168,11 +215,17 @@ function SupportHint({
return (
For repairs or moving, text photos or videos to{" "}
-
+
{smsNumber}
{" "}
or use the{" "}
-
+
{formLabel}
.
@@ -183,10 +236,13 @@ function SupportHint({
return (
Need more help? Use{" "}
-
+
{formLabel}
-
- {" "}and our team will follow up.
+ {" "}
+ and our team will follow up.
)
}
@@ -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([])
const [draft, setDraft] = useState("")
- const [profileDraft, setProfileDraft] = useState(() => createEmptyProfileDraft(pathname || "/"))
+ const [profileDraft, setProfileDraft] = useState(() =>
+ createEmptyProfileDraft(pathname || "/")
+ )
const [profile, setProfile] = useState(null)
const [error, setError] = useState(null)
const [profileError, setProfileError] = useState(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) => (
+
+
+
+
+
+ {bootstrap.assistantName}
+
+
+ Text support for service, sales, and placement questions
+
+
+
+
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"
+ >
+
+
+
+ )
+
+ const renderProfileGate = (isMobileLayout: boolean) => (
+
+
+
+ {profileError ? (
+
+ ) : null}
+
+ )
+
+ const renderConversation = (isMobileLayout: boolean) => (
+ <>
+
+
+ Chatting as{" "}
+ {profile?.name}
+
+
+
+ Edit details
+
+
+
+
+
+ {messages.map((message) => (
+
+
+ {message.role === "assistant"
+ ? bootstrap.assistantName
+ : profile?.name}
+
+
+ {message.content}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {error ? (
+
+ ) : null}
+
+ >
+ )
+
+ const renderPanel = (isMobileLayout: boolean) => (
+
+ {renderHeader(isMobileLayout)}
+ {!profile
+ ? renderProfileGate(isMobileLayout)
+ : renderConversation(isMobileLayout)}
+
+ )
+
+ if (isMobileViewport) {
+ return (
+
+ {!isOpen ? (
+
+
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}`}
+ >
+
+
+
+
+ Chat with {bootstrap.assistantName}
+
+
+ Service, sales, repairs, and placement help
+
+
+
+
+
+
+ ) : null}
+
+
+
+ Chat with {bootstrap.assistantName}
+
+
+ Ask about service, repairs, sales, or free placement.
+
+ {renderPanel(true)}
+
+
+ )
+ }
+
return (
-
-
-
-
-
{bootstrap.assistantName}
-
Text support
-
-
-
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"
- >
-
-
-
-
- {!profile ? (
-
-
-
-
Start with your details
-
- We use this to route the conversation to the right team member.
-
-
-
- setProfileDraft((current) => ({ ...current, name: event.target.value }))}
- autoComplete="name"
- required
- />
- setProfileDraft((current) => ({ ...current, phone: event.target.value }))}
- autoComplete="tel"
- inputMode="tel"
- type="tel"
- required
- />
- setProfileDraft((current) => ({ ...current, email: event.target.value }))}
- autoComplete="email"
- inputMode="email"
- type="email"
- required
- />
- setProfileDraft((current) => ({ ...current, intent: event.target.value }))}
- options={intentOptions}
- placeholder="Choose one"
- required
- />
-
-
-
-
-
Text updates
-
- Required service consent covers scheduling, support, repairs, moving, and follow-up texts for this request.
-
-
-
- setProfileDraft((current) => ({
- ...current,
- serviceTextConsent: checked,
- consentVersion: SMS_CONSENT_VERSION,
- consentSourcePage: pathname || "/",
- }))
- }
- serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
- />
-
-
-
-
-
-
-
-
- {profileError ? (
-
- ) : null}
-
- ) : (
- <>
-
-
- Chatting as {profile.name}
-
-
-
- Edit details
-
-
-
-
-
- {messages.map((message) => (
-
-
- {message.role === "assistant" ? bootstrap.assistantName : profile.name}
-
-
- {message.content}
-
-
- ))}
-
-
-
-
-
-
-
-
- Message
-
-
- 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"
- />
-
-
-
-
-
- {isSending ? : }
- {isSending ? "Sending..." : "Send"}
-
-
-
-
- Call
-
-
-
-
- {error ? (
-
- ) : null}
-
- >
- )}
+ {renderPanel(false)}
) : 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}`}
>
-
+
) : null}
diff --git a/components/stats-section.tsx b/components/stats-section.tsx
index 495bf011..0151a1d3 100644
--- a/components/stats-section.tsx
+++ b/components/stats-section.tsx
@@ -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 (
-
-
+
+
- {stats.map((stat, index) => (
-
-
-
- {stat.value}
-
-
- {stat.label}
-
-
-
+ {stats.map((stat) => (
+
+
+ {stat.value}
+
+
+ {stat.label}
+
+
))}
-
-
+
+
)
}
diff --git a/components/vending-machines-page.tsx b/components/vending-machines-page.tsx
index 30c33c9d..d8279eb3 100644
--- a/components/vending-machines-page.tsx
+++ b/components/vending-machines-page.tsx
@@ -44,7 +44,7 @@ export function VendingMachinesPage() {
]
return (
-