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);
@ -50,9 +50,16 @@
--shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
/* Custom brand colors */
--yellow: #FCBA09;
--orange: #F79611;
--mountain-bubbles: #FCBA0924; /* Yellow with transparency */
--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;
@ -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,12 +157,19 @@
}
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 */
@ -160,13 +177,19 @@
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 {
@ -176,13 +199,26 @@
/* 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 */
@ -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,
@ -303,7 +359,12 @@
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);

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,8 +47,8 @@ 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))
@ -94,32 +58,32 @@ export default async function ManualsPage() {
// 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">
<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: 768px) 100vw, 50vw"
sizes="(max-width: 1024px) 100vw, 52vw"
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>
</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,17 +40,16 @@ export default function MovingServicesPage() {
{/* Introduction Section */}
<section className="mb-12">
<Card className="border-border/50 shadow-md">
<CardContent className="p-6 md:p-8">
<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
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.
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
@ -62,8 +61,7 @@ export default function MovingServicesPage() {
process every time.
</p>
</div>
</CardContent>
</Card>
</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,8 +281,7 @@ 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">
<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" />
@ -315,11 +303,8 @@ export default function MovingServicesPage() {
<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.
<strong className="text-foreground">Minimal disruption:</strong>{" "}
Fast, coordinated service scheduled around your business hours.
</span>
</li>
<li className="flex items-start gap-3">
@ -341,8 +326,7 @@ export default function MovingServicesPage() {
</span>
</li>
</ul>
</CardContent>
</Card>
</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/`,
const structuredData = generateRegistryStructuredData("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/`,
}
}
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>

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">
<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) => (
<Card key={index}>
<CardContent className="p-6">
<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>
<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 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">
{saltLakeCounty.map((location) => (
<Card
{county.locations.map((location) => (
<PublicSurface
key={location.slug}
className="h-full hover:border-secondary/50 transition-colors"
className="flex h-full flex-col p-5"
>
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<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:
<div className="mb-5">
<p className="text-sm font-medium text-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 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}`}>
<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
</h2>
<p className="text-muted-foreground">
Supporting businesses from Ogden to Layton with reliable vending
services
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{davisCounty.map((location) => (
<Card
key={location.slug}
className="h-full hover:border-secondary/50 transition-colors"
<Link
href={`/vending-machines-${location.slug}`}
className="mt-auto"
>
<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"
<Button
variant="outline"
className="h-11 w-full rounded-full group"
>
<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" />
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
</CardContent>
</Card>
</PublicSurface>
))}
</div>
</section>
))}
</div>
{/* 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?
<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-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 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 className="flex flex-col sm:flex-row gap-4 justify-center">
<div className="mt-6 flex flex-col justify-center gap-3 sm:flex-row">
<Link href="/contact-us">
<Button size="lg">Contact Us</Button>
<Button size="lg" className="min-h-11 rounded-full">
Contact Us
</Button>
</Link>
<Link href="/service-areas">
<Button size="lg" variant="outline">
<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>
</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.
<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 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>
Our focus is on healthy vending and dependable service, while
still helping locations that want traditional snack and drink
options too.
</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>
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>
<p className="text-foreground font-semibold text-lg mt-6">
</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>
</div>
</CardContent>
</Card>
</PublicInset>
</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,55 +35,81 @@ 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>
<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.
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">
<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>
<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">
<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>
<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>
</PublicSurface>
<Card className="rounded-[2rem] border-border/70 shadow-[0_18px_45px_rgba(0,0,0,0.06)]">
<CardContent className="p-6">
<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>
<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-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>
@ -89,16 +118,27 @@ export function ContactPage() {
<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]"
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>
<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>
</CardContent>
</Card>
</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,10 +370,12 @@ export function Header() {
{/* Mobile Navigation */}
{state.isMenuOpen && (
<nav className="flex flex-col gap-5 py-6 md:hidden border-t border-border/40">
<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="text-sm font-medium py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Home
@ -311,7 +385,7 @@ export function Header() {
<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"
className={mobileGroupButtonClassName}
aria-label="Who We Serve menu"
aria-expanded={state.isWhoWeServeOpen}
aria-haspopup="true"
@ -323,12 +397,12 @@ export function Header() {
/>
</button>
{state.isWhoWeServeOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
<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="text-sm py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
@ -341,8 +415,10 @@ export function Header() {
{/* 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"
onClick={() =>
dispatch({ type: "TOGGLE_VENDING_MACHINES" })
}
className={mobileGroupButtonClassName}
aria-label="Vending Machines menu"
aria-expanded={state.isVendingMachinesOpen}
aria-haspopup="true"
@ -354,12 +430,12 @@ export function Header() {
/>
</button>
{state.isVendingMachinesOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
<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="text-sm py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
@ -373,7 +449,7 @@ export function Header() {
<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"
className={mobileGroupButtonClassName}
aria-label="Food & Beverage menu"
aria-expanded={state.isFoodBeverageOpen}
aria-haspopup="true"
@ -385,12 +461,12 @@ export function Header() {
/>
</button>
{state.isFoodBeverageOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
<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="text-sm py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
@ -404,7 +480,7 @@ export function Header() {
<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"
className={mobileGroupButtonClassName}
aria-label="Services menu"
aria-expanded={state.isServicesOpen}
aria-haspopup="true"
@ -416,12 +492,12 @@ export function Header() {
/>
</button>
{state.isServicesOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
<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="text-sm py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
@ -435,7 +511,7 @@ export function Header() {
<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"
className={mobileGroupButtonClassName}
aria-label="Blog Posts menu"
aria-expanded={state.isBlogPostsOpen}
aria-haspopup="true"
@ -447,12 +523,12 @@ export function Header() {
/>
</button>
{state.isBlogPostsOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
<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="text-sm py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
@ -466,7 +542,7 @@ export function Header() {
<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"
className={mobileGroupButtonClassName}
aria-label="About menu"
aria-expanded={state.isAboutOpen}
aria-haspopup="true"
@ -478,12 +554,12 @@ export function Header() {
/>
</button>
{state.isAboutOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
<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="text-sm py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
{item.label}
@ -495,27 +571,27 @@ export function Header() {
<Link
href="/products"
className="text-sm font-medium py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Products
</Link>
<Link
href="/service-areas"
className="text-sm font-medium py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Service Areas
</Link>
<Link
href="/contact-us"
className="text-sm font-medium py-1 transition-colors"
className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
>
Contact Us
</Link>
<div className="flex flex-col gap-4 pt-4 border-t border-border/40">
<div className="mt-3 flex flex-col gap-3 border-t border-border/40 pt-4">
<MobileCartButton
onClick={() => {
dispatch({ type: "TOGGLE_MENU" })
@ -524,7 +600,7 @@ export function Header() {
/>
<a
href="tel:+14352339668"
className="flex items-center gap-2 text-sm font-medium transition-colors"
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>
@ -534,11 +610,13 @@ export function Header() {
dispatch({ type: "TOGGLE_MENU" })
dispatch({ type: "SET_MODAL", value: true })
}}
className="bg-primary hover:bg-primary/90 w-full"
className="h-11 w-full rounded-full bg-primary hover:bg-primary/90"
>
Get Free Machine
</Button>
</div>
</div>
</div>
</nav>
)}
</div>

View file

@ -1,14 +1,17 @@
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">
<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">
@ -29,9 +32,8 @@ export function HeroSection() {
<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.
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">
@ -43,11 +45,15 @@ export function HeroSection() {
</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>
<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>
<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" />
@ -61,7 +67,9 @@ export function HeroSection() {
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>
<Link href="#request-machine">
See If Your Location Qualifies
</Link>
</Button>
<Button
asChild
@ -79,7 +87,7 @@ export function HeroSection() {
</div>
<div className="relative">
<div className="aspect-square relative rounded-2xl overflow-hidden bg-muted">
<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"
@ -89,13 +97,10 @@ export function HeroSection() {
/>
</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">
<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-3xl font-bold text-primary">3</div>
<div className="text-xs text-muted-foreground">
Counties Served
</div>
@ -107,11 +112,9 @@ export function HeroSection() {
</div>
</div>
</div>
</CardContent>
</Card>
</PublicSurface>
</div>
</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">
<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 text-primary uppercase tracking-wider">
<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-muted-foreground text-sm leading-relaxed">{step.description}</p>
</CardContent>
</Card>
</div>
<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,10 +270,11 @@ export function LocationLandingPage({
cta: "View manuals and parts",
},
].map((service) => (
<Card key={service.title} className="h-full">
<CardContent className="p-6">
<PublicSurface key={service.title} className="h-full p-6">
<h3 className="text-xl font-semibold">{service.title}</h3>
<p className="mt-3 text-muted-foreground">{service.body}</p>
<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"
@ -274,8 +282,7 @@ export function LocationLandingPage({
{service.cta}
<ArrowRight className="h-4 w-4" />
</Link>
</CardContent>
</Card>
</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">
<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>
<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="flex items-center gap-2 group"
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-secondary flex-shrink-0" />
<span className="text-foreground group-hover:text-secondary transition-colors">
{location.city}
</span>
<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>
<div className="mt-6 space-y-3">
<p className="text-muted-foreground">
<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 hover:underline">
<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>
</PublicInset>
</div>
</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) {
@ -277,7 +340,7 @@ export function SiteChatWidget() {
profileDraft.phone.trim() &&
profileDraft.email.trim() &&
profileDraft.intent &&
profileDraft.serviceTextConsent,
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,42 +440,55 @@ 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) => [
setMessages((current) =>
[
...current,
createMessage("assistant", "I'm temporarily unavailable right now. Please call us or use the contact form."),
].slice(-12))
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
}
return (
const renderHeader = (isMobileLayout: boolean) => (
<div
className="pointer-events-none fixed right-4 z-40 flex flex-col items-end gap-3"
style={{ bottom: "calc(env(safe-area-inset-bottom, 0px) + 1rem)" }}
className={cn(
"flex items-start justify-between border-b border-border/70 px-4 py-4",
isMobileLayout && "px-5 pt-3"
)}
>
{isOpen ? (
<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 }}
>
<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" />
<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>
<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
@ -418,12 +500,20 @@ export function SiteChatWidget() {
<X className="h-4 w-4" />
</button>
</div>
)
{!profile ? (
<div className="min-h-0 overflow-y-auto px-4 py-4">
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="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>
@ -433,7 +523,12 @@ export function SiteChatWidget() {
id="site-chat-name"
label="Name"
value={profileDraft.name}
onChange={(event) => setProfileDraft((current) => ({ ...current, name: event.target.value }))}
onChange={(event) =>
setProfileDraft((current) => ({
...current,
name: event.target.value,
}))
}
autoComplete="name"
required
/>
@ -441,7 +536,12 @@ export function SiteChatWidget() {
id="site-chat-phone"
label="Phone"
value={profileDraft.phone}
onChange={(event) => setProfileDraft((current) => ({ ...current, phone: event.target.value }))}
onChange={(event) =>
setProfileDraft((current) => ({
...current,
phone: event.target.value,
}))
}
autoComplete="tel"
inputMode="tel"
type="tel"
@ -451,7 +551,12 @@ export function SiteChatWidget() {
id="site-chat-email"
label="Email"
value={profileDraft.email}
onChange={(event) => setProfileDraft((current) => ({ ...current, email: event.target.value }))}
onChange={(event) =>
setProfileDraft((current) => ({
...current,
email: event.target.value,
}))
}
autoComplete="email"
inputMode="email"
type="email"
@ -461,7 +566,12 @@ export function SiteChatWidget() {
id="site-chat-intent"
label="Intent"
value={profileDraft.intent}
onChange={(event) => setProfileDraft((current) => ({ ...current, intent: event.target.value }))}
onChange={(event) =>
setProfileDraft((current) => ({
...current,
intent: event.target.value,
}))
}
options={intentOptions}
placeholder="Choose one"
required
@ -472,7 +582,8 @@ export function SiteChatWidget() {
<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.
Required service consent covers scheduling, support, repairs,
moving, and follow-up texts for this request.
</p>
<div className="mt-4">
<SmsConsentFields
@ -487,7 +598,11 @@ export function SiteChatWidget() {
consentSourcePage: pathname || "/",
}))
}
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
serviceError={
profileError && !profileDraft.serviceTextConsent
? profileError
: undefined
}
/>
</div>
</div>
@ -529,11 +644,14 @@ export function SiteChatWidget() {
</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>
Chatting as{" "}
<span className="font-medium text-foreground">{profile?.name}</span>
</span>
<button
type="button"
@ -545,19 +663,26 @@ export function SiteChatWidget() {
</button>
</div>
<div className="max-h-[22rem] overflow-y-auto px-4 py-4">
<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}
{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",
: "ml-auto bg-primary text-primary-foreground"
)}
>
{message.content}
@ -571,7 +696,10 @@ export function SiteChatWidget() {
<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">
<label
htmlFor="site-chat-input"
className="text-sm font-semibold text-foreground"
>
Message
</label>
@ -579,9 +707,11 @@ export function SiteChatWidget() {
id="site-chat-input"
data-testid="site-chat-input"
value={draft}
onChange={(event) => setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))}
onChange={(event) =>
setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))
}
placeholder="Describe what you need"
rows={3}
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"
/>
@ -589,7 +719,7 @@ export function SiteChatWidget() {
<SupportHint
formHref={formHref}
formLabel={formLabel}
intent={profile.intent}
intent={profile?.intent || ""}
smsNumber={bootstrap.smsNumber}
smsUrl={bootstrap.smsUrl}
/>
@ -601,7 +731,11 @@ export function SiteChatWidget() {
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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquare className="h-4 w-4" />
)}
{isSending ? "Sending..." : "Send"}
</button>
@ -625,7 +759,82 @@ export function SiteChatWidget() {
) : 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"
style={{ bottom: "calc(env(safe-area-inset-bottom, 0px) + 1rem)" }}
>
{isOpen ? (
<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: DESKTOP_PANEL_MAX_HEIGHT }}
>
{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">
{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>
</CardContent>
</Card>
</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" }]}