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);
@ -47,17 +47,24 @@
--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);
@ -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,12 +157,19 @@
} }
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 */
@ -160,13 +177,19 @@
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 {
@ -176,13 +199,26 @@
/* 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 */
@ -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,
@ -303,7 +359,12 @@
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);

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,8 +47,8 @@ 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))
@ -94,32 +58,32 @@ export default async function ManualsPage() {
// 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">
<div className="aspect-square relative overflow-hidden bg-muted">
<Image <Image
src={imageUrl} src={imageUrl}
alt={product.name} alt={product.name}
fill fill
className="object-cover" className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw" sizes="(max-width: 1024px) 100vw, 52vw"
priority priority
/> />
</div> </div>
</CardContent> </PublicSurface>
</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,17 +40,16 @@ 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 from compact snack machines to full-size refrigerated beverage and
and combo units. Whether you're rearranging equipment within a 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 our experienced team handles every detail to minimize downtime and
and protect your investment. 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
@ -62,8 +61,7 @@ export default function MovingServicesPage() {
process every time. process every time.
</p> </p>
</div> </div>
</CardContent> </PublicSurface>
</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,8 +281,7 @@ 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" />
@ -315,11 +303,8 @@ export default function MovingServicesPage() {
<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"> <strong className="text-foreground">Minimal disruption:</strong>{" "}
Minimal disruption: Fast, coordinated service scheduled around your business hours.
</strong>{" "}
Fast, coordinated service scheduled around your business
hours.
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
@ -341,8 +326,7 @@ export default function MovingServicesPage() {
</span> </span>
</li> </li>
</ul> </ul>
</CardContent> </PublicSurface>
</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 {
structuredData = generateStructuredData({
title: page.title || "Vending Machine Repairs",
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/services/repairs/`,
datePublished: page.date, datePublished: page.date,
dateModified: page.modified || 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>

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."
className="mb-6"
/>
<div className="grid gap-4 md:grid-cols-2">
{services.map((service, index) => ( {services.map((service, index) => (
<Card key={index}> <PublicInset key={index} className="h-full">
<CardContent className="p-6">
<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"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{saltLakeCounty.map((location) => ( {county.locations.map((location) => (
<Card <PublicSurface
key={location.slug} key={location.slug}
className="h-full hover:border-secondary/50 transition-colors" className="flex h-full flex-col p-5"
> >
<CardContent className="p-6"> <div className="mb-4">
<div className="flex items-start justify-between 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"> <div className="mb-5">
<p className="text-sm font-medium text-muted-foreground mb-2"> <p className="text-sm font-medium text-foreground mb-2">
Services Available: Services Available
</p> </p>
<ul className="text-sm text-muted-foreground space-y-1"> <ul className="space-y-2 text-sm text-muted-foreground">
<li> Vending Machine Sales</li> <li>Vending machine sales</li>
<li> Repair Services</li> <li>Repair services</li>
<li> Healthy Options</li> <li>Healthy snack and beverage options</li>
<li> Maintenance</li> <li>Maintenance and restocking</li>
</ul> </ul>
</div> </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 */} <Link
<section className="mb-16"> href={`/vending-machines-${location.slug}`}
<div className="mb-8"> className="mt-auto"
<h2 className="text-3xl font-bold mb-2 tracking-tight text-balance">
Davis County
</h2>
<p className="text-muted-foreground">
Supporting businesses from Ogden to Layton with reliable vending
services
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{davisCounty.map((location) => (
<Card
key={location.slug}
className="h-full hover:border-secondary/50 transition-colors"
> >
<CardContent className="p-6"> <Button
<div className="flex items-start justify-between mb-4"> variant="outline"
<div> className="h-11 w-full rounded-full group"
<h3 className="text-xl font-semibold mb-1">
{location.city}
</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-4 w-4" />
{location.zipCode}
</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
Services Available:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Vending Machine Sales</li>
<li> Repair Services</li>
<li> Healthy Options</li>
<li> Maintenance</li>
</ul>
</div>
<Link href={`/vending-machines-${location.slug}`}>
<Button variant="outline" className="w-full group">
View Services
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</section>
{/* Utah County */}
<section className="mb-16">
<div className="mb-8">
<h2 className="text-3xl font-bold mb-2 tracking-tight text-balance">
Utah County
</h2>
<p className="text-muted-foreground">
Serving Provo and surrounding areas with quality vending services
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{utahCounty.map((location) => (
<Card
key={location.slug}
className="h-full hover:border-secondary/50 transition-colors"
> >
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-semibold mb-1">
{location.city}
</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-4 w-4" />
{location.zipCode}
</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
Services Available:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Vending Machine Sales</li>
<li> Repair Services</li>
<li> Healthy Options</li>
<li> Maintenance</li>
</ul>
</div>
<Link href={`/vending-machines-${location.slug}`}>
<Button variant="outline" className="w-full group">
View Services View Services
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button> </Button>
</Link> </Link>
</CardContent> </PublicSurface>
</Card>
))} ))}
</div> </div>
</section> </section>
))}
</div>
{/* Call to Action */} <section className="mx-auto mt-12 max-w-4xl">
<section className="max-w-4xl mx-auto text-center py-12 bg-muted/30 rounded-lg"> <PublicSurface className="text-center">
<h2 className="text-3xl font-bold mb-4 tracking-tight text-balance"> <h2 className="text-3xl font-bold tracking-tight text-balance">
Ready to Get Started? Ready to get started?
</h2> </h2>
<p className="text-lg text-muted-foreground mb-6 text-pretty leading-relaxed"> <p className="mx-auto mt-4 max-w-2xl text-lg leading-8 text-muted-foreground">
All services are available across all 20 service areas. Contact us All services are available across our coverage area. Reach out and
today to learn more about vending machine solutions for your business. we&apos;ll help you figure out the right next step for your
location.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="mt-6 flex flex-col justify-center gap-3 sm:flex-row">
<Link href="/contact-us"> <Link href="/contact-us">
<Button size="lg">Contact Us</Button> <Button size="lg" className="min-h-11 rounded-full">
Contact Us
</Button>
</Link> </Link>
<Link href="/service-areas"> <Link href="/service-areas">
<Button size="lg" variant="outline"> <Button
size="lg"
variant="outline"
className="min-h-11 rounded-full"
>
View All Service Areas View All Service Areas
</Button> </Button>
</Link> </Link>
</div> </div>
<PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center">
<p className="text-sm leading-6 text-muted-foreground">
This route stays available for legacy links, but the primary
service-area experience lives on the main{" "}
<Link
href="/service-areas"
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
>
Utah service areas page
</Link>
.
</p>
</PublicInset>
</PublicSurface>
</section> </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
still helping locations that want traditional snack and drink
options too.
</p> </p>
<p className="text-muted-foreground leading-relaxed mb-4"> <p>
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. Rocky Mountain Vending is a local family-run business founded in
2019. We believe business should be built on trust, exceptional
customer service, modern technology, and enjoying the work we
do.
</p> </p>
<p className="text-foreground font-semibold text-lg mt-6"> </div>
<PublicInset className="mt-6">
<p className="text-sm leading-6 text-muted-foreground">
If you&apos;re looking for a more personal vending partner with
modern tools and real follow-through, that&apos;s exactly what
we set out to build.
</p>
<p className="mt-4 text-lg font-semibold text-foreground">
~Matt ~Matt
</p> </p>
</div> </PublicInset>
</CardContent>
</Card>
</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,55 +35,81 @@ export function ContactPage() {
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary"> <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>
<h2 className="mt-2 text-2xl font-semibold text-foreground">
Reach the team directly
</h2>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground"> <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
We monitor calls, texts, and email throughout the business day. If you're sending repair photos or videos, text them to the number below. We monitor calls, texts, and email throughout the business day. If
you're sending repair photos or videos, text them to the number
below.
</p> </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
href={businessConfig.publicCallUrl}
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
>
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Phone className="h-5 w-5" /> <Phone className="h-5 w-5" />
</div> </div>
<div> <div>
<p className="text-sm font-semibold text-foreground">Call</p> <p className="text-sm font-semibold text-foreground">Call</p>
<p className="mt-1 text-base font-medium text-foreground">{businessConfig.publicCallNumber}</p> <p className="mt-1 text-base font-medium text-foreground">
<p className="mt-1 text-sm text-muted-foreground">Best for immediate questions during business hours.</p> {businessConfig.publicCallNumber}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Best for immediate questions during business hours.
</p>
</div> </div>
</a> </a>
<a href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`} className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"> <a
href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`}
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
>
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
</div> </div>
<div> <div>
<p className="text-sm font-semibold text-foreground">Email</p> <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 break-all text-base font-medium text-foreground">
<p className="mt-1 text-sm text-muted-foreground">Good for longer requests and supporting details.</p> {businessConfig.email}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Good for longer requests and supporting details.
</p>
</div> </div>
</a> </a>
</div> </div>
</CardContent> </PublicSurface>
</Card>
<Card className="rounded-[2rem] border-border/70 shadow-[0_18px_45px_rgba(0,0,0,0.06)]"> <PublicSurface className="p-6">
<CardContent className="p-6">
<div className="flex items-center gap-3"> <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"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Clock className="h-5 w-5" /> <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-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
<h2 className="text-xl font-semibold text-foreground">When we're available</h2> Business Hours
</p>
<h2 className="text-xl font-semibold text-foreground">
When we're available
</h2>
</div> </div>
</div> </div>
@ -89,16 +118,27 @@ export function ContactPage() {
<PublicInset <PublicInset
key={schedule.day} key={schedule.day}
className={`flex items-center justify-between rounded-xl px-3 py-2 shadow-none ${ className={`flex items-center justify-between rounded-xl px-3 py-2 shadow-none ${
schedule.isClosed ? "bg-muted/55 text-muted-foreground" : "bg-primary/[0.04]" schedule.isClosed
? "bg-muted/55 text-muted-foreground"
: "bg-primary/[0.04]"
}`} }`}
> >
<span className="font-medium text-foreground">{schedule.day}</span> <span className="font-medium text-foreground">
<span className={schedule.isClosed ? "text-sm" : "text-sm font-semibold text-primary"}>{schedule.hours}</span> {schedule.day}
</span>
<span
className={
schedule.isClosed
? "text-sm"
: "text-sm font-semibold text-primary"
}
>
{schedule.hours}
</span>
</PublicInset> </PublicInset>
))} ))}
</div> </div>
</CardContent> </PublicSurface>
</Card>
</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,10 +370,12 @@ 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">
<div className="rounded-[1.5rem] border border-border/60 bg-white/95 p-3 shadow-[0_18px_48px_rgba(15,23,42,0.08)]">
<div className="flex flex-col gap-2">
<Link <Link
href="/" href="/"
className="text-sm font-medium py-1 transition-colors" className={mobileLinkClassName}
onClick={() => dispatch({ type: "TOGGLE_MENU" })} onClick={() => dispatch({ type: "TOGGLE_MENU" })}
> >
Home Home
@ -311,7 +385,7 @@ export function Header() {
<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"
@ -323,12 +397,12 @@ export function Header() {
/> />
</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}
@ -341,8 +415,10 @@ export function Header() {
{/* 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" })
}
className={mobileGroupButtonClassName}
aria-label="Vending Machines menu" aria-label="Vending Machines menu"
aria-expanded={state.isVendingMachinesOpen} aria-expanded={state.isVendingMachinesOpen}
aria-haspopup="true" aria-haspopup="true"
@ -354,12 +430,12 @@ export function Header() {
/> />
</button> </button>
{state.isVendingMachinesOpen && ( {state.isVendingMachinesOpen && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30"> <div className="flex flex-col gap-2 border-l border-border/60 pl-4">
{vendingMachinesItems.map((item) => ( {vendingMachinesItems.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}
@ -373,7 +449,7 @@ export function Header() {
<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"
@ -385,12 +461,12 @@ export function Header() {
/> />
</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}
@ -404,7 +480,7 @@ export function Header() {
<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"
@ -416,12 +492,12 @@ export function Header() {
/> />
</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}
@ -435,7 +511,7 @@ export function Header() {
<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"
@ -447,12 +523,12 @@ export function Header() {
/> />
</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}
@ -466,7 +542,7 @@ export function Header() {
<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"
@ -478,12 +554,12 @@ export function Header() {
/> />
</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}
@ -495,27 +571,27 @@ export function Header() {
<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" })
@ -524,7 +600,7 @@ export function Header() {
/> />
<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>
@ -534,11 +610,13 @@ export function Header() {
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>
)} )}
</div> </div>

View file

@ -1,14 +1,17 @@
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"
className="relative overflow-hidden"
containerClassName="relative"
>
<div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center"> <div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center">
{/* Left Content */} {/* Left Content */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@ -29,9 +32,8 @@ export function HeroSection() {
<p className="text-lg text-muted-foreground text-pretty leading-relaxed"> <p className="text-lg text-muted-foreground text-pretty leading-relaxed">
If your breakroom, waiting area, or staff space needs a better If your breakroom, waiting area, or staff space needs a better
snack-and-drink option, we can review the location, place the snack-and-drink option, we can review the location, place the right
right machine, keep it stocked, and handle service after machine, keep it stocked, and handle service after installation.
installation.
</p> </p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@ -43,11 +45,15 @@ export function HeroSection() {
</div> </div>
<div className="flex items-center gap-2 text-foreground"> <div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" /> <CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>We review the location, then handle stocking and service</span> <span>
We review the location, then handle stocking and service
</span>
</div> </div>
<div className="flex items-center gap-2 text-foreground"> <div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" /> <CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
<span>Snack, beverage, and combo machines with cashless payment</span> <span>
Snack, beverage, and combo machines with cashless payment
</span>
</div> </div>
<div className="flex items-center gap-2 text-foreground"> <div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" /> <CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
@ -61,7 +67,9 @@ export function HeroSection() {
size="lg" size="lg"
className="bg-primary hover:bg-primary/90 text-lg h-14 px-8" className="bg-primary hover:bg-primary/90 text-lg h-14 px-8"
> >
<Link href="#request-machine">See If Your Location Qualifies</Link> <Link href="#request-machine">
See If Your Location Qualifies
</Link>
</Button> </Button>
<Button <Button
asChild asChild
@ -79,7 +87,7 @@ export function HeroSection() {
</div> </div>
<div className="relative"> <div className="relative">
<div className="aspect-square relative rounded-2xl overflow-hidden bg-muted"> <div className="relative aspect-[0.94] overflow-hidden rounded-[2rem] border border-border/70 bg-muted shadow-[var(--public-surface-shadow)]">
<Image <Image
src="/images/vending-bay-2-scaled.webp" src="/images/vending-bay-2-scaled.webp"
alt="Modern vending machines installed at Utah business" alt="Modern vending machines installed at Utah business"
@ -89,13 +97,10 @@ export function HeroSection() {
/> />
</div> </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"> <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">
<CardContent className="p-0">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<div className="text-3xl font-bold text-primary"> <div className="text-3xl font-bold text-primary">3</div>
3
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Counties Served Counties Served
</div> </div>
@ -107,11 +112,9 @@ export function HeroSection() {
</div> </div>
</div> </div>
</div> </div>
</CardContent> </PublicSurface>
</Card>
</div> </div>
</div> </div>
</div> </PublicSection>
</section>
) )
} }

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">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold">
{step.number} {step.number}
</div> </div>
</div> </div>
<div className="mb-2 text-xs font-medium text-primary uppercase tracking-wider"> <div className="mb-2 text-xs font-medium uppercase tracking-[0.18em] text-primary">
{step.timing} {step.timing}
</div> </div>
<h3 className="text-xl font-semibold mb-3">{step.title}</h3> <h3 className="text-xl font-semibold mb-3">{step.title}</h3>
<p className="text-muted-foreground text-sm leading-relaxed">{step.description}</p> <p className="text-sm leading-relaxed text-muted-foreground">
</CardContent> {step.description}
</Card> </p>
</div> </PublicInset>
))} ))}
</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,10 +270,11 @@ 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 text-muted-foreground">{service.body}</p> <p className="mt-3 leading-7 text-muted-foreground">
{service.body}
</p>
<Link <Link
href={service.href} href={service.href}
className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline" className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
@ -274,8 +282,7 @@ export function LocationLandingPage({
{service.cta} {service.cta}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
</CardContent> </PublicSurface>
</Card>
))} ))}
</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">
<MapPin className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
City Coverage
</p>
<h3 className="text-2xl font-semibold tracking-tight text-balance">
Cities We Serve
</h3>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
{locations.map((location) => ( {locations.map((location) => (
<Link <Link
key={location.slug} key={location.slug}
href={`/vending-machines-${location.slug}`} href={`/vending-machines-${location.slug}`}
className="flex items-center gap-2 group" className="group flex min-h-11 items-center gap-2 rounded-full border border-border/60 bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
> >
<div className="h-2 w-2 rounded-full bg-secondary flex-shrink-0" /> <div className="h-2 w-2 rounded-full bg-primary/70 flex-shrink-0 transition group-hover:scale-110" />
<span className="text-foreground group-hover:text-secondary transition-colors"> <span className="transition-colors">{location.city}</span>
{location.city}
</span>
</Link> </Link>
))} ))}
</div> </div>
<div className="mt-6 space-y-3"> <PublicInset className="mt-6">
<p className="text-muted-foreground"> <p className="text-sm leading-relaxed text-muted-foreground">
Don't see your city? Give us a call at{" "} Don't see your city? Give us a call at{" "}
<a href="tel:+14352339668" className="font-semibold hover:underline"> <a
href="tel:+14352339668"
className="font-semibold text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
>
(435) 233-9668 (435) 233-9668
</a>{" "} </a>{" "}
to check if we can serve your location! to check if we can serve your location!
</p> </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>
</PublicInset>
</div> </div>
</div> </PublicSurface>
</div> </PublicSection>
</section>
) )
} }

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) {
@ -277,7 +340,7 @@ export function SiteChatWidget() {
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,42 +440,55 @@ 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, ...current,
createMessage("assistant", "I'm temporarily unavailable right now. Please call us or use the contact form."), createMessage(
].slice(-12)) "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
} }
return ( const renderHeader = (isMobileLayout: boolean) => (
<div <div
className="pointer-events-none fixed right-4 z-40 flex flex-col items-end gap-3" className={cn(
style={{ bottom: "calc(env(safe-area-inset-bottom, 0px) + 1rem)" }} "flex items-start justify-between border-b border-border/70 px-4 py-4",
isMobileLayout && "px-5 pt-3"
)}
> >
{isOpen ? (
<div
data-testid="site-chat-panel"
className="pointer-events-auto flex w-[min(24rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-[1.75rem] border border-border/70 bg-white shadow-[0_24px_80px_rgba(0,0,0,0.2)]"
style={{ maxHeight: PANEL_MAX_HEIGHT }}
>
<div className="flex items-start justify-between border-b border-border/70 px-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-11 w-11" /> <AssistantAvatar
src={bootstrap.avatarSrc}
alt={bootstrap.assistantName}
sizeClassName="h-11 w-11"
/>
<div> <div>
<p className="text-base font-semibold text-foreground">{bootstrap.assistantName}</p> <p className="text-base font-semibold text-foreground">
<p className="text-xs text-muted-foreground">Text support</p> {bootstrap.assistantName}
</p>
<p className="text-xs text-muted-foreground">
Text support for service, sales, and placement questions
</p>
</div> </div>
</div> </div>
<button <button
@ -418,12 +500,20 @@ export function SiteChatWidget() {
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
)
{!profile ? ( const renderProfileGate = (isMobileLayout: boolean) => (
<div className="min-h-0 overflow-y-auto px-4 py-4"> <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"> <form onSubmit={handleProfileSubmit} className="space-y-4">
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm"> <div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
<p className="text-sm font-medium text-foreground">Start with your details</p> <p className="text-sm font-medium text-foreground">
Start with your details
</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground"> <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
We use this to route the conversation to the right team member. We use this to route the conversation to the right team member.
</p> </p>
@ -433,7 +523,12 @@ export function SiteChatWidget() {
id="site-chat-name" id="site-chat-name"
label="Name" label="Name"
value={profileDraft.name} value={profileDraft.name}
onChange={(event) => setProfileDraft((current) => ({ ...current, name: event.target.value }))} onChange={(event) =>
setProfileDraft((current) => ({
...current,
name: event.target.value,
}))
}
autoComplete="name" autoComplete="name"
required required
/> />
@ -441,7 +536,12 @@ export function SiteChatWidget() {
id="site-chat-phone" id="site-chat-phone"
label="Phone" label="Phone"
value={profileDraft.phone} value={profileDraft.phone}
onChange={(event) => setProfileDraft((current) => ({ ...current, phone: event.target.value }))} onChange={(event) =>
setProfileDraft((current) => ({
...current,
phone: event.target.value,
}))
}
autoComplete="tel" autoComplete="tel"
inputMode="tel" inputMode="tel"
type="tel" type="tel"
@ -451,7 +551,12 @@ export function SiteChatWidget() {
id="site-chat-email" id="site-chat-email"
label="Email" label="Email"
value={profileDraft.email} value={profileDraft.email}
onChange={(event) => setProfileDraft((current) => ({ ...current, email: event.target.value }))} onChange={(event) =>
setProfileDraft((current) => ({
...current,
email: event.target.value,
}))
}
autoComplete="email" autoComplete="email"
inputMode="email" inputMode="email"
type="email" type="email"
@ -461,7 +566,12 @@ export function SiteChatWidget() {
id="site-chat-intent" id="site-chat-intent"
label="Intent" label="Intent"
value={profileDraft.intent} value={profileDraft.intent}
onChange={(event) => setProfileDraft((current) => ({ ...current, intent: event.target.value }))} onChange={(event) =>
setProfileDraft((current) => ({
...current,
intent: event.target.value,
}))
}
options={intentOptions} options={intentOptions}
placeholder="Choose one" placeholder="Choose one"
required required
@ -472,7 +582,8 @@ export function SiteChatWidget() {
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm"> <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="text-sm font-medium text-foreground">Text updates</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground"> <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Required service consent covers scheduling, support, repairs, moving, and follow-up texts for this request. Required service consent covers scheduling, support, repairs,
moving, and follow-up texts for this request.
</p> </p>
<div className="mt-4"> <div className="mt-4">
<SmsConsentFields <SmsConsentFields
@ -487,7 +598,11 @@ export function SiteChatWidget() {
consentSourcePage: pathname || "/", consentSourcePage: pathname || "/",
})) }))
} }
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined} serviceError={
profileError && !profileDraft.serviceTextConsent
? profileError
: undefined
}
/> />
</div> </div>
</div> </div>
@ -529,11 +644,14 @@ export function SiteChatWidget() {
</div> </div>
) : null} ) : null}
</div> </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"> <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> <span>
Chatting as <span className="font-medium text-foreground">{profile.name}</span> Chatting as{" "}
<span className="font-medium text-foreground">{profile?.name}</span>
</span> </span>
<button <button
type="button" type="button"
@ -545,19 +663,26 @@ export function SiteChatWidget() {
</button> </button>
</div> </div>
<div className="max-h-[22rem] overflow-y-auto px-4 py-4"> <div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-4 py-4",
isMobileLayout ? "pb-3" : "max-h-[22rem]"
)}
>
<div className="space-y-3"> <div className="space-y-3">
{messages.map((message) => ( {messages.map((message) => (
<div key={message.id} className="space-y-1"> <div key={message.id} className="space-y-1">
<div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground"> <div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{message.role === "assistant" ? bootstrap.assistantName : profile.name} {message.role === "assistant"
? bootstrap.assistantName
: profile?.name}
</div> </div>
<div <div
className={cn( className={cn(
"max-w-[92%] rounded-2xl px-3 py-2 text-sm shadow-sm", "max-w-[92%] rounded-2xl px-3 py-2 text-sm shadow-sm",
message.role === "assistant" message.role === "assistant"
? "bg-muted text-foreground" ? "bg-muted text-foreground"
: "ml-auto bg-primary text-primary-foreground", : "ml-auto bg-primary text-primary-foreground"
)} )}
> >
{message.content} {message.content}
@ -571,7 +696,10 @@ export function SiteChatWidget() {
<div className="border-t border-border/70 bg-white px-4 py-4"> <div className="border-t border-border/70 bg-white px-4 py-4">
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
<label htmlFor="site-chat-input" className="text-sm font-semibold text-foreground"> <label
htmlFor="site-chat-input"
className="text-sm font-semibold text-foreground"
>
Message Message
</label> </label>
@ -579,9 +707,11 @@ export function SiteChatWidget() {
id="site-chat-input" id="site-chat-input"
data-testid="site-chat-input" data-testid="site-chat-input"
value={draft} value={draft}
onChange={(event) => setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))} onChange={(event) =>
setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))
}
placeholder="Describe what you need" placeholder="Describe what you need"
rows={3} rows={isMobileLayout ? 4 : 3}
disabled={isSending} 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" className="min-h-24 w-full rounded-2xl border border-border/70 bg-white px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60"
/> />
@ -589,7 +719,7 @@ export function SiteChatWidget() {
<SupportHint <SupportHint
formHref={formHref} formHref={formHref}
formLabel={formLabel} formLabel={formLabel}
intent={profile.intent} intent={profile?.intent || ""}
smsNumber={bootstrap.smsNumber} smsNumber={bootstrap.smsNumber}
smsUrl={bootstrap.smsUrl} smsUrl={bootstrap.smsUrl}
/> />
@ -601,7 +731,11 @@ export function SiteChatWidget() {
disabled={isSending || !draft.trim()} 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" className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
> >
{isSending ? <Loader2 className="h-4 w-4 animate-spin" /> : <MessageSquare className="h-4 w-4" />} {isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquare className="h-4 w-4" />
)}
{isSending ? "Sending..." : "Send"} {isSending ? "Sending..." : "Send"}
</button> </button>
@ -625,7 +759,82 @@ export function SiteChatWidget() {
) : null} ) : null}
</div> </div>
</> </>
)} )
const renderPanel = (isMobileLayout: boolean) => (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white">
{renderHeader(isMobileLayout)}
{!profile
? renderProfileGate(isMobileLayout)
: renderConversation(isMobileLayout)}
</div>
)
if (isMobileViewport) {
return (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
{!isOpen ? (
<div
className="fixed inset-x-0 z-40 flex justify-center px-4"
style={{
bottom: "calc(env(safe-area-inset-bottom, 0px) + 0.75rem)",
}}
>
<button
type="button"
data-testid="site-chat-launcher"
onClick={() => setIsOpen(true)}
className="inline-flex min-h-14 w-full max-w-sm items-center justify-between gap-3 rounded-full border border-white/70 bg-white/96 px-4 py-3 shadow-[0_20px_60px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_24px_68px_rgba(0,0,0,0.22)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
aria-label={`Open chat with ${bootstrap.assistantName}`}
>
<div className="flex items-center gap-3 text-left">
<AssistantAvatar
src={bootstrap.avatarSrc}
alt={bootstrap.assistantName}
sizeClassName="h-10 w-10"
/>
<div>
<div className="text-sm font-semibold text-foreground">
Chat with {bootstrap.assistantName}
</div>
<div className="text-xs text-muted-foreground">
Service, sales, repairs, and placement help
</div>
</div>
</div>
<MessageSquare className="h-5 w-5 text-primary" />
</button>
</div>
) : null}
<DrawerContent
className="max-h-[85dvh] rounded-t-[1.75rem] border-border/70 bg-white px-0"
style={{ maxHeight: MOBILE_DRAWER_MAX_HEIGHT }}
>
<DrawerTitle className="sr-only">
Chat with {bootstrap.assistantName}
</DrawerTitle>
<DrawerDescription className="sr-only">
Ask about service, repairs, sales, or free placement.
</DrawerDescription>
{renderPanel(true)}
</DrawerContent>
</Drawer>
)
}
return (
<div
className="pointer-events-none fixed right-4 z-40 flex flex-col items-end gap-3"
style={{ bottom: "calc(env(safe-area-inset-bottom, 0px) + 1rem)" }}
>
{isOpen ? (
<div
data-testid="site-chat-panel"
className="pointer-events-auto flex w-[min(24rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-[1.75rem] border border-border/70 bg-white shadow-[0_24px_80px_rgba(0,0,0,0.2)]"
style={{ maxHeight: DESKTOP_PANEL_MAX_HEIGHT }}
>
{renderPanel(false)}
</div> </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}
className="border-0 bg-transparent px-3 py-4 text-center shadow-none"
>
<div className="text-4xl md:text-5xl font-bold text-primary mb-2"> <div className="text-4xl md:text-5xl font-bold text-primary mb-2">
{stat.value} {stat.value}
</div> </div>
<div className="text-sm md:text-base text-muted-foreground"> <div className="text-sm md:text-base text-muted-foreground">
{stat.label} {stat.label}
</div> </div>
</CardContent> </PublicInset>
</Card>
))} ))}
</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" }]}