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

View file

@ -1,80 +1,44 @@
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
import { existsSync } from 'fs' import { existsSync } from "fs"
import { join } from 'path' import { join } from "path"
import { Metadata } from 'next' import { Metadata } from "next"
import { businessConfig } from '@/lib/seo-config' import { businessConfig } from "@/lib/seo-config"
import { ManualsPageExperience } from '@/components/manuals-page-experience' import { ManualsPageExperience } from "@/components/manuals-page-experience"
import { listConvexManuals } from '@/lib/convex-service' import { listConvexManuals } from "@/lib/convex-service"
import { scanManuals } from '@/lib/manuals' import { scanManuals } from "@/lib/manuals"
import { selectManualsForSite } from '@/lib/manuals-site-selection' import { selectManualsForSite } from "@/lib/manuals-site-selection"
import { generateStructuredData } from '@/lib/seo' import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getManualsThumbnailsRoot } from '@/lib/manuals-paths' import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
export const metadata: Metadata = { export const metadata: Metadata = generateSEOMetadata({
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending', title: "Vending Machine Manuals | Rocky Mountain Vending",
description: 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: [ keywords: [
'vending machine manuals', "vending machine manuals",
'vending machine PDF', "vending machine PDF",
'vending machine service manual', "vending machine service manual",
'vending machine parts catalog', "vending machine repair manual",
'vending machine repair manual', "vending machine troubleshooting guide",
'vending machine installation guide', "Royal Vendors manual",
'vending machine troubleshooting guide', "Dixie-Narco manual",
'Royal Vendors manual', "Vendo manual",
'Dixie-Narco manual', "Crane vending manual",
'Vendo manual', "Seaga vending 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',
], ],
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() { export default async function ManualsPage() {
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated. // Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
const convexManuals = await listConvexManuals() const convexManuals = await listConvexManuals()
const allManuals = convexManuals.length > 0 ? convexManuals : await scanManuals() const allManuals =
let manuals = convexManuals.length > 0 ? convexManuals : selectManualsForSite(allManuals).manuals 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. // Hide broken local thumbnails so the public manuals page doesn't spam 404s.
const thumbnailsRoot = getManualsThumbnailsRoot() const thumbnailsRoot = getManualsThumbnailsRoot()
@ -83,43 +47,43 @@ export default async function ManualsPage() {
return manual return manual
} }
const relativeThumbnailPath = manual.thumbnailUrl.includes('/thumbnails/') const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/")
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, '') ? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "")
: manual.thumbnailUrl : manual.thumbnailUrl
return existsSync(join(thumbnailsRoot, relativeThumbnailPath)) return existsSync(join(thumbnailsRoot, relativeThumbnailPath))
? manual ? manual
: { ...manual, thumbnailUrl: undefined } : { ...manual, thumbnailUrl: undefined }
}) })
// Generate structured data for SEO // Generate structured data for SEO
const structuredData = generateStructuredData({ const structuredData = generateStructuredData({
title: 'Vending Machine Manuals', title: "Vending Machine Manuals",
description: 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`, url: `${businessConfig.website}/manuals`,
type: 'WebPage', type: "WebPage",
}) })
// Add CollectionPage schema for better SEO // Add CollectionPage schema for better SEO
const collectionSchema = { const collectionSchema = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'CollectionPage', "@type": "CollectionPage",
name: 'Vending Machine Manuals', name: "Vending Machine Manuals",
description: 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`, url: `${businessConfig.website}/manuals`,
mainEntity: { mainEntity: {
'@type': 'ItemList', "@type": "ItemList",
numberOfItems: manuals.length, numberOfItems: manuals.length,
itemListElement: manuals.slice(0, 50).map((manual, index) => ({ itemListElement: manuals.slice(0, 50).map((manual, index) => ({
'@type': 'ListItem', "@type": "ListItem",
position: index + 1, position: index + 1,
item: { item: {
'@type': 'DigitalDocument', "@type": "DigitalDocument",
name: manual.filename.replace(/\.pdf$/i, ''), name: manual.filename.replace(/\.pdf$/i, ""),
description: `${manual.manufacturer} ${manual.category} Manual`, 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" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
/> />
<div className="container mx-auto px-4 py-8 md:py-12"> <div className="public-page">
<ManualsPageExperience initialManuals={manuals} /> <ManualsPageExperience initialManuals={manuals} />
</div> </div>
</> </>

View file

@ -1,35 +1,37 @@
import { notFound } from 'next/navigation' import { notFound } from "next/navigation"
import Image from 'next/image' import Image from "next/image"
import { fetchProductById, fetchAllProducts } from '@/lib/stripe/products' import { fetchProductById, fetchAllProducts } from "@/lib/stripe/products"
import { AddToCartButton } from '@/components/add-to-cart-button' import { AddToCartButton } from "@/components/add-to-cart-button"
import { Card, CardContent } from '@/components/ui/card' import { PublicInset, PublicSurface } from "@/components/public-surface"
interface ProductPageProps { interface ProductPageProps {
params: Promise<{ id: string }> params: Promise<{ id: string }>
} }
// Required for static export // Required for static export
export const dynamic = 'force-static'; export const dynamic = "force-static"
export const dynamicParams = false; export const dynamicParams = false
// Generate static params for all products // Generate static params for all products
export async function generateStaticParams() { export async function generateStaticParams() {
try { try {
const products = await fetchAllProducts(); const products = await fetchAllProducts()
// Ensure we have products // Ensure we have products
if (!products || products.length === 0) { if (!products || products.length === 0) {
console.warn('No products found for static generation. Product pages will not be pre-rendered.'); console.warn(
return []; "No products found for static generation. Product pages will not be pre-rendered."
)
return []
} }
return products.map((product) => ({ return products.map((product) => ({
id: product.id, id: product.id,
})); }))
} catch (error) { } 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 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) { if (!product) {
return { 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() notFound()
} }
const imageUrl = product.images?.[0] || '/placeholder.svg' const imageUrl = product.images?.[0] || "/placeholder.svg"
return ( return (
<div className="container mx-auto px-4 py-8 md:py-16"> <div className="public-page">
<div className="grid gap-8 md:grid-cols-2 lg:gap-12"> <div className="grid gap-6 lg:grid-cols-[1fr_0.95fr] lg:gap-8">
{/* Product Image */} <PublicSurface className="overflow-hidden p-0">
<Card className="overflow-hidden border-border/50 hover:border-secondary/50 transition-all"> <div className="relative aspect-square overflow-hidden bg-muted/60">
<CardContent className="p-0"> <Image
<div className="aspect-square relative overflow-hidden bg-muted"> src={imageUrl}
<Image alt={product.name}
src={imageUrl} fill
alt={product.name} className="object-cover"
fill sizes="(max-width: 1024px) 100vw, 52vw"
className="object-cover" priority
sizes="(max-width: 768px) 100vw, 50vw" />
priority </div>
/> </PublicSurface>
</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 className="flex flex-col">
<div className="mb-6"> <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()} ${product.price.toFixed(2)} {product.currency.toUpperCase()}
</p> </p>
</div> </div>
{product.description && ( {product.description && (
<div className="mb-8"> <PublicInset className="mb-5">
<h2 className="text-xl font-semibold mb-3">Description</h2> <h2 className="text-lg font-semibold">Description</h2>
<div <div
className="text-muted-foreground leading-relaxed whitespace-pre-line" className="mt-3 whitespace-pre-line text-muted-foreground leading-7"
dangerouslySetInnerHTML={{ 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 && ( {product.metadata && Object.keys(product.metadata).length > 0 && (
<div className="mb-8"> <PublicInset className="mb-5">
<h2 className="text-xl font-semibold mb-3">Specifications</h2> <h2 className="text-lg font-semibold">Specifications</h2>
<dl className="space-y-2"> <dl className="mt-3 space-y-3">
{Object.entries(product.metadata).map(([key, value]) => ( {Object.entries(product.metadata).map(([key, value]) => (
<div key={key} className="flex"> <div
<dt className="font-medium mr-2">{key}:</dt> 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> <dd className="text-muted-foreground">{value}</dd>
</div> </div>
))} ))}
</dl> </dl>
</div> </PublicInset>
)} )}
<div className="mt-auto pt-6"> <div className="mt-auto pt-3">
<AddToCartButton product={product} /> <AddToCartButton product={product} />
</div> </div>
</div> </PublicSurface>
</div> </div>
</div> </div>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,25 @@
import Link from "next/link" import Link from "next/link"
import Image from "next/image" 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" import { Separator } from "./ui/separator"
export function Footer() { export function Footer() {
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
return ( return (
<footer className="border-t border-border bg-muted/50 shadow-inner py-8"> <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="container mx-auto px-4 lg:px-6 py-16 md:py-28"> <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-12 md:grid-cols-4"> <div className="grid gap-6 lg:grid-cols-[1.2fr_0.9fr_0.9fr_1fr]">
{/* Company Info */} {/* 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"> <Link href="/" className="inline-flex">
<Image <Image
src="/rmv-logo.png" src="/rmv-logo.png"
@ -23,26 +31,32 @@ export function Footer() {
</Link> </Link>
<p className="text-sm text-muted-foreground mb-6 max-w-md leading-relaxed"> <p className="text-sm text-muted-foreground mb-6 max-w-md leading-relaxed">
Serving Utah businesses with free placement for qualifying Serving Utah businesses with free placement for qualifying
locations, machine sales, repairs, restocking, and ongoing locations, machine sales, repairs, restocking, and ongoing service
service since 2019. since 2019.
</p> </p>
<div className="flex flex-col gap-3 text-sm text-muted-foreground mb-6"> <div className="flex flex-col gap-3 text-sm text-muted-foreground mb-6">
<a href="tel:+14352339668" className="transition-colors"> <a
📞 (435) 233-9668 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>
<a <a
href="mailto:info@rockymountainvending.com" 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>
<a <a
href="https://rockymountainvending.com/" href="https://rockymountainvending.com/"
className="transition-colors" className="inline-flex items-center gap-3 transition-colors hover:text-foreground"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
🌐 rockymountainvending.com <Globe className="h-4 w-4 text-primary" />
<span>rockymountainvending.com</span>
</a> </a>
</div> </div>
<div className="flex flex-wrap gap-6"> <div className="flex flex-wrap gap-6">
@ -90,7 +104,7 @@ export function Footer() {
</div> </div>
{/* Services */} {/* 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> <h3 className="font-semibold mb-5 text-base">Services</h3>
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="space-y-3 text-sm text-muted-foreground">
<li> <li>
@ -145,7 +159,7 @@ export function Footer() {
</div> </div>
{/* Company */} {/* 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> <h3 className="font-semibold mb-5 text-base">Company</h3>
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="space-y-3 text-sm text-muted-foreground">
<li> <li>
@ -184,7 +198,7 @@ export function Footer() {
</div> </div>
{/* Service Areas */} {/* 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> <h3 className="font-semibold mb-5 text-base">Service Areas</h3>
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="space-y-3 text-sm text-muted-foreground">
<li> <li>
@ -248,8 +262,8 @@ export function Footer() {
</div> </div>
</div> </div>
<div className="border-t border-border mt-12 pt-8"> <div className="border-t border-border/60 mt-10 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground"> <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> <p>
© {currentYear} Rocky Mountain Vending LLC. All rights reserved. © {currentYear} Rocky Mountain Vending LLC. All rights reserved.
</p> </p>

View file

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

View file

@ -1,117 +1,120 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { CheckCircle2 } from "lucide-react" import { CheckCircle2 } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import Image from "next/image" import Image from "next/image"
import { PublicSection, PublicSurface } from "@/components/public-surface"
export function HeroSection() { export function HeroSection() {
return ( return (
<section className="relative overflow-hidden bg-background py-20 md:py-32"> <PublicSection
<div className="container mx-auto px-4"> tone="warm"
<div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center"> className="relative overflow-hidden"
{/* Left Content */} containerClassName="relative"
<div className="flex flex-col gap-6"> >
<Badge <div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center">
variant="outline" {/* Left Content */}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs font-medium w-fit" <div className="flex flex-col gap-6">
> <Badge
<span className="relative flex h-2 w-2"> variant="outline"
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span> className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs font-medium w-fit"
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span> >
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
Serving Utah Businesses Since 2019
</Badge>
<h1 className="text-4xl font-bold tracking-tight text-balance md:text-5xl lg:text-6xl">
Free Vending Machine Placement for Utah Businesses
</h1>
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
If your breakroom, waiting area, or staff space needs a better
snack-and-drink option, we can review the location, place the right
machine, keep it stocked, and handle service after installation.
</p>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span className="font-semibold">
Free placement for qualifying business locations
</span> </span>
Serving Utah Businesses Since 2019
</Badge>
<h1 className="text-4xl font-bold tracking-tight text-balance md:text-5xl lg:text-6xl">
Free Vending Machine Placement for Utah Businesses
</h1>
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
If your breakroom, waiting area, or staff space needs a better
snack-and-drink option, we can review the location, place the
right machine, keep it stocked, and handle service after
installation.
</p>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span className="font-semibold">
Free placement for qualifying business locations
</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>We review the location, then handle stocking and service</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>Snack, beverage, and combo machines with cashless payment</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>Traditional favorites and healthier product options</span>
</div>
</div> </div>
<div className="flex items-center gap-2 text-foreground">
<div className="flex flex-col sm:flex-row gap-4 pt-4"> <CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<Button <span>
asChild We review the location, then handle stocking and service
size="lg" </span>
className="bg-primary hover:bg-primary/90 text-lg h-14 px-8" </div>
> <div className="flex items-center gap-2 text-foreground">
<Link href="#request-machine">See If Your Location Qualifies</Link> <CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
</Button> <span>
<Button Snack, beverage, and combo machines with cashless payment
asChild </span>
size="lg" </div>
variant="outline" <div className="flex items-center gap-2 text-foreground">
className="text-lg h-14 bg-transparent" <CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
> <span>Traditional favorites and healthier product options</span>
<Link href="#how-it-works">See How It Works</Link>
</Button>
</div> </div>
<p className="text-sm text-muted-foreground pt-2">
Serving Davis, Salt Lake, and Utah counties
</p>
</div> </div>
<div className="relative"> <div className="flex flex-col sm:flex-row gap-4 pt-4">
<div className="aspect-square relative rounded-2xl overflow-hidden bg-muted"> <Button
<Image asChild
src="/images/vending-bay-2-scaled.webp" size="lg"
alt="Modern vending machines installed at Utah business" className="bg-primary hover:bg-primary/90 text-lg h-14 px-8"
fill >
className="object-cover" <Link href="#request-machine">
priority See If Your Location Qualifies
/> </Link>
</div> </Button>
<Button
asChild
size="lg"
variant="outline"
className="text-lg h-14 bg-transparent"
>
<Link href="#how-it-works">See How It Works</Link>
</Button>
</div>
<Card className="absolute bottom-6 left-6 right-6 md:left-auto md:right-6 md:w-64 bg-card border border-border rounded-lg p-6 shadow-lg backdrop-blur-sm"> <p className="text-sm text-muted-foreground pt-2">
<CardContent className="p-0"> Serving Davis, Salt Lake, and Utah counties
<div className="grid grid-cols-2 gap-4"> </p>
<div> </div>
<div className="text-3xl font-bold text-primary">
3 <div className="relative">
</div> <div className="relative aspect-[0.94] overflow-hidden rounded-[2rem] border border-border/70 bg-muted shadow-[var(--public-surface-shadow)]">
<div className="text-xs text-muted-foreground"> <Image
Counties Served src="/images/vending-bay-2-scaled.webp"
</div> alt="Modern vending machines installed at Utah business"
</div> fill
<div> className="object-cover"
<div className="text-3xl font-bold text-primary">6+</div> priority
<div className="text-xs text-muted-foreground"> />
Years in Business </div>
</div>
</div> <PublicSurface className="absolute bottom-4 left-4 right-4 p-5 backdrop-blur-sm md:bottom-6 md:left-auto md:right-6 md:w-64">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-3xl font-bold text-primary">3</div>
<div className="text-xs text-muted-foreground">
Counties Served
</div> </div>
</CardContent> </div>
</Card> <div>
</div> <div className="text-3xl font-bold text-primary">6+</div>
<div className="text-xs text-muted-foreground">
Years in Business
</div>
</div>
</div>
</PublicSurface>
</div> </div>
</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 = [ const steps = [
{ {
@ -26,49 +31,47 @@ const steps = [
export function HowItWorksSection() { export function HowItWorksSection() {
return ( return (
<section id="how-it-works" className="py-20 md:py-28"> <PublicSection id="how-it-works">
<div className="container mx-auto px-4"> <PublicPageHeader
<div className="text-center mb-12 md:mb-16"> align="center"
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance">How It Works</h2> className="mb-12 md:mb-16"
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed"> title="How It Works"
Getting the best vending machine supplier in Utah shouldn't be a difficult process. Your free machine could description={
be delivered in as little as 7 days depending on our schedule. "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> />
<PublicSurface className="relative p-6 md:p-8">
<div className="relative"> <div className="relative">
{/* Connection Line - Desktop */} <div className="absolute left-[16.666%] right-[16.666%] top-16 hidden h-px bg-border/80 lg:block" />
<div className="hidden lg:block absolute top-24 left-[16.666%] right-[16.666%] h-0.5 bg-border" />
<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) => ( {steps.map((step, index) => (
<div key={index} className="relative"> <PublicInset key={index} className="relative h-full p-5 md:p-6">
<Card className="border-border/50 hover:border-secondary/50 transition-colors h-full"> <div className="mb-5">
<CardContent className="pt-8"> <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">
<div className="mb-6"> {step.number}
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold"> </div>
{step.number} </div>
</div> <div className="mb-2 text-xs font-medium uppercase tracking-[0.18em] text-primary">
</div> {step.timing}
<div className="mb-2 text-xs font-medium text-primary uppercase tracking-wider"> </div>
{step.timing} <h3 className="text-xl font-semibold mb-3">{step.title}</h3>
</div> <p className="text-sm leading-relaxed text-muted-foreground">
<h3 className="text-xl font-semibold mb-3">{step.title}</h3> {step.description}
<p className="text-muted-foreground text-sm leading-relaxed">{step.description}</p> </p>
</CardContent> </PublicInset>
</Card>
</div>
))} ))}
</div> </div>
</div> </div>
<div className="text-center mt-12"> <div className="mt-8 text-center">
<p className="text-sm text-muted-foreground"> <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>, Our process is as easy as 3 steps. We mentioned{" "}
right? <span className="font-semibold text-foreground">FREE</span>, right?
</p> </p>
</div> </div>
</div> </PublicSurface>
</section> </PublicSection>
) )
} }

View file

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

View file

@ -5,13 +5,15 @@ export function ProductShowcaseSection() {
const products = [ const products = [
{ {
title: "Traditional Snacks & Drinks", 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", image: "/images/traditional-snacks-utah.webp",
alt: "Traditional snacks including M&Ms, Oreos, Reeses in vending machine", alt: "Traditional snacks including M&Ms, Oreos, Reeses in vending machine",
}, },
{ {
title: "Healthy Options", 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", image: "/images/healthy-vending-utah.webp",
alt: "Healthy vending options including protein bars and nutritious snacks", alt: "Healthy vending options including protein bars and nutritious snacks",
}, },
@ -24,7 +26,8 @@ export function ProductShowcaseSection() {
}, },
{ {
title: "Specialty Products", 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", image: "/images/drink-and-snack-delivery-utah-scaled.webp",
alt: "Specialty Bucked Up Energy drinks available for delivery", alt: "Specialty Bucked Up Energy drinks available for delivery",
}, },
@ -37,7 +40,7 @@ export function ProductShowcaseSection() {
align="center" align="center"
eyebrow="Product Mix" eyebrow="Product Mix"
title="What we keep stocked and ready to tailor for your location." 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" className="mb-12"
/> />
@ -57,7 +60,9 @@ export function ProductShowcaseSection() {
</div> </div>
<div className="p-5 md:p-6"> <div className="p-5 md:p-6">
<h3 className="text-xl font-semibold mb-2">{product.title}</h3> <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> </div>
</PublicSurface> </PublicSurface>
))} ))}

View file

@ -3,6 +3,14 @@
import type { ElementType, HTMLAttributes, ReactNode } from "react" import type { ElementType, HTMLAttributes, ReactNode } from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type PublicSectionProps = {
id?: string
tone?: "default" | "muted" | "warm"
className?: string
containerClassName?: string
children: ReactNode
}
type PublicPageHeaderProps = { type PublicPageHeaderProps = {
eyebrow?: string eyebrow?: string
title: string title: string
@ -12,6 +20,36 @@ type PublicPageHeaderProps = {
children?: ReactNode 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({ export function PublicPageHeader({
eyebrow, eyebrow,
title, title,
@ -23,17 +61,27 @@ export function PublicPageHeader({
const isCentered = align === "center" const isCentered = align === "center"
return ( 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 ? ( {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} ) : null}
<div className="space-y-3"> <div className="space-y-3 md:space-y-4">
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground md:text-5xl">{title}</h1> <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 ? ( {description ? (
<p <p
className={cn( className={cn(
"text-base leading-relaxed text-muted-foreground md:text-lg", "text-base leading-7 text-muted-foreground md:text-lg md:leading-8",
isCentered ? "mx-auto max-w-3xl" : "max-w-3xl", isCentered ? "mx-auto max-w-3xl" : "max-w-3xl"
)} )}
> >
{description} {description}
@ -54,8 +102,8 @@ export function PublicSurface({
return ( return (
<Component <Component
className={cn( 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", "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, className
)} )}
{...props} {...props}
> >
@ -72,8 +120,8 @@ export function PublicInset({
return ( return (
<div <div
className={cn( className={cn(
"rounded-[1.5rem] border border-border/60 bg-white p-4 shadow-sm", "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, className
)} )}
{...props} {...props}
> >
@ -96,10 +144,14 @@ export function PublicSectionHeader({
className, className,
}: PublicSectionHeaderProps) { }: PublicSectionHeaderProps) {
return ( return (
<div className={cn("space-y-1", className)}> <div className={cn("space-y-2", className)}>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">{eyebrow}</p> <p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
<h2 className="text-lg font-semibold text-foreground">{title}</h2> {eyebrow}
<p className="text-sm leading-relaxed text-muted-foreground">{description}</p> </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> </div>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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