deploy: unify public UI and mobile chat
This commit is contained in:
parent
1c1c01069c
commit
0be731e474
22 changed files with 1837 additions and 1408 deletions
153
app/globals.css
153
app/globals.css
|
|
@ -10,20 +10,20 @@
|
|||
--card-foreground: oklch(0.178 0.014 275.627);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.178 0.014 275.627);
|
||||
--primary: #29A047; /* Primary brand color (green from logo) */
|
||||
--primary: #29a047; /* Primary brand color (green from logo) */
|
||||
--primary-foreground: oklch(0.989 0.003 106.423);
|
||||
--primary-dark: #1d7a35; /* Darker primary for gradients and hover states */
|
||||
--secondary: #54595F; /* Secondary color (gray) */
|
||||
--secondary: #54595f; /* Secondary color (gray) */
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.961 0.004 106.423);
|
||||
--muted-foreground: oklch(0.556 0.014 275.627);
|
||||
--accent: #C4142C; /* Accent color (red - matches link hover) */
|
||||
--accent: #c4142c; /* Accent color (red - matches link hover) */
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0.004 106.423);
|
||||
--input: oklch(0.922 0.004 106.423);
|
||||
--ring: #29A047; /* Primary color for focus rings */
|
||||
--ring: #29a047; /* Primary color for focus rings */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
|
@ -38,26 +38,33 @@
|
|||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
|
||||
/* Link Colors - Master Style Guide */
|
||||
--link-color: var(--foreground);
|
||||
--link-hover-color: #c4142c; /* Red: rgb(196, 20, 44) */
|
||||
--link-hover-color-dark: #a01020; /* Darker red for gradients and hover states */
|
||||
--link-hover-bg: rgba(196, 20, 44, 0.1);
|
||||
|
||||
--header-bg: #ffffff;
|
||||
--footer-bg: #fef3e0;
|
||||
--shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
--header-bg: #ffffff;
|
||||
--footer-bg: #fef3e0;
|
||||
--shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Custom brand colors */
|
||||
--yellow: #FCBA09;
|
||||
--orange: #F79611;
|
||||
--mountain-bubbles: #FCBA0924; /* Yellow with transparency */
|
||||
/* Custom brand colors */
|
||||
--yellow: #fcba09;
|
||||
--orange: #f79611;
|
||||
--mountain-bubbles: #fcba0924; /* Yellow with transparency */
|
||||
--public-shell-max: 80rem;
|
||||
--public-section-space: clamp(4.5rem, 7vw, 7rem);
|
||||
--public-surface-radius: 2rem;
|
||||
--public-inset-radius: 1.5rem;
|
||||
--public-surface-shadow: 0 22px 56px rgba(15, 23, 42, 0.08);
|
||||
--public-surface-shadow-hover: 0 28px 72px rgba(15, 23, 42, 0.12);
|
||||
--header-height: 5.25rem;
|
||||
|
||||
/* Increased spacing variables */
|
||||
--spacing-xs: 0.75rem;
|
||||
--spacing-sm: 1.25rem;
|
||||
}
|
||||
/* Increased spacing variables */
|
||||
--spacing-xs: 0.75rem;
|
||||
--spacing-sm: 1.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0.01 275.627);
|
||||
|
|
@ -92,7 +99,7 @@
|
|||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
|
||||
|
||||
/* Link Colors - Dark Mode */
|
||||
--link-color: var(--foreground);
|
||||
--link-hover-color: #ff4d6d; /* Lighter red for dark mode visibility */
|
||||
|
|
@ -101,8 +108,11 @@
|
|||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-inter), "Inter", "Inter Fallback", system-ui, -apple-system, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", monospace;
|
||||
--font-sans:
|
||||
var(--font-inter), "Inter", "Inter Fallback", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
|
@ -147,44 +157,70 @@
|
|||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-inter), "Inter", system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
var(--font-inter),
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth; /* Added smooth scroll behavior for Apple-like experience */
|
||||
scroll-padding-top: calc(var(--header-height) + 1.25rem);
|
||||
}
|
||||
|
||||
|
||||
/* Global Link Styling - Master Style Guide */
|
||||
/* All hyperlinks should highlight in red on hover */
|
||||
a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
text-decoration-color: transparent;
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.18em;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
text-decoration-color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: var(--link-hover-color);
|
||||
background-color: var(--link-hover-bg);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
a:active {
|
||||
color: var(--link-hover-color);
|
||||
}
|
||||
|
||||
|
||||
/* Next.js Link components inherit the same styling */
|
||||
a[href] {
|
||||
color: var(--link-color);
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
text-decoration-color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
a[href]:hover,
|
||||
a[href]:focus {
|
||||
color: var(--link-hover-color);
|
||||
background-color: var(--link-hover-bg);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Hide Next.js dev tools portal */
|
||||
nextjs-portal,
|
||||
nextjs-portal * {
|
||||
|
|
@ -193,7 +229,7 @@
|
|||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* WordPress content styling */
|
||||
.prose strong,
|
||||
article strong,
|
||||
|
|
@ -201,13 +237,13 @@
|
|||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
||||
.prose em,
|
||||
article em,
|
||||
.wordpress-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
.prose ul,
|
||||
.prose ol,
|
||||
article ul,
|
||||
|
|
@ -218,7 +254,7 @@
|
|||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
.prose li,
|
||||
article li,
|
||||
.wordpress-content li {
|
||||
|
|
@ -239,6 +275,26 @@
|
|||
color: var(--link-hover-color);
|
||||
}
|
||||
|
||||
.public-page {
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
max-width: var(--public-shell-max);
|
||||
padding-inline: 1rem;
|
||||
padding-block: 2.5rem 3.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.public-page {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.public-page {
|
||||
padding-block: 3rem 4.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wordpress-content h1,
|
||||
.wordpress-content h2,
|
||||
.wordpress-content h3,
|
||||
|
|
@ -271,7 +327,7 @@
|
|||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
.wordpress-content > div img,
|
||||
.wordpress-content figure img {
|
||||
@apply mx-auto block;
|
||||
|
|
@ -285,44 +341,49 @@
|
|||
.wordpress-content > div {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
|
||||
/* Visual Separation for Page Sections */
|
||||
section {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
|
||||
/* Main content wrapper for visual separation from header */
|
||||
main {
|
||||
@apply relative;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
|
||||
/* Improved spacing and readability */
|
||||
p {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
||||
/* Footer Styling - Only apply to top-level footer, not article footers */
|
||||
body > div > footer {
|
||||
background: var(--footer-bg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
|
||||
/* Hide scrollbar for horizontal scrolling galleries */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
/* Focus visible styling for keyboard navigation - Following Vercel Web Design Guidelines */
|
||||
|
|
|
|||
|
|
@ -1,80 +1,44 @@
|
|||
export const dynamic = "force-dynamic"
|
||||
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Metadata } from 'next'
|
||||
import { businessConfig } from '@/lib/seo-config'
|
||||
import { ManualsPageExperience } from '@/components/manuals-page-experience'
|
||||
import { listConvexManuals } from '@/lib/convex-service'
|
||||
import { scanManuals } from '@/lib/manuals'
|
||||
import { selectManualsForSite } from '@/lib/manuals-site-selection'
|
||||
import { generateStructuredData } from '@/lib/seo'
|
||||
import { getManualsThumbnailsRoot } from '@/lib/manuals-paths'
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { Metadata } from "next"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { ManualsPageExperience } from "@/components/manuals-page-experience"
|
||||
import { listConvexManuals } from "@/lib/convex-service"
|
||||
import { scanManuals } from "@/lib/manuals"
|
||||
import { selectManualsForSite } from "@/lib/manuals-site-selection"
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending',
|
||||
export const metadata: Metadata = generateSEOMetadata({
|
||||
title: "Vending Machine Manuals | Rocky Mountain Vending",
|
||||
description:
|
||||
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.',
|
||||
"Browse vending machine manuals, service guides, and support documentation for snack, beverage, combo, coffee, and food machines.",
|
||||
path: "/manuals",
|
||||
keywords: [
|
||||
'vending machine manuals',
|
||||
'vending machine PDF',
|
||||
'vending machine service manual',
|
||||
'vending machine parts catalog',
|
||||
'vending machine repair manual',
|
||||
'vending machine installation guide',
|
||||
'vending machine troubleshooting guide',
|
||||
'Royal Vendors manual',
|
||||
'Dixie-Narco manual',
|
||||
'Vendo manual',
|
||||
'Crane vending manual',
|
||||
'BevMax manual',
|
||||
'Merchant Series manual',
|
||||
'AP vending manual',
|
||||
'GPL vending manual',
|
||||
'Seaga vending manual',
|
||||
'USI vending manual',
|
||||
'snack machine manual',
|
||||
'beverage machine manual',
|
||||
'combo vending machine manual',
|
||||
'coffee vending machine manual',
|
||||
'food vending machine manual',
|
||||
'frozen food vending manual',
|
||||
'vending machine parts',
|
||||
'vending machine replacement parts',
|
||||
'vending machine wiring diagram',
|
||||
'vending machine maintenance',
|
||||
"vending machine manuals",
|
||||
"vending machine PDF",
|
||||
"vending machine service manual",
|
||||
"vending machine repair manual",
|
||||
"vending machine troubleshooting guide",
|
||||
"Royal Vendors manual",
|
||||
"Dixie-Narco manual",
|
||||
"Vendo manual",
|
||||
"Crane vending manual",
|
||||
"Seaga vending manual",
|
||||
],
|
||||
openGraph: {
|
||||
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending',
|
||||
description:
|
||||
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from leading manufacturers. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines.',
|
||||
type: 'website',
|
||||
url: `${businessConfig.website}/manuals`,
|
||||
images: [
|
||||
{
|
||||
url: `${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`,
|
||||
width: 926,
|
||||
height: 1024,
|
||||
alt: 'Rocky Mountain Vending Manuals',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Vending Machine Manuals | Download PDF Guides',
|
||||
description:
|
||||
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, and more. Find service manuals, parts catalogs, installation instructions, and troubleshooting guides.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${businessConfig.website}/manuals`,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export default async function ManualsPage() {
|
||||
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
|
||||
const convexManuals = await listConvexManuals()
|
||||
const allManuals = convexManuals.length > 0 ? convexManuals : await scanManuals()
|
||||
let manuals = convexManuals.length > 0 ? convexManuals : selectManualsForSite(allManuals).manuals
|
||||
const allManuals =
|
||||
convexManuals.length > 0 ? convexManuals : await scanManuals()
|
||||
let manuals =
|
||||
convexManuals.length > 0
|
||||
? convexManuals
|
||||
: selectManualsForSite(allManuals).manuals
|
||||
|
||||
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
|
||||
const thumbnailsRoot = getManualsThumbnailsRoot()
|
||||
|
|
@ -83,43 +47,43 @@ export default async function ManualsPage() {
|
|||
return manual
|
||||
}
|
||||
|
||||
const relativeThumbnailPath = manual.thumbnailUrl.includes('/thumbnails/')
|
||||
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, '')
|
||||
const relativeThumbnailPath = manual.thumbnailUrl.includes("/thumbnails/")
|
||||
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, "")
|
||||
: manual.thumbnailUrl
|
||||
|
||||
return existsSync(join(thumbnailsRoot, relativeThumbnailPath))
|
||||
? manual
|
||||
: { ...manual, thumbnailUrl: undefined }
|
||||
})
|
||||
|
||||
|
||||
// Generate structured data for SEO
|
||||
const structuredData = generateStructuredData({
|
||||
title: 'Vending Machine Manuals',
|
||||
title: "Vending Machine Manuals",
|
||||
description:
|
||||
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.',
|
||||
"Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.",
|
||||
url: `${businessConfig.website}/manuals`,
|
||||
type: 'WebPage',
|
||||
type: "WebPage",
|
||||
})
|
||||
|
||||
// Add CollectionPage schema for better SEO
|
||||
const collectionSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Vending Machine Manuals',
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
name: "Vending Machine Manuals",
|
||||
description:
|
||||
'A comprehensive collection of vending machine manuals, service guides, and parts documentation from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Includes service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. Many manuals feature available replacement parts with direct purchase links.',
|
||||
"A comprehensive collection of vending machine manuals, service guides, and parts documentation from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Includes service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. Many manuals feature available replacement parts with direct purchase links.",
|
||||
url: `${businessConfig.website}/manuals`,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
"@type": "ItemList",
|
||||
numberOfItems: manuals.length,
|
||||
itemListElement: manuals.slice(0, 50).map((manual, index) => ({
|
||||
'@type': 'ListItem',
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
item: {
|
||||
'@type': 'DigitalDocument',
|
||||
name: manual.filename.replace(/\.pdf$/i, ''),
|
||||
"@type": "DigitalDocument",
|
||||
name: manual.filename.replace(/\.pdf$/i, ""),
|
||||
description: `${manual.manufacturer} ${manual.category} Manual`,
|
||||
encodingFormat: 'application/pdf',
|
||||
encodingFormat: "application/pdf",
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
|
@ -135,7 +99,7 @@ export default async function ManualsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
<div className="public-page">
|
||||
<ManualsPageExperience initialManuals={manuals} />
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,37 @@
|
|||
import { notFound } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { fetchProductById, fetchAllProducts } from '@/lib/stripe/products'
|
||||
import { AddToCartButton } from '@/components/add-to-cart-button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { notFound } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
import { fetchProductById, fetchAllProducts } from "@/lib/stripe/products"
|
||||
import { AddToCartButton } from "@/components/add-to-cart-button"
|
||||
import { PublicInset, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
interface ProductPageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
// Required for static export
|
||||
export const dynamic = 'force-static';
|
||||
export const dynamicParams = false;
|
||||
export const dynamic = "force-static"
|
||||
export const dynamicParams = false
|
||||
|
||||
// Generate static params for all products
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const products = await fetchAllProducts();
|
||||
|
||||
const products = await fetchAllProducts()
|
||||
|
||||
// Ensure we have products
|
||||
if (!products || products.length === 0) {
|
||||
console.warn('No products found for static generation. Product pages will not be pre-rendered.');
|
||||
return [];
|
||||
console.warn(
|
||||
"No products found for static generation. Product pages will not be pre-rendered."
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
return products.map((product) => ({
|
||||
id: product.id,
|
||||
}));
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error generating static params for products:', error);
|
||||
console.error("Error generating static params for products:", error)
|
||||
// Return empty array - product pages won't be pre-rendered but won't break the build
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +41,7 @@ export async function generateMetadata({ params }: ProductPageProps) {
|
|||
|
||||
if (!product) {
|
||||
return {
|
||||
title: 'Product Not Found | Rocky Mountain Vending',
|
||||
title: "Product Not Found | Rocky Mountain Vending",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,69 +59,70 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||
notFound()
|
||||
}
|
||||
|
||||
const imageUrl = product.images?.[0] || '/placeholder.svg'
|
||||
const imageUrl = product.images?.[0] || "/placeholder.svg"
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:gap-12">
|
||||
{/* Product Image */}
|
||||
<Card className="overflow-hidden border-border/50 hover:border-secondary/50 transition-all">
|
||||
<CardContent className="p-0">
|
||||
<div className="aspect-square relative overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4">{product.name}</h1>
|
||||
<div className="public-page">
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_0.95fr] lg:gap-8">
|
||||
<PublicSurface className="overflow-hidden p-0">
|
||||
<div className="relative aspect-square overflow-hidden bg-muted/60">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 52vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
|
||||
<PublicSurface className="flex flex-col">
|
||||
<div className="mb-6">
|
||||
<p className="text-3xl font-bold text-[var(--link-hover-color)] mb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance md:text-4xl">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="mt-4 text-3xl font-bold text-[var(--link-hover-color)]">
|
||||
${product.price.toFixed(2)} {product.currency.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{product.description && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">Description</h2>
|
||||
<PublicInset className="mb-5">
|
||||
<h2 className="text-lg font-semibold">Description</h2>
|
||||
<div
|
||||
className="text-muted-foreground leading-relaxed whitespace-pre-line"
|
||||
className="mt-3 whitespace-pre-line text-muted-foreground leading-7"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: product.description.replace(/\n/g, '<br />'),
|
||||
__html: product.description.replace(/\n/g, "<br />"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PublicInset>
|
||||
)}
|
||||
|
||||
{product.metadata && Object.keys(product.metadata).length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">Specifications</h2>
|
||||
<dl className="space-y-2">
|
||||
<PublicInset className="mb-5">
|
||||
<h2 className="text-lg font-semibold">Specifications</h2>
|
||||
<dl className="mt-3 space-y-3">
|
||||
{Object.entries(product.metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex">
|
||||
<dt className="font-medium mr-2">{key}:</dt>
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 border-b border-border/50 pb-3 last:border-b-0 last:pb-0 sm:flex-row sm:gap-3"
|
||||
>
|
||||
<dt className="font-medium text-foreground sm:min-w-32">
|
||||
{key}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</PublicInset>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<div className="mt-auto pt-3">
|
||||
<AddToCartButton product={product} />
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export default function ServiceAreasPage() {
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<div className="public-page">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Service Coverage"
|
||||
|
|
@ -129,8 +129,8 @@ export default function ServiceAreasPage() {
|
|||
Don't see your city yet?
|
||||
</h2>
|
||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
If you're close to one of our current routes, we may still
|
||||
be able to help. Reach out and we'll confirm whether your
|
||||
If you're close to one of our current routes, we may still be
|
||||
able to help. Reach out and we'll confirm whether your
|
||||
location fits our current coverage and service schedule.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -169,8 +169,8 @@ export default function ServiceAreasPage() {
|
|||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-2xl text-base leading-relaxed text-muted-foreground">
|
||||
From free placement and machine sales to repairs, moving, and
|
||||
parts help, we help Utah businesses keep vending available
|
||||
without having to manage the machines themselves.
|
||||
parts help, we help Utah businesses keep vending available without
|
||||
having to manage the machines themselves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const metadata: Metadata = generateSEOMetadata({
|
|||
|
||||
export default function MovingServicesPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-6xl px-4 py-10 md:py-14">
|
||||
<div className="public-page">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[
|
||||
|
|
@ -40,30 +40,28 @@ export default function MovingServicesPage() {
|
|||
|
||||
{/* Introduction Section */}
|
||||
<section className="mb-12">
|
||||
<Card className="border-border/50 shadow-md">
|
||||
<CardContent className="p-6 md:p-8">
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
At Rocky Mountain Vending LLC, we specialize in the safe and
|
||||
efficient relocation of vending machines of all types and sizes
|
||||
— from compact snack machines to full-size refrigerated beverage
|
||||
and combo units. Whether you're rearranging equipment within a
|
||||
building, moving to a new location, or removing an old machine,
|
||||
our experienced team handles every detail to minimize downtime
|
||||
and protect your investment.
|
||||
</p>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Vending machines are heavy (often 400–900+ lbs), delicate, and
|
||||
require specialized handling to avoid damage to internal
|
||||
components like compressors, electronics, glass fronts, or
|
||||
refrigeration systems. Attempting a DIY move can lead to costly
|
||||
repairs, injuries, or property damage. We use proven techniques
|
||||
and professional-grade equipment to ensure a smooth, damage-free
|
||||
process every time.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PublicSurface className="p-6 md:p-8">
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
At Rocky Mountain Vending LLC, we specialize in the safe and
|
||||
efficient relocation of vending machines of all types and sizes —
|
||||
from compact snack machines to full-size refrigerated beverage and
|
||||
combo units. Whether you're rearranging equipment within a
|
||||
building, moving to a new location, or removing an old machine,
|
||||
our experienced team handles every detail to minimize downtime and
|
||||
protect your investment.
|
||||
</p>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Vending machines are heavy (often 400–900+ lbs), delicate, and
|
||||
require specialized handling to avoid damage to internal
|
||||
components like compressors, electronics, glass fronts, or
|
||||
refrigeration systems. Attempting a DIY move can lead to costly
|
||||
repairs, injuries, or property damage. We use proven techniques
|
||||
and professional-grade equipment to ensure a smooth, damage-free
|
||||
process every time.
|
||||
</p>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
{/* Image Gallery Section */}
|
||||
|
|
@ -75,10 +73,7 @@ export default function MovingServicesPage() {
|
|||
{/* Image 1 */}
|
||||
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-0">
|
||||
<div
|
||||
className="relative w-full bg-muted"
|
||||
style={{ height: "600px" }}
|
||||
>
|
||||
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
|
||||
<Image
|
||||
src="/images/vending-machine-moving-service-1.png"
|
||||
alt="Vending machine securely packaged for transport with dark green protective blankets, clear shrink wrap, bright yellow straps, and yellow corner protectors on wooden pallet - professional moving service in Utah"
|
||||
|
|
@ -102,10 +97,7 @@ export default function MovingServicesPage() {
|
|||
{/* Image 2 */}
|
||||
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-0">
|
||||
<div
|
||||
className="relative w-full bg-muted"
|
||||
style={{ height: "600px" }}
|
||||
>
|
||||
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
|
||||
<Image
|
||||
src="/images/vending-machine-moving-service-2.png"
|
||||
alt="Vending machine wrapped in dark blue protective blankets and clear shrink wrap with yellow straps, foam padding, and safety markings secured on wooden pallets for safe transport"
|
||||
|
|
@ -129,10 +121,7 @@ export default function MovingServicesPage() {
|
|||
{/* Image 3 */}
|
||||
<Card className="border-border/50 shadow-md overflow-hidden hover:border-secondary/50 transition-colors">
|
||||
<CardContent className="p-0">
|
||||
<div
|
||||
className="relative w-full bg-muted"
|
||||
style={{ height: "600px" }}
|
||||
>
|
||||
<div className="relative aspect-[4/5] w-full bg-muted sm:aspect-[3/4]">
|
||||
<Image
|
||||
src="/images/vending-machine-moving-service-3.webp"
|
||||
alt="Utah vending machine moving professionals transporting commercial vending equipment with secure transport methods and fully insured relocation services"
|
||||
|
|
@ -292,57 +281,52 @@ export default function MovingServicesPage() {
|
|||
<h2 className="text-3xl font-bold mb-8 tracking-tight text-balance">
|
||||
Why Choose Us for Your Vending Move?
|
||||
</h2>
|
||||
<Card className="border-border/70">
|
||||
<CardContent className="p-6 md:p-8">
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">
|
||||
Years of hands-on vending industry experience
|
||||
</strong>{" "}
|
||||
— we understand the unique vulnerabilities of snack, drink,
|
||||
combo, and refrigerated machines.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<Shield className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">Fully insured</strong> for
|
||||
complete peace of mind.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<Clock className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">
|
||||
Minimal disruption:
|
||||
</strong>{" "}
|
||||
Fast, coordinated service scheduled around your business
|
||||
hours.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">
|
||||
One-machine or multi-machine jobs
|
||||
</strong>{" "}
|
||||
handled efficiently.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<Shield className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">
|
||||
Commitment to safety
|
||||
</strong>{" "}
|
||||
for our team, your staff, and your equipment.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PublicSurface className="p-6 md:p-8">
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">
|
||||
Years of hands-on vending industry experience
|
||||
</strong>{" "}
|
||||
— we understand the unique vulnerabilities of snack, drink,
|
||||
combo, and refrigerated machines.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<Shield className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">Fully insured</strong> for
|
||||
complete peace of mind.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<Clock className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">Minimal disruption:</strong>{" "}
|
||||
Fast, coordinated service scheduled around your business hours.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">
|
||||
One-machine or multi-machine jobs
|
||||
</strong>{" "}
|
||||
handled efficiently.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<Shield className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">
|
||||
Commitment to safety
|
||||
</strong>{" "}
|
||||
for our team, your staff, and your equipment.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import {
|
||||
generateRegistryMetadata,
|
||||
generateRegistryStructuredData,
|
||||
} from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { ServicesSection } from "@/components/services-section"
|
||||
|
|
@ -18,7 +21,11 @@ import {
|
|||
import { RepairsImageCarousel } from "@/components/repairs-image-carousel"
|
||||
import { ContactForm } from "@/components/forms/contact-form"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import { PublicInset, PublicSurface } from "@/components/public-surface"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
|
@ -38,14 +45,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
}
|
||||
}
|
||||
|
||||
return generateSEOMetadata({
|
||||
title: page.title || "Vending Machine Repairs",
|
||||
description: page.seoDescription || page.excerpt || "",
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("repairs", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
path: "/services/repairs",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -140,28 +143,10 @@ export default async function RepairsPage() {
|
|||
<p className="text-muted-foreground">No content available.</p>
|
||||
)
|
||||
|
||||
let structuredData
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || "Vending Machine Repairs",
|
||||
description: page.seoDescription || page.excerpt || "",
|
||||
url:
|
||||
page.link ||
|
||||
page.urlPath ||
|
||||
`https://rockymountainvending.com/services/repairs/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: "WebPage",
|
||||
})
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
headline: page.title || "Vending Machine Repairs",
|
||||
description: page.seoDescription || "",
|
||||
url: `https://rockymountainvending.com/services/repairs/`,
|
||||
}
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("repairs", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -179,11 +164,15 @@ export default async function RepairsPage() {
|
|||
{ label: "Repairs", href: "/services/repairs" },
|
||||
]}
|
||||
/>
|
||||
<header className="mb-8 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
{page.title || "Vending Machine Repairs and Service"}
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-3xl mx-auto leading-relaxed mb-8">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
className="mb-8"
|
||||
title={page.title || "Vending Machine Repairs and Service"}
|
||||
description={
|
||||
"Rocky Mountain Vending delivers expert vending machine repair and maintenance services to keep your business thriving."
|
||||
}
|
||||
>
|
||||
<p className="mx-auto max-w-3xl text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||
Rocky Mountain Vending delivers expert{" "}
|
||||
<Link
|
||||
href="/services/repairs"
|
||||
|
|
@ -210,11 +199,11 @@ export default async function RepairsPage() {
|
|||
</Link>{" "}
|
||||
services, contact us today for fast, professional solutions!
|
||||
</p>
|
||||
</header>
|
||||
</PublicPageHeader>
|
||||
{/* Images Carousel */}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<PublicSurface className="mx-auto max-w-4xl p-4 md:p-5">
|
||||
<RepairsImageCarousel />
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -576,10 +565,10 @@ export default async function RepairsPage() {
|
|||
If you don't see your brand listed, feel free to reach out! We
|
||||
may still be able to service it, but there are some brands we
|
||||
may not support.{" "}
|
||||
<Link
|
||||
href="/contact-us"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
<Link
|
||||
href="/contact-us"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
, and we'll let you know how we can help.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ import type { Metadata } from "next"
|
|||
import Link from "next/link"
|
||||
import { getAllLocations } from "@/lib/location-data"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowRight, MapPin } from "lucide-react"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSectionHeader,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Utah Service Areas | Rocky Mountain Vending",
|
||||
|
|
@ -92,209 +97,156 @@ export default function ServiceAreasServicesPage() {
|
|||
},
|
||||
]
|
||||
|
||||
const counties = [
|
||||
{
|
||||
title: "Salt Lake County",
|
||||
description:
|
||||
"Serving 14 cities in Salt Lake County with reliable vending services.",
|
||||
locations: saltLakeCounty,
|
||||
},
|
||||
{
|
||||
title: "Davis County",
|
||||
description:
|
||||
"Supporting businesses from Ogden to Layton with reliable vending services.",
|
||||
locations: davisCounty,
|
||||
},
|
||||
{
|
||||
title: "Utah County",
|
||||
description:
|
||||
"Serving Provo and surrounding areas with quality vending services.",
|
||||
locations: utahCounty,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
<header className="text-center mb-12 md:mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Vending Machine Services by Location
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-3xl mx-auto text-pretty leading-relaxed">
|
||||
Rocky Mountain Vending provides comprehensive vending machine services
|
||||
across 20 service areas in Utah. Find services available in your city
|
||||
below.
|
||||
</p>
|
||||
</header>
|
||||
<div className="public-page">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Legacy Route"
|
||||
title="Vending machine services by location"
|
||||
description="Rocky Mountain Vending provides vending machine sales, service, repairs, and healthy option planning across our Utah coverage area. Browse by city below or head to the main service-areas page for the canonical experience."
|
||||
className="mb-10 md:mb-14"
|
||||
/>
|
||||
|
||||
{/* Services Overview */}
|
||||
<section className="mb-16 max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold mb-8 text-center tracking-tight text-balance">
|
||||
Our Services
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{services.map((service, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-6">
|
||||
<section className="mx-auto mb-12 max-w-5xl">
|
||||
<PublicSurface className="p-5 md:p-7">
|
||||
<PublicSectionHeader
|
||||
eyebrow="Service Overview"
|
||||
title="Our services"
|
||||
description="Every city we cover can be supported with the same core vending services, tailored to the way the location actually operates."
|
||||
className="mb-6"
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{services.map((service, index) => (
|
||||
<PublicInset key={index} className="h-full">
|
||||
<h3 className="text-xl font-semibold mb-3">{service.title}</h3>
|
||||
<p className="text-muted-foreground">{service.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-7">
|
||||
{service.description}
|
||||
</p>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
|
||||
{/* Salt Lake County */}
|
||||
<section className="mb-16">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2 tracking-tight text-balance">
|
||||
Salt Lake County
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Serving 14 cities in Salt Lake County with reliable vending services
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{saltLakeCounty.map((location) => (
|
||||
<Card
|
||||
key={location.slug}
|
||||
className="h-full hover:border-secondary/50 transition-colors"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="space-y-10 md:space-y-12">
|
||||
{counties.map((county) => (
|
||||
<section key={county.title} className="mx-auto max-w-6xl">
|
||||
<div className="mb-6">
|
||||
<PublicSectionHeader
|
||||
eyebrow="Coverage"
|
||||
title={county.title}
|
||||
description={county.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{county.locations.map((location) => (
|
||||
<PublicSurface
|
||||
key={location.slug}
|
||||
className="flex h-full flex-col p-5"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl font-semibold mb-1">
|
||||
{location.city}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1.5">
|
||||
<MapPin className="h-4 w-4 text-primary" />
|
||||
{location.zipCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Services Available:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Vending Machine Sales</li>
|
||||
<li>• Repair Services</li>
|
||||
<li>• Healthy Options</li>
|
||||
<li>• Maintenance</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link href={`/vending-machines-${location.slug}`}>
|
||||
<Button variant="outline" className="w-full group">
|
||||
View Services
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Davis County */}
|
||||
<section className="mb-16">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2 tracking-tight text-balance">
|
||||
Davis County
|
||||
<div className="mb-5">
|
||||
<p className="text-sm font-medium text-foreground mb-2">
|
||||
Services Available
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>Vending machine sales</li>
|
||||
<li>Repair services</li>
|
||||
<li>Healthy snack and beverage options</li>
|
||||
<li>Maintenance and restocking</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/vending-machines-${location.slug}`}
|
||||
className="mt-auto"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 w-full rounded-full group"
|
||||
>
|
||||
View Services
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</PublicSurface>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mx-auto mt-12 max-w-4xl">
|
||||
<PublicSurface className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-balance">
|
||||
Ready to get started?
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Supporting businesses from Ogden to Layton with reliable vending
|
||||
services
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
All services are available across our coverage area. Reach out and
|
||||
we'll help you figure out the right next step for your
|
||||
location.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{davisCounty.map((location) => (
|
||||
<Card
|
||||
key={location.slug}
|
||||
className="h-full hover:border-secondary/50 transition-colors"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-1">
|
||||
{location.city}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{location.zipCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Services Available:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Vending Machine Sales</li>
|
||||
<li>• Repair Services</li>
|
||||
<li>• Healthy Options</li>
|
||||
<li>• Maintenance</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link href={`/vending-machines-${location.slug}`}>
|
||||
<Button variant="outline" className="w-full group">
|
||||
View Services
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Utah County */}
|
||||
<section className="mb-16">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2 tracking-tight text-balance">
|
||||
Utah County
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Serving Provo and surrounding areas with quality vending services
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{utahCounty.map((location) => (
|
||||
<Card
|
||||
key={location.slug}
|
||||
className="h-full hover:border-secondary/50 transition-colors"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-1">
|
||||
{location.city}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{location.zipCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Services Available:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Vending Machine Sales</li>
|
||||
<li>• Repair Services</li>
|
||||
<li>• Healthy Options</li>
|
||||
<li>• Maintenance</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link href={`/vending-machines-${location.slug}`}>
|
||||
<Button variant="outline" className="w-full group">
|
||||
View Services
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="max-w-4xl mx-auto text-center py-12 bg-muted/30 rounded-lg">
|
||||
<h2 className="text-3xl font-bold mb-4 tracking-tight text-balance">
|
||||
Ready to Get Started?
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground mb-6 text-pretty leading-relaxed">
|
||||
All services are available across all 20 service areas. Contact us
|
||||
today to learn more about vending machine solutions for your business.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/contact-us">
|
||||
<Button size="lg">Contact Us</Button>
|
||||
</Link>
|
||||
<Link href="/service-areas">
|
||||
<Button size="lg" variant="outline">
|
||||
View All Service Areas
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col justify-center gap-3 sm:flex-row">
|
||||
<Link href="/contact-us">
|
||||
<Button size="lg" className="min-h-11 rounded-full">
|
||||
Contact Us
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/service-areas">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="min-h-11 rounded-full"
|
||||
>
|
||||
View All Service Areas
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<PublicInset className="mx-auto mt-6 max-w-2xl text-left sm:text-center">
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
This route stays available for legacy links, but the primary
|
||||
service-area experience lives on the main{" "}
|
||||
<Link
|
||||
href="/service-areas"
|
||||
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
|
||||
>
|
||||
Utah service areas page
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</PublicInset>
|
||||
</PublicSurface>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,58 +1,72 @@
|
|||
'use client'
|
||||
"use client"
|
||||
|
||||
import Image from 'next/image'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
|
||||
export function AboutPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-12 max-w-6xl">
|
||||
{/* Header */}
|
||||
<header className="text-center mb-12 md:mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">About Us</h1>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[var(--link-hover-color)] to-[var(--link-hover-color-dark)] mx-auto rounded-full"></div>
|
||||
</header>
|
||||
<div className="public-page max-w-6xl">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Family Owned"
|
||||
title="About Rocky Mountain Vending"
|
||||
description="We built Rocky Mountain Vending to serve Utah businesses with responsive service, better machine care, and a relationship that still feels personal."
|
||||
className="mb-10 md:mb-14"
|
||||
/>
|
||||
|
||||
{/* Main Content - Image and Text */}
|
||||
<div className="grid gap-8 md:grid-cols-3 mb-12">
|
||||
{/* Left Column - Image */}
|
||||
<div className="md:col-span-1">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-lg overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<PublicSurface className="overflow-hidden p-5 md:p-7">
|
||||
<div className="grid gap-6 md:grid-cols-[0.95fr_1.05fr] md:gap-8">
|
||||
<div>
|
||||
<PublicInset className="overflow-hidden p-0">
|
||||
<Image
|
||||
src="https://rockymountainvending.com/wp-content/uploads/2022/06/Rebekahand-Matt-scaled.webp"
|
||||
alt="Matt and Rebekah"
|
||||
width={240}
|
||||
height={300}
|
||||
className="w-full h-auto object-cover"
|
||||
width={640}
|
||||
height={800}
|
||||
className="h-full w-full object-cover"
|
||||
priority
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PublicInset>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Text Content */}
|
||||
<div className="md:col-span-2">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors shadow-lg h-full">
|
||||
<CardContent className="p-6 md:p-8">
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
When my wife, Rebekah, and I met we knew that we wanted to start a business and raise our family here in the Salt Lake Valley. We met in Bellevue Washington and as soon we got married moved straight to Salt Lake City to start Rocky Mountain Vending. Our focus is on Healthy Vending and providing the best service. Of course we can also provide traditional vending options as well.
|
||||
</p>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Rocky Mountain Vending is 100% a local family run business founded in 2019. If you are looking for the old-fashioned business relationship with all the perks of the 21st century.
|
||||
</p>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
We strongly believe that business should be founded on trust, exceptional customer service, employing the latest technology, and of course having fun while we work.
|
||||
</p>
|
||||
<p className="text-foreground font-semibold text-lg mt-6">
|
||||
~Matt
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="space-y-4 text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
|
||||
<p>
|
||||
When my wife, Rebekah, and I met, we knew we wanted to start a
|
||||
business and raise our family here in the Salt Lake Valley. We
|
||||
met in Bellevue, Washington and moved straight to Salt Lake City
|
||||
after we got married to build Rocky Mountain Vending.
|
||||
</p>
|
||||
<p>
|
||||
Our focus is on healthy vending and dependable service, while
|
||||
still helping locations that want traditional snack and drink
|
||||
options too.
|
||||
</p>
|
||||
<p>
|
||||
Rocky Mountain Vending is a local family-run business founded in
|
||||
2019. We believe business should be built on trust, exceptional
|
||||
customer service, modern technology, and enjoying the work we
|
||||
do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PublicInset className="mt-6">
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
If you're looking for a more personal vending partner with
|
||||
modern tools and real follow-through, that's exactly what
|
||||
we set out to build.
|
||||
</p>
|
||||
<p className="mt-4 text-lg font-semibold text-foreground">
|
||||
~Matt
|
||||
</p>
|
||||
</PublicInset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
import { Clock, Mail, Phone } from "lucide-react"
|
||||
import { ContactForm } from "@/components/forms/contact-form"
|
||||
import { PublicInset, PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
export function ContactPage() {
|
||||
|
|
@ -18,7 +21,7 @@ export function ContactPage() {
|
|||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-6xl px-4 py-10 md:py-14">
|
||||
<div className="public-page">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Contact Rocky Mountain Vending"
|
||||
|
|
@ -32,73 +35,110 @@ export function ContactPage() {
|
|||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Contact Form
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">For repairs or moving, include the machine model and a clear description of what's happening.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For repairs or moving, include the machine model and a clear
|
||||
description of what's happening.
|
||||
</p>
|
||||
</div>
|
||||
<ContactForm onSubmit={(data) => console.log("Contact form submitted:", data)} />
|
||||
<ContactForm
|
||||
onSubmit={(data) => console.log("Contact form submitted:", data)}
|
||||
/>
|
||||
</PublicSurface>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<Card className="overflow-hidden rounded-[2rem] border-border/70 bg-white shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
|
||||
<CardContent className="bg-white p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">Direct Options</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-foreground">Reach the team directly</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
We monitor calls, texts, and email throughout the business day. If you're sending repair photos or videos, text them to the number below.
|
||||
</p>
|
||||
<PublicSurface className="overflow-hidden p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Direct Options
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-foreground">
|
||||
Reach the team directly
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
We monitor calls, texts, and email throughout the business day. If
|
||||
you're sending repair photos or videos, text them to the number
|
||||
below.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<a href={businessConfig.publicCallUrl} className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Phone className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Call</p>
|
||||
<p className="mt-1 text-base font-medium text-foreground">{businessConfig.publicCallNumber}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Best for immediate questions during business hours.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`} className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Mail className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Email</p>
|
||||
<p className="mt-1 break-all text-base font-medium text-foreground">{businessConfig.email}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Good for longer requests and supporting details.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-[2rem] border-border/70 shadow-[0_18px_45px_rgba(0,0,0,0.06)]">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Clock className="h-5 w-5" />
|
||||
<div className="mt-6 space-y-4">
|
||||
<a
|
||||
href={businessConfig.publicCallUrl}
|
||||
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Phone className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">Business Hours</p>
|
||||
<h2 className="text-xl font-semibold text-foreground">When we're available</h2>
|
||||
<p className="text-sm font-semibold text-foreground">Call</p>
|
||||
<p className="mt-1 text-base font-medium text-foreground">
|
||||
{businessConfig.publicCallNumber}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Best for immediate questions during business hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
{businessHours.map((schedule) => (
|
||||
<PublicInset
|
||||
key={schedule.day}
|
||||
className={`flex items-center justify-between rounded-xl px-3 py-2 shadow-none ${
|
||||
schedule.isClosed ? "bg-muted/55 text-muted-foreground" : "bg-primary/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-foreground">{schedule.day}</span>
|
||||
<span className={schedule.isClosed ? "text-sm" : "text-sm font-semibold text-primary"}>{schedule.hours}</span>
|
||||
</PublicInset>
|
||||
))}
|
||||
<a
|
||||
href={`mailto:${businessConfig.email}?Subject=Rocky%20Mountain%20Vending%20Inquiry`}
|
||||
className="flex items-start gap-4 rounded-2xl border border-border/60 bg-white px-4 py-4 transition hover:border-primary/35"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Mail className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Email</p>
|
||||
<p className="mt-1 break-all text-base font-medium text-foreground">
|
||||
{businessConfig.email}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Good for longer requests and supporting details.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
|
||||
<PublicSurface className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
Business Hours
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
When we're available
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
{businessHours.map((schedule) => (
|
||||
<PublicInset
|
||||
key={schedule.day}
|
||||
className={`flex items-center justify-between rounded-xl px-3 py-2 shadow-none ${
|
||||
schedule.isClosed
|
||||
? "bg-muted/55 text-muted-foreground"
|
||||
: "bg-primary/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-foreground">
|
||||
{schedule.day}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
schedule.isClosed
|
||||
? "text-sm"
|
||||
: "text-sm font-semibold text-primary"
|
||||
}
|
||||
>
|
||||
{schedule.hours}
|
||||
</span>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import { Facebook, Twitter, Linkedin, Youtube } from "lucide-react"
|
||||
import {
|
||||
Facebook,
|
||||
Globe,
|
||||
Linkedin,
|
||||
Mail,
|
||||
Phone,
|
||||
Twitter,
|
||||
Youtube,
|
||||
} from "lucide-react"
|
||||
import { Separator } from "./ui/separator"
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border bg-muted/50 shadow-inner py-8">
|
||||
<div className="container mx-auto px-4 lg:px-6 py-16 md:py-28">
|
||||
<div className="grid gap-12 md:grid-cols-4">
|
||||
<footer className="border-t border-border/60 bg-[linear-gradient(180deg,rgba(254,243,224,0.62),rgba(255,248,235,0.96))] py-8">
|
||||
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 py-14 sm:px-5 md:py-20 lg:px-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.9fr_0.9fr_1fr]">
|
||||
{/* Company Info */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="rounded-[2rem] border border-border/60 bg-white/92 p-6 shadow-[0_18px_48px_rgba(15,23,42,0.07)] lg:col-span-1">
|
||||
<Link href="/" className="inline-flex">
|
||||
<Image
|
||||
src="/rmv-logo.png"
|
||||
|
|
@ -23,26 +31,32 @@ export function Footer() {
|
|||
</Link>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md leading-relaxed">
|
||||
Serving Utah businesses with free placement for qualifying
|
||||
locations, machine sales, repairs, restocking, and ongoing
|
||||
service since 2019.
|
||||
locations, machine sales, repairs, restocking, and ongoing service
|
||||
since 2019.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 text-sm text-muted-foreground mb-6">
|
||||
<a href="tel:+14352339668" className="transition-colors">
|
||||
📞 (435) 233-9668
|
||||
<a
|
||||
href="tel:+14352339668"
|
||||
className="inline-flex items-center gap-3 transition-colors hover:text-foreground"
|
||||
>
|
||||
<Phone className="h-4 w-4 text-primary" />
|
||||
<span>(435) 233-9668</span>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:info@rockymountainvending.com"
|
||||
className="transition-colors"
|
||||
className="inline-flex items-center gap-3 transition-colors hover:text-foreground"
|
||||
>
|
||||
✉️ info@rockymountainvending.com
|
||||
<Mail className="h-4 w-4 text-primary" />
|
||||
<span>info@rockymountainvending.com</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://rockymountainvending.com/"
|
||||
className="transition-colors"
|
||||
className="inline-flex items-center gap-3 transition-colors hover:text-foreground"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🌐 rockymountainvending.com
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
<span>rockymountainvending.com</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
|
|
@ -90,7 +104,7 @@ export function Footer() {
|
|||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div className="footer-section px-4 py-4">
|
||||
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
|
||||
<h3 className="font-semibold mb-5 text-base">Services</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
|
|
@ -145,7 +159,7 @@ export function Footer() {
|
|||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div className="footer-section px-4 py-4">
|
||||
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
|
||||
<h3 className="font-semibold mb-5 text-base">Company</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
|
|
@ -184,7 +198,7 @@ export function Footer() {
|
|||
</div>
|
||||
|
||||
{/* Service Areas */}
|
||||
<div className="footer-section px-4 py-4">
|
||||
<div className="footer-section rounded-[2rem] border border-border/60 bg-white/88 px-5 py-5 shadow-[0_14px_38px_rgba(15,23,42,0.06)]">
|
||||
<h3 className="font-semibold mb-5 text-base">Service Areas</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
|
|
@ -248,8 +262,8 @@ export function Footer() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mt-12 pt-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="border-t border-border/60 mt-10 pt-8">
|
||||
<div className="flex flex-col items-center justify-between gap-4 text-center text-sm text-muted-foreground md:flex-row md:text-left">
|
||||
<p>
|
||||
© {currentYear} Rocky Mountain Vending LLC. All rights reserved.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { GetFreeMachineModal } from "@/components/get-free-machine-modal"
|
|||
import { Cart } from "@/components/cart"
|
||||
import { CartButton } from "@/components/cart-button"
|
||||
import { MobileCartButton } from "@/components/mobile-cart-button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Header state types following Vercel React Best Practices
|
||||
type HeaderState = {
|
||||
|
|
@ -126,14 +127,21 @@ export function Header() {
|
|||
{ label: "FAQs", href: "/about/faqs" },
|
||||
]
|
||||
|
||||
const desktopLinkClassName =
|
||||
"rounded-full px-3 py-2 text-sm font-medium text-foreground transition hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||
const mobileLinkClassName =
|
||||
"flex min-h-11 items-center rounded-[1rem] px-4 text-sm font-medium text-foreground transition hover:bg-primary/6 hover:text-primary"
|
||||
const mobileGroupButtonClassName =
|
||||
"flex min-h-11 items-center justify-between rounded-[1rem] px-4 text-sm font-medium text-foreground transition hover:bg-primary/6 hover:text-primary"
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border/40 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/90 shadow-sm">
|
||||
<div className="w-full px-4 lg:px-6 xl:px-8 2xl:px-12">
|
||||
<div className="flex h-20 items-center justify-between gap-4 lg:gap-6">
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border/50 bg-white/92 shadow-[0_10px_35px_rgba(15,23,42,0.06)] backdrop-blur supports-[backdrop-filter]:bg-white/80">
|
||||
<div className="mx-auto w-full max-w-[var(--public-shell-max)] px-4 sm:px-5 lg:px-6">
|
||||
<div className="flex h-[var(--header-height)] items-center justify-between gap-3 lg:gap-6">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 flex-shrink-0 min-w-0"
|
||||
className="flex min-w-0 flex-shrink-0 items-center gap-2 rounded-full"
|
||||
>
|
||||
<Image
|
||||
src="/rmv-logo.png"
|
||||
|
|
@ -146,20 +154,32 @@ export function Header() {
|
|||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden items-center gap-4 lg:gap-6 md:flex flex-1 justify-center">
|
||||
<Link href="/" className="text-sm font-medium transition-colors">
|
||||
<nav className="hidden flex-1 items-center justify-center gap-1 md:flex lg:gap-2">
|
||||
<Link href="/" className={desktopLinkClassName}>
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{/* Who We Serve Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
Who We Serve
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-56 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
{whoWeServeItems.map((item) => (
|
||||
<DropdownMenuItem key={item.href} asChild>
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
|
@ -168,13 +188,25 @@ export function Header() {
|
|||
|
||||
{/* Vending Machines Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
Vending Machines
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-64 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
{vendingMachinesItems.map((item) => (
|
||||
<DropdownMenuItem key={item.href} asChild>
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
|
@ -183,13 +215,25 @@ export function Header() {
|
|||
|
||||
{/* Food and Beverage Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand whitespace-nowrap outline-none data-[state=open]:text-primary">
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 whitespace-nowrap data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
Food & Beverage
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-64 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
{foodBeverageItems.map((item) => (
|
||||
<DropdownMenuItem key={item.href} asChild>
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
|
@ -198,13 +242,25 @@ export function Header() {
|
|||
|
||||
{/* Services Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
Services
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-72 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
{servicesItems.map((item) => (
|
||||
<DropdownMenuItem key={item.href} asChild>
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
|
@ -213,13 +269,25 @@ export function Header() {
|
|||
|
||||
{/* Blog Posts Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
Blog Posts
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-56 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
{blogPostsItems.map((item) => (
|
||||
<DropdownMenuItem key={item.href} asChild>
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
|
@ -228,55 +296,58 @@ export function Header() {
|
|||
|
||||
{/* About Us Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm font-medium hover-brand outline-none data-[state=open]:text-primary">
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
desktopLinkClassName,
|
||||
"gap-1.5 data-[state=open]:text-primary"
|
||||
)}
|
||||
>
|
||||
About Us
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-56 rounded-[1.25rem] border-border/60 p-2 shadow-[0_22px_55px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
{aboutItems.map((item) => (
|
||||
<DropdownMenuItem key={item.href} asChild>
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
asChild
|
||||
className="rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Link
|
||||
href="/products"
|
||||
className="text-sm font-medium transition-colors"
|
||||
>
|
||||
<Link href="/products" className={desktopLinkClassName}>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
href="/service-areas"
|
||||
className="text-sm font-medium transition-colors"
|
||||
>
|
||||
<Link href="/service-areas" className={desktopLinkClassName}>
|
||||
Service Areas
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact-us"
|
||||
className="text-sm font-medium transition-colors"
|
||||
>
|
||||
<Link href="/contact-us" className={desktopLinkClassName}>
|
||||
Contact Us
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Desktop CTA */}
|
||||
<div className="hidden items-center gap-2 lg:gap-3 md:flex flex-shrink-0">
|
||||
<div className="hidden flex-shrink-0 items-center gap-2 md:flex lg:gap-3">
|
||||
<CartButton
|
||||
onClick={() => dispatch({ type: "SET_CART", value: true })}
|
||||
/>
|
||||
<a
|
||||
href="tel:+14352339668"
|
||||
className="flex items-center gap-2 text-sm font-medium transition-colors whitespace-nowrap"
|
||||
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-border/60 bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden lg:inline">(435) 233-9668</span>
|
||||
</a>
|
||||
<Button
|
||||
onClick={() => dispatch({ type: "SET_MODAL", value: true })}
|
||||
className="bg-primary hover:bg-primary/90 whitespace-nowrap"
|
||||
size="sm"
|
||||
className="h-11 whitespace-nowrap rounded-full bg-primary px-5 hover:bg-primary/90"
|
||||
size="lg"
|
||||
>
|
||||
Get Free Machine
|
||||
</Button>
|
||||
|
|
@ -284,9 +355,10 @@ export function Header() {
|
|||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-white text-foreground transition hover:border-primary/35 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 md:hidden"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={state.isMenuOpen}
|
||||
>
|
||||
{state.isMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
|
|
@ -298,246 +370,252 @@ export function Header() {
|
|||
|
||||
{/* Mobile Navigation */}
|
||||
{state.isMenuOpen && (
|
||||
<nav className="flex flex-col gap-5 py-6 md:hidden border-t border-border/40">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-medium py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<nav className="border-t border-border/40 py-5 md:hidden">
|
||||
<div className="rounded-[1.5rem] border border-border/60 bg-white/95 p-3 shadow-[0_18px_48px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{/* Who We Serve Mobile Section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_WHO_WE_SERVE" })}
|
||||
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
|
||||
aria-label="Who We Serve menu"
|
||||
aria-expanded={state.isWhoWeServeOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Who We Serve
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isWhoWeServeOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isWhoWeServeOpen && (
|
||||
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
|
||||
{whoWeServeItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* Who We Serve Mobile Section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_WHO_WE_SERVE" })}
|
||||
className={mobileGroupButtonClassName}
|
||||
aria-label="Who We Serve menu"
|
||||
aria-expanded={state.isWhoWeServeOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Who We Serve
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isWhoWeServeOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isWhoWeServeOpen && (
|
||||
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
|
||||
{whoWeServeItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vending Machines Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_VENDING_MACHINES" })}
|
||||
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
|
||||
aria-label="Vending Machines menu"
|
||||
aria-expanded={state.isVendingMachinesOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Vending Machines
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isVendingMachinesOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isVendingMachinesOpen && (
|
||||
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
|
||||
{vendingMachinesItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* Vending Machines Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
dispatch({ type: "TOGGLE_VENDING_MACHINES" })
|
||||
}
|
||||
className={mobileGroupButtonClassName}
|
||||
aria-label="Vending Machines menu"
|
||||
aria-expanded={state.isVendingMachinesOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Vending Machines
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isVendingMachinesOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isVendingMachinesOpen && (
|
||||
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
|
||||
{vendingMachinesItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Food and Beverage Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_FOOD_BEVERAGE" })}
|
||||
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
|
||||
aria-label="Food & Beverage menu"
|
||||
aria-expanded={state.isFoodBeverageOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Food & Beverage
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isFoodBeverageOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isFoodBeverageOpen && (
|
||||
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
|
||||
{foodBeverageItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* Food and Beverage Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_FOOD_BEVERAGE" })}
|
||||
className={mobileGroupButtonClassName}
|
||||
aria-label="Food & Beverage menu"
|
||||
aria-expanded={state.isFoodBeverageOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Food & Beverage
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isFoodBeverageOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isFoodBeverageOpen && (
|
||||
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
|
||||
{foodBeverageItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Services Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_SERVICES" })}
|
||||
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
|
||||
aria-label="Services menu"
|
||||
aria-expanded={state.isServicesOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Services
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isServicesOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isServicesOpen && (
|
||||
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
|
||||
{servicesItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* Services Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_SERVICES" })}
|
||||
className={mobileGroupButtonClassName}
|
||||
aria-label="Services menu"
|
||||
aria-expanded={state.isServicesOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Services
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isServicesOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isServicesOpen && (
|
||||
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
|
||||
{servicesItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blog Posts Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_BLOG_POSTS" })}
|
||||
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
|
||||
aria-label="Blog Posts menu"
|
||||
aria-expanded={state.isBlogPostsOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Blog Posts
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isBlogPostsOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isBlogPostsOpen && (
|
||||
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
|
||||
{blogPostsItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* Blog Posts Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_BLOG_POSTS" })}
|
||||
className={mobileGroupButtonClassName}
|
||||
aria-label="Blog Posts menu"
|
||||
aria-expanded={state.isBlogPostsOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
Blog Posts
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isBlogPostsOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isBlogPostsOpen && (
|
||||
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
|
||||
{blogPostsItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* About Us Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_ABOUT" })}
|
||||
className="flex items-center justify-between text-sm font-medium py-1 hover-brand"
|
||||
aria-label="About menu"
|
||||
aria-expanded={state.isAboutOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
About Us
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isAboutOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isAboutOpen && (
|
||||
<div className="flex flex-col gap-3 pl-4 border-l-2 border-secondary/30">
|
||||
{aboutItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* About Us Mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "TOGGLE_ABOUT" })}
|
||||
className={mobileGroupButtonClassName}
|
||||
aria-label="About menu"
|
||||
aria-expanded={state.isAboutOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
About Us
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${state.isAboutOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{state.isAboutOpen && (
|
||||
<div className="flex flex-col gap-2 border-l border-border/60 pl-4">
|
||||
{aboutItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/products"
|
||||
className="text-sm font-medium py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
href="/service-areas"
|
||||
className="text-sm font-medium py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
Service Areas
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact-us"
|
||||
className="text-sm font-medium py-1 transition-colors"
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
href="/service-areas"
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
Service Areas
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact-us"
|
||||
className={mobileLinkClassName}
|
||||
onClick={() => dispatch({ type: "TOGGLE_MENU" })}
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-4 pt-4 border-t border-border/40">
|
||||
<MobileCartButton
|
||||
onClick={() => {
|
||||
dispatch({ type: "TOGGLE_MENU" })
|
||||
dispatch({ type: "SET_CART", value: true })
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href="tel:+14352339668"
|
||||
className="flex items-center gap-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
<span>(435) 233-9668</span>
|
||||
</a>
|
||||
<Button
|
||||
onClick={() => {
|
||||
dispatch({ type: "TOGGLE_MENU" })
|
||||
dispatch({ type: "SET_MODAL", value: true })
|
||||
}}
|
||||
className="bg-primary hover:bg-primary/90 w-full"
|
||||
>
|
||||
Get Free Machine
|
||||
</Button>
|
||||
<div className="mt-3 flex flex-col gap-3 border-t border-border/40 pt-4">
|
||||
<MobileCartButton
|
||||
onClick={() => {
|
||||
dispatch({ type: "TOGGLE_MENU" })
|
||||
dispatch({ type: "SET_CART", value: true })
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href="tel:+14352339668"
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border/60 bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
<span>(435) 233-9668</span>
|
||||
</a>
|
||||
<Button
|
||||
onClick={() => {
|
||||
dispatch({ type: "TOGGLE_MENU" })
|
||||
dispatch({ type: "SET_MODAL", value: true })
|
||||
}}
|
||||
className="h-11 w-full rounded-full bg-primary hover:bg-primary/90"
|
||||
>
|
||||
Get Free Machine
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,117 +1,120 @@
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import { PublicSection, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-background py-20 md:py-32">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center">
|
||||
{/* Left Content */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs font-medium w-fit"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
<PublicSection
|
||||
tone="warm"
|
||||
className="relative overflow-hidden"
|
||||
containerClassName="relative"
|
||||
>
|
||||
<div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center">
|
||||
{/* Left Content */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs font-medium w-fit"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
Serving Utah Businesses Since 2019
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance md:text-5xl lg:text-6xl">
|
||||
Free Vending Machine Placement for Utah Businesses
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
If your breakroom, waiting area, or staff space needs a better
|
||||
snack-and-drink option, we can review the location, place the right
|
||||
machine, keep it stocked, and handle service after installation.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="font-semibold">
|
||||
Free placement for qualifying business locations
|
||||
</span>
|
||||
Serving Utah Businesses Since 2019
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance md:text-5xl lg:text-6xl">
|
||||
Free Vending Machine Placement for Utah Businesses
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-muted-foreground text-pretty leading-relaxed">
|
||||
If your breakroom, waiting area, or staff space needs a better
|
||||
snack-and-drink option, we can review the location, place the
|
||||
right machine, keep it stocked, and handle service after
|
||||
installation.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="font-semibold">
|
||||
Free placement for qualifying business locations
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span>We review the location, then handle stocking and service</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span>Snack, beverage, and combo machines with cashless payment</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span>Traditional favorites and healthier product options</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary hover:bg-primary/90 text-lg h-14 px-8"
|
||||
>
|
||||
<Link href="#request-machine">See If Your Location Qualifies</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="text-lg h-14 bg-transparent"
|
||||
>
|
||||
<Link href="#how-it-works">See How It Works</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span>
|
||||
We review the location, then handle stocking and service
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span>
|
||||
Snack, beverage, and combo machines with cashless payment
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span>Traditional favorites and healthier product options</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground pt-2">
|
||||
Serving Davis, Salt Lake, and Utah counties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="aspect-square relative rounded-2xl overflow-hidden bg-muted">
|
||||
<Image
|
||||
src="/images/vending-bay-2-scaled.webp"
|
||||
alt="Modern vending machines installed at Utah business"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary hover:bg-primary/90 text-lg h-14 px-8"
|
||||
>
|
||||
<Link href="#request-machine">
|
||||
See If Your Location Qualifies
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="text-lg h-14 bg-transparent"
|
||||
>
|
||||
<Link href="#how-it-works">See How It Works</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="absolute bottom-6 left-6 right-6 md:left-auto md:right-6 md:w-64 bg-card border border-border rounded-lg p-6 shadow-lg backdrop-blur-sm">
|
||||
<CardContent className="p-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
3
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Counties Served
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-primary">6+</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Years in Business
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground pt-2">
|
||||
Serving Davis, Salt Lake, and Utah counties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[0.94] overflow-hidden rounded-[2rem] border border-border/70 bg-muted shadow-[var(--public-surface-shadow)]">
|
||||
<Image
|
||||
src="/images/vending-bay-2-scaled.webp"
|
||||
alt="Modern vending machines installed at Utah business"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PublicSurface className="absolute bottom-4 left-4 right-4 p-5 backdrop-blur-sm md:bottom-6 md:left-auto md:right-6 md:w-64">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-primary">3</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Counties Served
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-primary">6+</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Years in Business
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PublicSection>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSection,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
|
||||
const steps = [
|
||||
{
|
||||
|
|
@ -26,49 +31,47 @@ const steps = [
|
|||
|
||||
export function HowItWorksSection() {
|
||||
return (
|
||||
<section id="how-it-works" className="py-20 md:py-28">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-12 md:mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance">How It Works</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
|
||||
Getting the best vending machine supplier in Utah shouldn't be a difficult process. Your free machine could
|
||||
be delivered in as little as 7 days depending on our schedule.
|
||||
</p>
|
||||
</div>
|
||||
<PublicSection id="how-it-works">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
className="mb-12 md:mb-16"
|
||||
title="How It Works"
|
||||
description={
|
||||
"Getting the best vending machine supplier in Utah shouldn't be a difficult process. Your free machine could be delivered in as little as 7 days depending on our schedule."
|
||||
}
|
||||
/>
|
||||
|
||||
<PublicSurface className="relative p-6 md:p-8">
|
||||
<div className="relative">
|
||||
{/* Connection Line - Desktop */}
|
||||
<div className="hidden lg:block absolute top-24 left-[16.666%] right-[16.666%] h-0.5 bg-border" />
|
||||
<div className="absolute left-[16.666%] right-[16.666%] top-16 hidden h-px bg-border/80 lg:block" />
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<div className="grid gap-5 md:grid-cols-3 md:gap-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="relative">
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors h-full">
|
||||
<CardContent className="pt-8">
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-primary text-primary-foreground text-2xl font-bold">
|
||||
{step.number}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 text-xs font-medium text-primary uppercase tracking-wider">
|
||||
{step.timing}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3">{step.title}</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">{step.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<PublicInset key={index} className="relative h-full p-5 md:p-6">
|
||||
<div className="mb-5">
|
||||
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary text-lg font-bold text-primary-foreground shadow-sm md:h-16 md:w-16 md:text-2xl">
|
||||
{step.number}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-[0.18em] text-primary">
|
||||
{step.timing}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3">{step.title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Our process is as easy as 3 steps. We mentioned <span className="font-semibold text-foreground">FREE</span>,
|
||||
right?
|
||||
Our process is as easy as 3 steps. We mentioned{" "}
|
||||
<span className="font-semibold text-foreground">FREE</span>, right?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PublicSurface>
|
||||
</PublicSection>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { ArrowRight, Clock, Globe, Mail, MapPin, Phone, Wrench } from "lucide-react"
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Globe,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Wrench,
|
||||
} from "lucide-react"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import { ReviewsSection } from "@/components/reviews-section"
|
||||
import { GetFreeMachineCta } from "@/components/get-free-machine-cta"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
|
|
@ -192,16 +199,16 @@ export function LocationLandingPage({
|
|||
Vending service for businesses across {locationData.city}
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
||||
If your business is in {locationData.neighborhoods.join(", ")},
|
||||
or elsewhere in {locationData.city}, we can review the location,
|
||||
If your business is in {locationData.neighborhoods.join(", ")}, or
|
||||
elsewhere in {locationData.city}, we can review the location,
|
||||
recommend the right machine mix, and explain what service would
|
||||
look like once the machines are in place.
|
||||
</p>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
||||
We regularly work with businesses in {countyName} that want snack,
|
||||
beverage, combo, or healthier vending options without asking
|
||||
their own staff to handle stocking, service calls, or day-to-day
|
||||
machine issues.
|
||||
beverage, combo, or healthier vending options without asking their
|
||||
own staff to handle stocking, service calls, or day-to-day machine
|
||||
issues.
|
||||
</p>
|
||||
</PublicSurface>
|
||||
|
||||
|
|
@ -263,19 +270,19 @@ export function LocationLandingPage({
|
|||
cta: "View manuals and parts",
|
||||
},
|
||||
].map((service) => (
|
||||
<Card key={service.title} className="h-full">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-semibold">{service.title}</h3>
|
||||
<p className="mt-3 text-muted-foreground">{service.body}</p>
|
||||
<Link
|
||||
href={service.href}
|
||||
className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{service.cta}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PublicSurface key={service.title} className="h-full p-6">
|
||||
<h3 className="text-xl font-semibold">{service.title}</h3>
|
||||
<p className="mt-3 leading-7 text-muted-foreground">
|
||||
{service.body}
|
||||
</p>
|
||||
<Link
|
||||
href={service.href}
|
||||
className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{service.cta}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</PublicSurface>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -294,7 +301,8 @@ export function LocationLandingPage({
|
|||
<ul className="mt-6 space-y-3 text-sm text-muted-foreground">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
|
||||
Neighborhood coverage includes {locationData.neighborhoods.join(", ")}.
|
||||
Neighborhood coverage includes{" "}
|
||||
{locationData.neighborhoods.join(", ")}.
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<Globe className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
|
||||
|
|
@ -342,7 +350,10 @@ export function LocationLandingPage({
|
|||
<Phone className="mt-1 h-5 w-5 flex-shrink-0 text-primary" />
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">Call</div>
|
||||
<a href={businessConfig.phoneUrl} className="text-muted-foreground hover:underline">
|
||||
<a
|
||||
href={businessConfig.phoneUrl}
|
||||
className="text-muted-foreground hover:underline"
|
||||
>
|
||||
{businessConfig.phone}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@ export function ProductShowcaseSection() {
|
|||
const products = [
|
||||
{
|
||||
title: "Traditional Snacks & Drinks",
|
||||
description: "Popular brands your employees know and love. From chips and candy to soda and energy drinks.",
|
||||
description:
|
||||
"Popular brands your employees know and love. From chips and candy to soda and energy drinks.",
|
||||
image: "/images/traditional-snacks-utah.webp",
|
||||
alt: "Traditional snacks including M&Ms, Oreos, Reeses in vending machine",
|
||||
},
|
||||
{
|
||||
title: "Healthy Options",
|
||||
description: "Nutritious choices for health-conscious customers. Protein bars, nuts, sparkling water, and more.",
|
||||
description:
|
||||
"Nutritious choices for health-conscious customers. Protein bars, nuts, sparkling water, and more.",
|
||||
image: "/images/healthy-vending-utah.webp",
|
||||
alt: "Healthy vending options including protein bars and nutritious snacks",
|
||||
},
|
||||
|
|
@ -24,7 +26,8 @@ export function ProductShowcaseSection() {
|
|||
},
|
||||
{
|
||||
title: "Specialty Products",
|
||||
description: "Local favorites and specialty items. We can customize your machine with what your team wants.",
|
||||
description:
|
||||
"Local favorites and specialty items. We can customize your machine with what your team wants.",
|
||||
image: "/images/drink-and-snack-delivery-utah-scaled.webp",
|
||||
alt: "Specialty Bucked Up Energy drinks available for delivery",
|
||||
},
|
||||
|
|
@ -37,7 +40,7 @@ export function ProductShowcaseSection() {
|
|||
align="center"
|
||||
eyebrow="Product Mix"
|
||||
title="What we keep stocked and ready to tailor for your location."
|
||||
description="From traditional favorites to healthier options, the homepage now uses the same rounded Rocky surfaces here too instead of falling back to the older card style."
|
||||
description="From traditional favorites to healthier options, we can tailor the product mix so the machine feels right for the people using your space."
|
||||
className="mb-12"
|
||||
/>
|
||||
|
||||
|
|
@ -57,7 +60,9 @@ export function ProductShowcaseSection() {
|
|||
</div>
|
||||
<div className="p-5 md:p-6">
|
||||
<h3 className="text-xl font-semibold mb-2">{product.title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{product.description}</p>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@
|
|||
import type { ElementType, HTMLAttributes, ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type PublicSectionProps = {
|
||||
id?: string
|
||||
tone?: "default" | "muted" | "warm"
|
||||
className?: string
|
||||
containerClassName?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type PublicPageHeaderProps = {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
|
|
@ -12,6 +20,36 @@ type PublicPageHeaderProps = {
|
|||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PublicSection({
|
||||
id,
|
||||
tone = "default",
|
||||
className,
|
||||
containerClassName,
|
||||
children,
|
||||
}: PublicSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
"py-[var(--public-section-space)]",
|
||||
tone === "muted" && "bg-muted/30",
|
||||
tone === "warm" &&
|
||||
"bg-[linear-gradient(180deg,rgba(247,244,236,0.82),rgba(255,248,235,0.98))]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-[var(--public-shell-max)] px-4 sm:px-5 lg:px-6",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicPageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
|
|
@ -23,17 +61,27 @@ export function PublicPageHeader({
|
|||
const isCentered = align === "center"
|
||||
|
||||
return (
|
||||
<header className={cn("space-y-4", isCentered && "mx-auto max-w-3xl text-center", className)}>
|
||||
<header
|
||||
className={cn(
|
||||
"space-y-4 md:space-y-5",
|
||||
isCentered && "mx-auto max-w-3xl text-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{eyebrow ? (
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary/80">{eyebrow}</p>
|
||||
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.24em] text-primary/80">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground md:text-5xl">{title}</h1>
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance text-foreground sm:text-4xl md:text-5xl lg:text-[3.4rem]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-base leading-relaxed text-muted-foreground md:text-lg",
|
||||
isCentered ? "mx-auto max-w-3xl" : "max-w-3xl",
|
||||
"text-base leading-7 text-muted-foreground md:text-lg md:leading-8",
|
||||
isCentered ? "mx-auto max-w-3xl" : "max-w-3xl"
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
|
|
@ -54,8 +102,8 @@ export function PublicSurface({
|
|||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"rounded-[2rem] border border-border/70 bg-white p-5 shadow-[0_20px_48px_rgba(15,23,42,0.08)] md:p-7",
|
||||
className,
|
||||
"rounded-[var(--public-surface-radius)] border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,251,243,0.96))] p-5 shadow-[var(--public-surface-shadow)] md:p-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -72,8 +120,8 @@ export function PublicInset({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[1.5rem] border border-border/60 bg-white p-4 shadow-sm",
|
||||
className,
|
||||
"rounded-[var(--public-inset-radius)] border border-border/60 bg-white/95 p-4 shadow-[0_10px_28px_rgba(15,23,42,0.06)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -96,10 +144,14 @@ export function PublicSectionHeader({
|
|||
className,
|
||||
}: PublicSectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">{eyebrow}</p>
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-primary/80">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground md:text-[1.375rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
'use client'
|
||||
"use client"
|
||||
|
||||
import * as React from 'react'
|
||||
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '@/components/ui/carousel'
|
||||
import Image from 'next/image'
|
||||
import * as React from "react"
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
type CarouselApi,
|
||||
} from "@/components/ui/carousel"
|
||||
import Image from "next/image"
|
||||
|
||||
export function RepairsImageCarousel() {
|
||||
const [api, setApi] = React.useState<CarouselApi>()
|
||||
|
|
@ -13,7 +18,7 @@ export function RepairsImageCarousel() {
|
|||
|
||||
setCurrent(api.selectedScrollSnap())
|
||||
|
||||
api.on('select', () => {
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap())
|
||||
})
|
||||
}, [api])
|
||||
|
|
@ -33,13 +38,13 @@ export function RepairsImageCarousel() {
|
|||
setApi={setApi}
|
||||
className="w-full"
|
||||
opts={{
|
||||
align: 'start',
|
||||
align: "start",
|
||||
loop: true,
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
<CarouselItem className="md:basis-1/2">
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden shadow-lg">
|
||||
<div className="relative aspect-video overflow-hidden rounded-[1.5rem] border border-border/60 shadow-[0_16px_40px_rgba(15,23,42,0.08)]">
|
||||
<Image
|
||||
src="https://rockymountainvending.com/wp-content/uploads/2025/09/IMG_4660-scaled.jpeg"
|
||||
alt="Vending machine repair service"
|
||||
|
|
@ -50,7 +55,7 @@ export function RepairsImageCarousel() {
|
|||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem className="md:basis-1/2">
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden shadow-lg">
|
||||
<div className="relative aspect-video overflow-hidden rounded-[1.5rem] border border-border/60 shadow-[0_16px_40px_rgba(15,23,42,0.08)]">
|
||||
<Image
|
||||
src="https://rockymountainvending.com/wp-content/uploads/2025/09/IMG_4676-scaled.jpeg"
|
||||
alt="Vending machine maintenance"
|
||||
|
|
@ -64,4 +69,3 @@ export function RepairsImageCarousel() {
|
|||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function ReviewsPage() {
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<div className="public-page">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
eyebrow="Customer Reviews"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import { MapPin } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { getServiceAreaCities, serviceAreas as serviceAreasConfig } from "@/lib/seo-config"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSection,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import {
|
||||
businessConfig,
|
||||
serviceAreas as serviceAreasConfig,
|
||||
} from "@/lib/seo-config"
|
||||
import { getAllLocations } from "@/lib/location-data"
|
||||
|
||||
export function ServiceAreasSection() {
|
||||
const serviceAreas = getServiceAreaCities()
|
||||
const locations = getAllLocations()
|
||||
|
||||
// Build structured data for service areas
|
||||
|
|
@ -13,6 +21,11 @@ export function ServiceAreasSection() {
|
|||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
serviceType: "Vending Machine Services",
|
||||
provider: {
|
||||
"@type": "Organization",
|
||||
name: businessConfig.name,
|
||||
url: businessConfig.website,
|
||||
},
|
||||
areaServed: serviceAreasConfig.map((area) => ({
|
||||
"@type": "City",
|
||||
name: area.city,
|
||||
|
|
@ -22,24 +35,24 @@ export function ServiceAreasSection() {
|
|||
}
|
||||
|
||||
return (
|
||||
<section id="service-areas" className="py-16 md:py-24 bg-background">
|
||||
<PublicSection id="service-areas">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(serviceAreaStructuredData) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(serviceAreaStructuredData),
|
||||
}}
|
||||
/>
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
className="mb-12"
|
||||
eyebrow="Service Coverage"
|
||||
title="Service Areas in Utah"
|
||||
description="Proudly serving businesses across Davis, Salt Lake, and Utah Counties."
|
||||
/>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center gap-2 mb-4">
|
||||
<MapPin className="h-6 w-6 text-secondary" />
|
||||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight text-balance">Service Areas in Utah</h2>
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-pretty leading-relaxed">
|
||||
Proudly serving businesses across Davis, Salt Lake, and Utah Counties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2 items-center max-w-6xl mx-auto">
|
||||
<div className="order-2 lg:order-1 relative aspect-[926/1024] rounded-lg shadow-lg overflow-hidden">
|
||||
<PublicSurface className="grid items-center gap-6 p-5 md:p-7 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="order-2 lg:order-1 relative overflow-hidden rounded-[1.5rem] border border-border/60 bg-[radial-gradient(circle_at_top_left,rgba(196,154,52,0.16),transparent_55%),linear-gradient(180deg,rgba(247,244,236,0.72),rgba(255,255,255,0.96))]">
|
||||
<div className="relative aspect-[926/1024]">
|
||||
<Image
|
||||
src="/images/rocky-mountain-vending-service-area-926x1024.webp"
|
||||
alt="Rocky Mountain Vending service area map covering Salt Lake City, Ogden, Provo and surrounding Utah cities"
|
||||
|
|
@ -47,41 +60,62 @@ export function ServiceAreasSection() {
|
|||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-1 lg:order-2">
|
||||
<h3 className="text-2xl font-bold mb-6">Cities We Serve</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{locations.map((location) => (
|
||||
<Link
|
||||
key={location.slug}
|
||||
href={`/vending-machines-${location.slug}`}
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
<div className="h-2 w-2 rounded-full bg-secondary flex-shrink-0" />
|
||||
<span className="text-foreground group-hover:text-secondary transition-colors">
|
||||
{location.city}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="order-1 lg:order-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<MapPin className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-3">
|
||||
<p className="text-muted-foreground">
|
||||
Don't see your city? Give us a call at{" "}
|
||||
<a href="tel:+14352339668" className="font-semibold hover:underline">
|
||||
(435) 233-9668
|
||||
</a>{" "}
|
||||
to check if we can serve your location!
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||
City Coverage
|
||||
</p>
|
||||
<h3 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
Cities We Serve
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
{locations.map((location) => (
|
||||
<Link
|
||||
key={location.slug}
|
||||
href={`/vending-machines-${location.slug}`}
|
||||
className="group flex min-h-11 items-center gap-2 rounded-full border border-border/60 bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
||||
>
|
||||
<div className="h-2 w-2 rounded-full bg-primary/70 flex-shrink-0 transition group-hover:scale-110" />
|
||||
<span className="transition-colors">{location.city}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<PublicInset className="mt-6">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Don't see your city? Give us a call at{" "}
|
||||
<a
|
||||
href="tel:+14352339668"
|
||||
className="font-semibold text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
|
||||
>
|
||||
(435) 233-9668
|
||||
</a>{" "}
|
||||
to check if we can serve your location!
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="/service-areas"
|
||||
className="inline-flex items-center gap-2 hover:underline font-medium"
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
>
|
||||
View All Service Areas →
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
Ask about your location
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PublicInset>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PublicSurface>
|
||||
</PublicSection>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertCircle, Loader2, MessageSquare, Phone, SquarePen, X } from "lucide-react"
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
SquarePen,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { AssistantAvatar } from "@/components/assistant-avatar"
|
||||
import { FormInput } from "@/components/forms/form-input"
|
||||
import { FormSelect } from "@/components/forms/form-select"
|
||||
import { SmsConsentFields } from "@/components/forms/sms-consent-fields"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
getSiteChatBootstrap,
|
||||
|
|
@ -59,11 +79,13 @@ type ChatApiResponse = {
|
|||
limits?: ChatLimitStatus
|
||||
}
|
||||
|
||||
const CHAT_UNAVAILABLE_MESSAGE = "Jessica is temporarily unavailable right now. Please call or use the contact form."
|
||||
const CHAT_UNAVAILABLE_MESSAGE =
|
||||
"Jessica is temporarily unavailable right now. Please call or use the contact form."
|
||||
|
||||
const SESSION_STORAGE_KEY = "rmv-site-chat-session"
|
||||
const PROFILE_STORAGE_KEY = "rmv-site-chat-profile"
|
||||
const PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))"
|
||||
const DESKTOP_PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))"
|
||||
const MOBILE_DRAWER_MAX_HEIGHT = "min(48rem, calc(100dvh - 0.5rem))"
|
||||
const MOBILE_CHAT_MEDIA_QUERY = "(max-width: 767px)"
|
||||
|
||||
function createMessage(role: ChatRole, content: string): ChatMessage {
|
||||
|
|
@ -74,7 +96,9 @@ function createMessage(role: ChatRole, content: string): ChatMessage {
|
|||
}
|
||||
}
|
||||
|
||||
function createEmptyProfileDraft(consentSourcePage: string): ChatVisitorProfile {
|
||||
function createEmptyProfileDraft(
|
||||
consentSourcePage: string
|
||||
): ChatVisitorProfile {
|
||||
return {
|
||||
name: "",
|
||||
phone: "",
|
||||
|
|
@ -90,26 +114,43 @@ function createEmptyProfileDraft(consentSourcePage: string): ChatVisitorProfile
|
|||
|
||||
function normalizeProfile(
|
||||
value: Partial<ChatVisitorProfile> | null | undefined,
|
||||
fallbackSourcePage: string,
|
||||
fallbackSourcePage: string
|
||||
): ChatVisitorProfile | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = {
|
||||
name: String(value.name || "").replace(/\s+/g, " ").trim().slice(0, 80),
|
||||
phone: String(value.phone || "").replace(/\s+/g, " ").trim().slice(0, 40),
|
||||
email: String(value.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(),
|
||||
intent: String(value.intent || "").replace(/\s+/g, " ").trim().slice(0, 80),
|
||||
name: String(value.name || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80),
|
||||
phone: String(value.phone || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 40),
|
||||
email: String(value.email || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
.toLowerCase(),
|
||||
intent: String(value.intent || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80),
|
||||
serviceTextConsent: Boolean(value.serviceTextConsent),
|
||||
marketingTextConsent: Boolean(value.marketingTextConsent),
|
||||
consentVersion: String(value.consentVersion || SMS_CONSENT_VERSION).trim() || SMS_CONSENT_VERSION,
|
||||
consentVersion:
|
||||
String(value.consentVersion || SMS_CONSENT_VERSION).trim() ||
|
||||
SMS_CONSENT_VERSION,
|
||||
consentCapturedAt:
|
||||
typeof value.consentCapturedAt === "string" && value.consentCapturedAt.trim()
|
||||
typeof value.consentCapturedAt === "string" &&
|
||||
value.consentCapturedAt.trim()
|
||||
? value.consentCapturedAt
|
||||
: new Date().toISOString(),
|
||||
consentSourcePage:
|
||||
typeof value.consentSourcePage === "string" && value.consentSourcePage.trim()
|
||||
typeof value.consentSourcePage === "string" &&
|
||||
value.consentSourcePage.trim()
|
||||
? value.consentSourcePage.trim()
|
||||
: fallbackSourcePage,
|
||||
}
|
||||
|
|
@ -136,7 +177,10 @@ function normalizeProfile(
|
|||
function createIntroMessage(profile: ChatVisitorProfile) {
|
||||
const firstName = profile.name.split(" ")[0] || profile.name
|
||||
const intentLabel = profile.intent.toLowerCase()
|
||||
return createMessage("assistant", `Hi ${firstName}, I've got your ${intentLabel} request. How can I help?`)
|
||||
return createMessage(
|
||||
"assistant",
|
||||
`Hi ${firstName}, I've got your ${intentLabel} request. How can I help?`
|
||||
)
|
||||
}
|
||||
|
||||
function SupportHint({
|
||||
|
|
@ -156,10 +200,13 @@ function SupportHint({
|
|||
return (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
Ready to get started? Use{" "}
|
||||
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
||||
<Link
|
||||
href={formHref}
|
||||
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
|
||||
>
|
||||
{formLabel}
|
||||
</Link>
|
||||
{" "}and we'll help you plan the right setup.
|
||||
</Link>{" "}
|
||||
and we'll help you plan the right setup.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -168,11 +215,17 @@ function SupportHint({
|
|||
return (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
For repairs or moving, text photos or videos to{" "}
|
||||
<a href={smsUrl} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
||||
<a
|
||||
href={smsUrl}
|
||||
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
|
||||
>
|
||||
{smsNumber}
|
||||
</a>{" "}
|
||||
or use the{" "}
|
||||
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
||||
<Link
|
||||
href={formHref}
|
||||
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
|
||||
>
|
||||
{formLabel}
|
||||
</Link>
|
||||
.
|
||||
|
|
@ -183,10 +236,13 @@ function SupportHint({
|
|||
return (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
Need more help? Use{" "}
|
||||
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
||||
<Link
|
||||
href={formHref}
|
||||
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
|
||||
>
|
||||
{formLabel}
|
||||
</Link>
|
||||
{" "}and our team will follow up.
|
||||
</Link>{" "}
|
||||
and our team will follow up.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -195,14 +251,17 @@ export function SiteChatWidget() {
|
|||
const pathname = usePathname()
|
||||
const bootstrap = useMemo(() => getSiteChatBootstrap(), [])
|
||||
const intentOptions = useMemo(
|
||||
() => CHAT_INTENT_OPTIONS.map((option) => ({ label: option, value: option })),
|
||||
[],
|
||||
() =>
|
||||
CHAT_INTENT_OPTIONS.map((option) => ({ label: option, value: option })),
|
||||
[]
|
||||
)
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [draft, setDraft] = useState("")
|
||||
const [profileDraft, setProfileDraft] = useState<ChatVisitorProfile>(() => createEmptyProfileDraft(pathname || "/"))
|
||||
const [profileDraft, setProfileDraft] = useState<ChatVisitorProfile>(() =>
|
||||
createEmptyProfileDraft(pathname || "/")
|
||||
)
|
||||
const [profile, setProfile] = useState<ChatVisitorProfile | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [profileError, setProfileError] = useState<string | null>(null)
|
||||
|
|
@ -219,14 +278,15 @@ export function SiteChatWidget() {
|
|||
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", handleViewportChange)
|
||||
return () => mediaQuery.removeEventListener("change", handleViewportChange)
|
||||
return () =>
|
||||
mediaQuery.removeEventListener("change", handleViewportChange)
|
||||
}
|
||||
|
||||
mediaQuery.addListener(handleViewportChange)
|
||||
return () => mediaQuery.removeListener(handleViewportChange)
|
||||
}, [])
|
||||
|
||||
const isSuppressed = isSiteChatSuppressedRoute(pathname) || isMobileViewport
|
||||
const isSuppressed = isSiteChatSuppressedRoute(pathname)
|
||||
|
||||
useEffect(() => {
|
||||
const storedSessionId = window.localStorage.getItem(SESSION_STORAGE_KEY)
|
||||
|
|
@ -241,7 +301,10 @@ export function SiteChatWidget() {
|
|||
}
|
||||
|
||||
try {
|
||||
const parsedProfile = normalizeProfile(JSON.parse(rawProfile), pathname || "/")
|
||||
const parsedProfile = normalizeProfile(
|
||||
JSON.parse(rawProfile),
|
||||
pathname || "/"
|
||||
)
|
||||
if (!parsedProfile) {
|
||||
window.localStorage.removeItem(PROFILE_STORAGE_KEY)
|
||||
setProfileDraft(createEmptyProfileDraft(pathname || "/"))
|
||||
|
|
@ -259,7 +322,7 @@ export function SiteChatWidget() {
|
|||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" })
|
||||
}, [messages.length])
|
||||
}, [messages.length, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuppressed) {
|
||||
|
|
@ -274,10 +337,10 @@ export function SiteChatWidget() {
|
|||
const formLabel = getBestIntentFormLabel(activeIntent)
|
||||
const profileDraftIsReady = Boolean(
|
||||
profileDraft.name.trim() &&
|
||||
profileDraft.phone.trim() &&
|
||||
profileDraft.email.trim() &&
|
||||
profileDraft.intent &&
|
||||
profileDraft.serviceTextConsent,
|
||||
profileDraft.phone.trim() &&
|
||||
profileDraft.email.trim() &&
|
||||
profileDraft.intent &&
|
||||
profileDraft.serviceTextConsent
|
||||
)
|
||||
|
||||
const handleProfileSubmit = useCallback(
|
||||
|
|
@ -286,15 +349,18 @@ export function SiteChatWidget() {
|
|||
const nextProfile = normalizeProfile(
|
||||
{
|
||||
...profileDraft,
|
||||
consentCapturedAt: profileDraft.consentCapturedAt || new Date().toISOString(),
|
||||
consentCapturedAt:
|
||||
profileDraft.consentCapturedAt || new Date().toISOString(),
|
||||
consentSourcePage: pathname || "/",
|
||||
consentVersion: profileDraft.consentVersion || SMS_CONSENT_VERSION,
|
||||
},
|
||||
pathname || "/",
|
||||
pathname || "/"
|
||||
)
|
||||
|
||||
if (!nextProfile) {
|
||||
setProfileError("Enter a valid name, phone, email, intent, and required service SMS consent.")
|
||||
setProfileError(
|
||||
"Enter a valid name, phone, email, intent, and required service SMS consent."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -303,9 +369,12 @@ export function SiteChatWidget() {
|
|||
setMessages([createIntroMessage(nextProfile)])
|
||||
setProfileError(null)
|
||||
setError(null)
|
||||
window.localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(nextProfile))
|
||||
window.localStorage.setItem(
|
||||
PROFILE_STORAGE_KEY,
|
||||
JSON.stringify(nextProfile)
|
||||
)
|
||||
},
|
||||
[pathname, profileDraft],
|
||||
[pathname, profileDraft]
|
||||
)
|
||||
|
||||
const handleProfileReset = useCallback(() => {
|
||||
|
|
@ -371,25 +440,389 @@ export function SiteChatWidget() {
|
|||
throw new Error(data.error || CHAT_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
|
||||
setMessages((current) => [...current, createMessage("assistant", data.reply || "")].slice(-12))
|
||||
setMessages((current) =>
|
||||
[...current, createMessage("assistant", data.reply || "")].slice(-12)
|
||||
)
|
||||
} catch (chatError) {
|
||||
const message = chatError instanceof Error ? chatError.message : CHAT_UNAVAILABLE_MESSAGE
|
||||
const message =
|
||||
chatError instanceof Error
|
||||
? chatError.message
|
||||
: CHAT_UNAVAILABLE_MESSAGE
|
||||
setError(message)
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createMessage("assistant", "I'm temporarily unavailable right now. Please call us or use the contact form."),
|
||||
].slice(-12))
|
||||
setMessages((current) =>
|
||||
[
|
||||
...current,
|
||||
createMessage(
|
||||
"assistant",
|
||||
"I'm temporarily unavailable right now. Please call us or use the contact form."
|
||||
),
|
||||
].slice(-12)
|
||||
)
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
},
|
||||
[draft, isSending, messages, pathname, profile, sessionId],
|
||||
[draft, isSending, messages, pathname, profile, sessionId]
|
||||
)
|
||||
|
||||
if (isSuppressed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderHeader = (isMobileLayout: boolean) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start justify-between border-b border-border/70 px-4 py-4",
|
||||
isMobileLayout && "px-5 pt-3"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AssistantAvatar
|
||||
src={bootstrap.avatarSrc}
|
||||
alt={bootstrap.assistantName}
|
||||
sizeClassName="h-11 w-11"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{bootstrap.assistantName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Text support for service, sales, and placement questions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-white text-foreground transition hover:border-primary/50 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderProfileGate = (isMobileLayout: boolean) => (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto px-4",
|
||||
isMobileLayout ? "pb-6 pt-4" : "py-4"
|
||||
)}
|
||||
>
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Start with your details
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
We use this to route the conversation to the right team member.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<FormInput
|
||||
id="site-chat-name"
|
||||
label="Name"
|
||||
value={profileDraft.name}
|
||||
onChange={(event) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
id="site-chat-phone"
|
||||
label="Phone"
|
||||
value={profileDraft.phone}
|
||||
onChange={(event) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
phone: event.target.value,
|
||||
}))
|
||||
}
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
type="tel"
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
id="site-chat-email"
|
||||
label="Email"
|
||||
value={profileDraft.email}
|
||||
onChange={(event) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
email: event.target.value,
|
||||
}))
|
||||
}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
<FormSelect
|
||||
id="site-chat-intent"
|
||||
label="Intent"
|
||||
value={profileDraft.intent}
|
||||
onChange={(event) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
intent: event.target.value,
|
||||
}))
|
||||
}
|
||||
options={intentOptions}
|
||||
placeholder="Choose one"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm font-medium text-foreground">Text updates</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Required service consent covers scheduling, support, repairs,
|
||||
moving, and follow-up texts for this request.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SmsConsentFields
|
||||
idPrefix="site-chat"
|
||||
mode="chat"
|
||||
serviceChecked={profileDraft.serviceTextConsent}
|
||||
onServiceChange={(checked) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
serviceTextConsent: checked,
|
||||
consentVersion: SMS_CONSENT_VERSION,
|
||||
consentSourcePage: pathname || "/",
|
||||
}))
|
||||
}
|
||||
serviceError={
|
||||
profileError && !profileDraft.serviceTextConsent
|
||||
? profileError
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profileDraftIsReady}
|
||||
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Start chat
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={bootstrap.callUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SupportHint
|
||||
formHref={formHref}
|
||||
formLabel={formLabel}
|
||||
intent={activeIntent}
|
||||
smsNumber={bootstrap.smsNumber}
|
||||
smsUrl={bootstrap.smsUrl}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{profileError ? (
|
||||
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{profileError}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderConversation = (isMobileLayout: boolean) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Chatting as{" "}
|
||||
<span className="font-medium text-foreground">{profile?.name}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProfileReset}
|
||||
className="inline-flex items-center gap-1 font-medium text-foreground transition hover:text-primary"
|
||||
>
|
||||
<SquarePen className="h-3.5 w-3.5" />
|
||||
Edit details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto px-4 py-4",
|
||||
isMobileLayout ? "pb-3" : "max-h-[22rem]"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="space-y-1">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{message.role === "assistant"
|
||||
? bootstrap.assistantName
|
||||
: profile?.name}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[92%] rounded-2xl px-3 py-2 text-sm shadow-sm",
|
||||
message.role === "assistant"
|
||||
? "bg-muted text-foreground"
|
||||
: "ml-auto bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/70 bg-white px-4 py-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<label
|
||||
htmlFor="site-chat-input"
|
||||
className="text-sm font-semibold text-foreground"
|
||||
>
|
||||
Message
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="site-chat-input"
|
||||
data-testid="site-chat-input"
|
||||
value={draft}
|
||||
onChange={(event) =>
|
||||
setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))
|
||||
}
|
||||
placeholder="Describe what you need"
|
||||
rows={isMobileLayout ? 4 : 3}
|
||||
disabled={isSending}
|
||||
className="min-h-24 w-full rounded-2xl border border-border/70 bg-white px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
|
||||
<SupportHint
|
||||
formHref={formHref}
|
||||
formLabel={formLabel}
|
||||
intent={profile?.intent || ""}
|
||||
smsNumber={bootstrap.smsNumber}
|
||||
smsUrl={bootstrap.smsUrl}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="site-chat-send"
|
||||
disabled={isSending || !draft.trim()}
|
||||
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
)}
|
||||
{isSending ? "Sending..." : "Send"}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={bootstrap.callUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderPanel = (isMobileLayout: boolean) => (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white">
|
||||
{renderHeader(isMobileLayout)}
|
||||
{!profile
|
||||
? renderProfileGate(isMobileLayout)
|
||||
: renderConversation(isMobileLayout)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isMobileViewport) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
||||
{!isOpen ? (
|
||||
<div
|
||||
className="fixed inset-x-0 z-40 flex justify-center px-4"
|
||||
style={{
|
||||
bottom: "calc(env(safe-area-inset-bottom, 0px) + 0.75rem)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="site-chat-launcher"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex min-h-14 w-full max-w-sm items-center justify-between gap-3 rounded-full border border-white/70 bg-white/96 px-4 py-3 shadow-[0_20px_60px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_24px_68px_rgba(0,0,0,0.22)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
|
||||
aria-label={`Open chat with ${bootstrap.assistantName}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-left">
|
||||
<AssistantAvatar
|
||||
src={bootstrap.avatarSrc}
|
||||
alt={bootstrap.assistantName}
|
||||
sizeClassName="h-10 w-10"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
Chat with {bootstrap.assistantName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Service, sales, repairs, and placement help
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DrawerContent
|
||||
className="max-h-[85dvh] rounded-t-[1.75rem] border-border/70 bg-white px-0"
|
||||
style={{ maxHeight: MOBILE_DRAWER_MAX_HEIGHT }}
|
||||
>
|
||||
<DrawerTitle className="sr-only">
|
||||
Chat with {bootstrap.assistantName}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
Ask about service, repairs, sales, or free placement.
|
||||
</DrawerDescription>
|
||||
{renderPanel(true)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed right-4 z-40 flex flex-col items-end gap-3"
|
||||
|
|
@ -399,233 +832,9 @@ export function SiteChatWidget() {
|
|||
<div
|
||||
data-testid="site-chat-panel"
|
||||
className="pointer-events-auto flex w-[min(24rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-[1.75rem] border border-border/70 bg-white shadow-[0_24px_80px_rgba(0,0,0,0.2)]"
|
||||
style={{ maxHeight: PANEL_MAX_HEIGHT }}
|
||||
style={{ maxHeight: DESKTOP_PANEL_MAX_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-start justify-between border-b border-border/70 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-11 w-11" />
|
||||
<div>
|
||||
<p className="text-base font-semibold text-foreground">{bootstrap.assistantName}</p>
|
||||
<p className="text-xs text-muted-foreground">Text support</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-white text-foreground transition hover:border-primary/50 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!profile ? (
|
||||
<div className="min-h-0 overflow-y-auto px-4 py-4">
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm font-medium text-foreground">Start with your details</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
We use this to route the conversation to the right team member.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<FormInput
|
||||
id="site-chat-name"
|
||||
label="Name"
|
||||
value={profileDraft.name}
|
||||
onChange={(event) => setProfileDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
id="site-chat-phone"
|
||||
label="Phone"
|
||||
value={profileDraft.phone}
|
||||
onChange={(event) => setProfileDraft((current) => ({ ...current, phone: event.target.value }))}
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
type="tel"
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
id="site-chat-email"
|
||||
label="Email"
|
||||
value={profileDraft.email}
|
||||
onChange={(event) => setProfileDraft((current) => ({ ...current, email: event.target.value }))}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
<FormSelect
|
||||
id="site-chat-intent"
|
||||
label="Intent"
|
||||
value={profileDraft.intent}
|
||||
onChange={(event) => setProfileDraft((current) => ({ ...current, intent: event.target.value }))}
|
||||
options={intentOptions}
|
||||
placeholder="Choose one"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm font-medium text-foreground">Text updates</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Required service consent covers scheduling, support, repairs, moving, and follow-up texts for this request.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SmsConsentFields
|
||||
idPrefix="site-chat"
|
||||
mode="chat"
|
||||
serviceChecked={profileDraft.serviceTextConsent}
|
||||
onServiceChange={(checked) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
serviceTextConsent: checked,
|
||||
consentVersion: SMS_CONSENT_VERSION,
|
||||
consentSourcePage: pathname || "/",
|
||||
}))
|
||||
}
|
||||
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profileDraftIsReady}
|
||||
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Start chat
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={bootstrap.callUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SupportHint
|
||||
formHref={formHref}
|
||||
formLabel={formLabel}
|
||||
intent={activeIntent}
|
||||
smsNumber={bootstrap.smsNumber}
|
||||
smsUrl={bootstrap.smsUrl}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{profileError ? (
|
||||
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{profileError}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Chatting as <span className="font-medium text-foreground">{profile.name}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProfileReset}
|
||||
className="inline-flex items-center gap-1 font-medium text-foreground transition hover:text-primary"
|
||||
>
|
||||
<SquarePen className="h-3.5 w-3.5" />
|
||||
Edit details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[22rem] overflow-y-auto px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="space-y-1">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{message.role === "assistant" ? bootstrap.assistantName : profile.name}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[92%] rounded-2xl px-3 py-2 text-sm shadow-sm",
|
||||
message.role === "assistant"
|
||||
? "bg-muted text-foreground"
|
||||
: "ml-auto bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/70 bg-white px-4 py-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<label htmlFor="site-chat-input" className="text-sm font-semibold text-foreground">
|
||||
Message
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="site-chat-input"
|
||||
data-testid="site-chat-input"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))}
|
||||
placeholder="Describe what you need"
|
||||
rows={3}
|
||||
disabled={isSending}
|
||||
className="min-h-24 w-full rounded-2xl border border-border/70 bg-white px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
|
||||
<SupportHint
|
||||
formHref={formHref}
|
||||
formLabel={formLabel}
|
||||
intent={profile.intent}
|
||||
smsNumber={bootstrap.smsNumber}
|
||||
smsUrl={bootstrap.smsUrl}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="site-chat-send"
|
||||
disabled={isSending || !draft.trim()}
|
||||
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSending ? <Loader2 className="h-4 w-4 animate-spin" /> : <MessageSquare className="h-4 w-4" />}
|
||||
{isSending ? "Sending..." : "Send"}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={bootstrap.callUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{renderPanel(false)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -637,7 +846,11 @@ export function SiteChatWidget() {
|
|||
className="pointer-events-auto inline-flex h-14 w-14 items-center justify-center rounded-full border border-white/70 bg-white shadow-[0_20px_60px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_24px_68px_rgba(0,0,0,0.22)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
|
||||
aria-label={`Open chat with ${bootstrap.assistantName}`}
|
||||
>
|
||||
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-12 w-12" />
|
||||
<AssistantAvatar
|
||||
src={bootstrap.avatarSrc}
|
||||
alt={bootstrap.assistantName}
|
||||
sizeClassName="h-12 w-12"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicSection,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
|
||||
export function StatsSection() {
|
||||
const stats = [
|
||||
|
|
@ -9,23 +13,24 @@ export function StatsSection() {
|
|||
]
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-card/30">
|
||||
<div className="container mx-auto px-4">
|
||||
<PublicSection tone="muted">
|
||||
<PublicSurface className="p-4 sm:p-5 md:p-6">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="border-0 shadow-none bg-transparent">
|
||||
<CardContent className="p-0 text-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-primary mb-2">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-sm md:text-base text-muted-foreground">
|
||||
{stat.label}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{stats.map((stat) => (
|
||||
<PublicInset
|
||||
key={stat.label}
|
||||
className="border-0 bg-transparent px-3 py-4 text-center shadow-none"
|
||||
>
|
||||
<div className="text-4xl md:text-5xl font-bold text-primary mb-2">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-sm md:text-base text-muted-foreground">
|
||||
{stat.label}
|
||||
</div>
|
||||
</PublicInset>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PublicSurface>
|
||||
</PublicSection>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function VendingMachinesPage() {
|
|||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-10 md:py-14">
|
||||
<div className="public-page">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[{ label: "Vending Machines", href: "/vending-machines" }]}
|
||||
|
|
|
|||
Loading…
Reference in a new issue