This commit is contained in:
Timo Knuth 2026-03-10 18:31:23 +01:00
parent 66225e4662
commit 4455605394
180 changed files with 9005 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
.git
.next
node_modules
npm-debug.log
Dockerfile
docker-compose.yml
README.md

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
.next
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.env
.env.local
.env.production
dist
coverage

29
Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM node:22-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm install
FROM base AS dev
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"]
FROM base AS builder
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

196
app/about/page.tsx Normal file
View File

@ -0,0 +1,196 @@
import Image from "next/image";
import Link from "next/link";
import { Breadcrumbs } from "@/components/breadcrumbs";
import { JsonLd } from "@/components/json-ld";
import { aboutHighlights, buildStory, siteConfig } from "@/data/site-content";
import { breadcrumbSchema, buildPageMetadata } from "@/lib/seo";
import { MotionSection } from "@/components/motion-section";
import { FadeUp, FadeIn, SlideIn } from "@/components/page-hero-motion";
export const metadata = buildPageMetadata({
title: "About Southern Masonry Supply",
description:
"Learn how Southern Masonry Supply has served Corpus Christi with masonry and landscaping materials since 1990.",
path: "/about",
});
export default function AboutPage() {
const breadcrumbs = [
{ name: "Home", path: "/" },
{ name: "About", path: "/about" },
];
return (
<>
<JsonLd id="about-breadcrumbs" data={breadcrumbSchema(breadcrumbs)} />
<div className="breadcrumb-strip">
<div className="container">
<Breadcrumbs items={breadcrumbs} />
</div>
</div>
<section className="page-hero">
<div className="container page-hero-shell">
<div className="page-hero-copy">
<FadeUp delay={0.05}>
<span className="eyebrow">Family owned and operated</span>
</FadeUp>
<FadeUp delay={0.15}>
<h1 style={{ marginTop: "1.25rem", marginBottom: "1.75rem" }}>
Serving Corpus Christi projects with material knowledge that lasts.
</h1>
</FadeUp>
<FadeUp delay={0.25}>
<p className="hero-copy" style={{ marginBottom: "2rem" }}>
Southern Masonry Supply has spent more than 34 years helping
contractors, homeowners, architects, and designers source the
right masonry and landscaping materials for projects large and
small.
</p>
</FadeUp>
<FadeUp delay={0.35}>
<div className="page-hero-meta">
<span>Since 1990 in South Texas</span>
<span>Family-owned: Sid Smith Jr.</span>
<span>Project-grounded recommendations</span>
</div>
</FadeUp>
</div>
<FadeIn delay={0.2} className="page-hero-visual">
<Image
src="/images/hero_about.webp"
alt="Southern Masonry Supply heritage tools"
fill
priority
sizes="(max-width: 1100px) 100vw, 40vw"
quality={72}
className="cover-image"
/>
<span className="hero-visual-note">
Built for long-horizon projects
</span>
</FadeIn>
</div>
</section>
<section className="section bg-layer overflow-hidden">
<div className="container about-story">
<div className="about-story-copy" style={{ gap: "4rem" }}>
{buildStory.map((section, idx) => (
<MotionSection key={section.title} delay={idx * 0.1} direction="up">
<article className="story-block" style={{ paddingBottom: "1rem" }}>
<span className="eyebrow">{section.eyebrow}</span>
<h2 style={{ marginTop: "0.75rem", marginBottom: "1.25rem" }}>{section.title}</h2>
<p style={{ lineHeight: 1.8 }}>{section.copy}</p>
</article>
</MotionSection>
))}
</div>
<MotionSection direction="right" className="about-story-media">
<div className="image-frame decorative">
<Image
src="/images/flagstone_pathway_garden_png_1773134755795.webp"
alt="Flagstone pathway and landscaping project"
fill
sizes="(max-width: 960px) 100vw, 40vw"
quality={70}
className="cover-image"
/>
</div>
</MotionSection>
</div>
</section>
<section className="section section-contrast">
<div className="container">
<MotionSection>
<div className="section-header align-center" style={{ marginBottom: "4rem" }}>
<span className="eyebrow">The Southern Standard</span>
<h2 style={{ marginTop: "1rem" }}>Why builders trust our yard</h2>
</div>
</MotionSection>
<div className="feature-grid">
{aboutHighlights.map((item, idx) => (
<MotionSection key={item.title} delay={idx * 0.08} direction="up">
<article className="feature-card" style={{ padding: "2.5rem 2rem" }}>
<div className="feature-icon" aria-hidden="true">
{item.icon}
</div>
<h3 style={{ marginTop: "1.25rem", marginBottom: "0.75rem" }}>{item.title}</h3>
<p style={{ lineHeight: 1.75 }}>{item.description}</p>
</article>
</MotionSection>
))}
</div>
</div>
</section>
<section className="section">
<div className="container content-panel glass-panel">
<SlideIn direction="left" className="content-panel-media">
<div className="image-frame wide elevated">
<Image
src="/images/delivery_truck_logistics_png_1773134721043.webp"
alt="Delivery truck for masonry and landscaping orders"
fill
sizes="(max-width: 960px) 100vw, 45vw"
quality={72}
className="cover-image"
/>
</div>
</SlideIn>
<SlideIn direction="right" delay={0.15} className="content-panel-copy">
<span className="eyebrow">Service that stays practical</span>
<h2 className="text-display" style={{ marginTop: "1rem", marginBottom: "1.5rem" }}>
Our mission is simple: make good material easier to source.
</h2>
<p className="text-lg" style={{ marginBottom: "1.5rem", lineHeight: 1.8 }}>
Founded in 1990 and led by Sid Smith Jr., Southern Masonry Supply
stays focused on responsive service, reliable stock levels, and
materials worth putting into long-term work. Sid's hands-on
expertise in both masonry and landscaping ensures the yard remains
the southern standard for quality and practical guidance.
</p>
<p style={{ lineHeight: 1.8, marginBottom: "2rem" }}>
Whether you are ordering flagstone by the ton, pebbles by the
bag, or masonry cement for a new project phase, our team keeps the
conversation grounded in application, quantity, and timing.
</p>
<div className="inline-actions">
<Link href="/masonry-supplies" className="button button-primary">
View masonry supplies
</Link>
<Link
href="/landscaping-supplies"
className="button button-secondary invert"
>
View landscaping supplies
</Link>
</div>
</SlideIn>
</div>
</section>
<MotionSection>
<section className="section section-tight">
<div className="container cta-panel">
<div>
<span className="eyebrow">Visit or call</span>
<h2 style={{ marginTop: "0.75rem", marginBottom: "1rem" }}>{siteConfig.address.street}</h2>
<p style={{ lineHeight: 1.7 }}>
Stop by the yard during business hours or call{" "}
{siteConfig.phoneDisplay} for material availability and delivery
planning.
</p>
</div>
<Link href="/contact" className="button button-primary">
Contact us
</Link>
</div>
</section>
</MotionSection>
</>
);
}

65
app/api/contact/route.ts Normal file
View File

@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
type ContactPayload = {
name?: string;
phone?: string;
email?: string;
projectType?: string;
materialInterest?: string;
message?: string;
};
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function isValidPhone(value: string) {
return /^[0-9()+.\-\s]{10,}$/.test(value);
}
export async function POST(request: Request) {
const payload = (await request.json()) as ContactPayload;
const fieldErrors: Record<string, string> = {};
const name = payload.name?.trim() ?? "";
const phone = payload.phone?.trim() ?? "";
const email = payload.email?.trim() ?? "";
const message = payload.message?.trim() ?? "";
if (!name) {
fieldErrors.name = "Please enter your full name.";
}
if (!phone) {
fieldErrors.phone = "Please enter a phone number.";
} else if (!isValidPhone(phone)) {
fieldErrors.phone = "Please enter a valid phone number.";
}
if (!email) {
fieldErrors.email = "Please enter an email address.";
} else if (!isValidEmail(email)) {
fieldErrors.email = "Please enter a valid email address.";
}
if (!message) {
fieldErrors.message = "Please describe your project or material request.";
}
if (Object.keys(fieldErrors).length > 0) {
return NextResponse.json(
{
success: false,
message: "Please correct the highlighted fields and try again.",
fieldErrors,
},
{ status: 400 },
);
}
return NextResponse.json({
success: true,
message:
"Thanks for reaching out. This form is wired to a placeholder API route and ready for email delivery integration.",
});
}

155
app/contact/page.tsx Normal file
View File

@ -0,0 +1,155 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { Breadcrumbs } from "@/components/breadcrumbs";
import { ContactForm } from "@/components/contact-form";
import { JsonLd } from "@/components/json-ld";
import { deliveryHighlights, siteConfig } from "@/data/site-content";
import {
breadcrumbSchema,
buildPageMetadata,
} from "@/lib/seo";
import { FadeUp, FadeIn } from "@/components/page-hero-motion";
import { MotionSection } from "@/components/motion-section";
export const metadata = buildPageMetadata({
title: "Contact Southern Masonry Supply in Corpus Christi, TX",
description:
"Reach Southern Masonry Supply at 5205 Agnes St, Corpus Christi, TX 78405 or call (361) 289-1074 during business hours.",
path: "/contact",
image: "/images/delivery_truck_logistics_png_1773134721043.png",
});
export default function ContactPage() {
const breadcrumbs = [
{ name: "Home", path: "/" },
{ name: "Contact", path: "/contact" },
];
return (
<>
<JsonLd id="contact-breadcrumbs" data={breadcrumbSchema(breadcrumbs)} />
<div className="breadcrumb-strip">
<div className="container">
<Breadcrumbs items={breadcrumbs} />
</div>
</div>
<section className="page-hero">
<div className="container page-hero-shell">
<div className="page-hero-copy">
<FadeUp delay={0.05}>
<span className="eyebrow">Contact and delivery</span>
</FadeUp>
<FadeUp delay={0.15}>
<h1 style={{ marginTop: "1.25rem", marginBottom: "1.75rem" }}>
Reach the yard, request a quote, or line up a delivery.
</h1>
</FadeUp>
<FadeUp delay={0.25}>
<p className="hero-copy" style={{ marginBottom: "2rem" }}>
Share the material, quantity, and timing you need. We will get
back with the right next step.
</p>
</FadeUp>
<FadeUp delay={0.35}>
<div className="page-hero-meta">
<span>Quotes during business hours</span>
<span>Delivery thresholds made clear upfront</span>
<span>Material-first project guidance</span>
</div>
</FadeUp>
</div>
<FadeIn delay={0.2} className="page-hero-visual">
<Image
src="/images/delivery_truck_logistics_png_1773134721043.webp"
alt="Southern Masonry Supply delivery service"
fill
priority
sizes="(max-width: 1100px) 100vw, 40vw"
quality={72}
className="cover-image"
/>
<span className="hero-visual-note">
Call first for stock and routing
</span>
</FadeIn>
</div>
</section>
<section className="section">
<div className="container contact-layout">
<div className="contact-card">
<div className="card-heading">
<span className="eyebrow">Send us a message</span>
<h2>Tell us about your project.</h2>
</div>
<Suspense fallback={<p>Loading contact form...</p>}>
<ContactForm />
</Suspense>
</div>
<div className="contact-sidebar">
<div className="map-card" style={{ padding: 0, overflow: "hidden", background: "transparent", border: "none" }}>
<div className="contact-map-embed">
<iframe
src="https://www.google.com/maps?q=5205+Agnes+St,+Corpus+Christi,+TX+78405&output=embed"
title="Southern Masonry Supply location"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
aria-label="Map showing Southern Masonry Supply at 5205 Agnes St, Corpus Christi TX"
/>
</div>
<Link
href={siteConfig.mapUrl}
target="_blank"
rel="noreferrer"
className="button button-primary"
style={{ width: "100%", marginTop: "0", borderRadius: "0 0 12px 12px" }}
>
Open in Google Maps
</Link>
</div>
<div className="details-grid">
<article className="detail-card">
<h3>Visit the yard</h3>
<p>{siteConfig.address.street}</p>
<p>{siteConfig.address.cityStateZip}</p>
<a href={siteConfig.phoneHref} className="text-link">
{siteConfig.phoneDisplay}
</a>
</article>
<article className="detail-card">
<h3>Hours</h3>
<ul className="detail-list">
{siteConfig.hours.map((item) => (
<li key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</li>
))}
</ul>
</article>
<article className="detail-card detail-card-wide">
<h3>Delivery notes</h3>
<ul className="detail-list stacked">
{deliveryHighlights.map((item) => (
<li key={item.title}>
<strong>{item.title}</strong>
<span>{item.description}</span>
</li>
))}
</ul>
</article>
</div>
</div>
</div>
</section>
</>
);
}

3149
app/globals.css Normal file

File diff suppressed because it is too large Load Diff

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

14
app/icon.svg Normal file
View File

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f8ea1" />
<rect x="10" y="45" width="44" height="5" rx="2.5" fill="#f47f20" />
<text
x="32"
y="36"
font-family="Arial Rounded MT Bold, Arial, sans-serif"
font-weight="700"
font-size="24"
fill="#ffffff"
text-anchor="middle"
letter-spacing="0.5"
>SM</text>
</svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@ -0,0 +1,121 @@
import NextImage from "next/image";
import { Breadcrumbs } from "@/components/breadcrumbs";
import { JsonLd } from "@/components/json-ld";
import { MaterialCatalog } from "@/components/material-catalog";
import {
landscapingCategory,
siteConfig,
} from "@/data/site-content";
import { breadcrumbSchema, buildPageMetadata, itemListSchema } from "@/lib/seo";
import { landscapingMaterials } from "@/data/site-content";
import { FadeUp, FadeIn } from "@/components/page-hero-motion";
export const metadata = buildPageMetadata({
title: "Landscaping Supplies in Corpus Christi, TX",
description:
"Browse flagstone, sand & gravel, boulders & stone, and Mexico Beach Pebbles from Southern Masonry Supply in Corpus Christi, TX.",
path: "/landscaping-supplies",
image: "/images/hero_landscaping.png",
});
export default function LandscapingSuppliesPage() {
const breadcrumbs = [
{ name: "Home", path: "/" },
{ name: "Landscaping Supplies", path: "/landscaping-supplies" },
];
return (
<>
<JsonLd
id="landscaping-breadcrumbs"
data={breadcrumbSchema(breadcrumbs)}
/>
<JsonLd
id="landscaping-item-list"
data={itemListSchema({
name: "Landscaping Supplies",
path: "/landscaping-supplies",
items: [
{ name: "Mexico Beach Pebbles" },
{ name: '1" - 2" Mexico Beach Pebbles Multicolor' },
{ name: "Mexico Beach Pebbles 40 lb. Bags" },
{ name: "Mexico Beach Pebbles 2 Ton Basket" },
{ name: "Sand & Gravel" },
{ name: "Flagstone" },
{ name: '1" Oklahoma' },
{ name: '1" Oklahoma Silver Mist' },
{ name: '1" Arkansas Chestnut' },
{ name: '1/2" Arkansas Blue' },
{ name: '1" Arkansas Blue' },
{ name: '2" Arkansas Blue' },
{ name: '1/2" Arkansas Brown' },
{ name: '1" Arkansas Brown' },
{ name: '2" Arkansas Brown' },
{ name: '1-1/2" Mexico White' },
{ name: '1-1/2" Mexico Creama' },
{ name: '1" Mexico Rosa Flagstone' },
{ name: '1-1/2" Mexico Rosa Flagstone' },
{ name: '1-1/2" Mexico Gray Flagstone' },
{ name: '1-1/2" Mexico Cafe Flagstone' },
{ name: "Boulders & Stone" },
],
})}
/>
<div className="breadcrumb-strip">
<div className="container">
<Breadcrumbs items={breadcrumbs} />
</div>
</div>
<section className="page-hero">
<div className="container page-hero-shell">
<div className="page-hero-copy">
<FadeUp delay={0.05}>
<span className="eyebrow">Landscaping supplies</span>
</FadeUp>
<FadeUp delay={0.15}>
<h1 style={{ marginTop: "1.25rem", marginBottom: "1.75rem" }}>
{landscapingCategory.title}
</h1>
</FadeUp>
<FadeUp delay={0.25}>
<p className="hero-copy" style={{ marginBottom: "2rem" }}>
{landscapingCategory.description}
</p>
</FadeUp>
<FadeUp delay={0.35}>
<div className="page-hero-meta">
<span>{siteConfig.cityRegion}</span>
<span>Bulk bags and by-the-yard options</span>
<span>Delivery available</span>
</div>
</FadeUp>
</div>
<FadeIn delay={0.2} className="page-hero-visual">
<NextImage
src="/images/hero_landscaping.webp"
alt="Southern Masonry Supply landscaping gravel and rocks"
fill
priority
sizes="(max-width: 1100px) 100vw, 40vw"
quality={72}
className="cover-image"
/>
<span className="hero-visual-note">
Flagstone, pebbles, aggregates, and boulders
</span>
</FadeIn>
</div>
</section>
<MaterialCatalog
heroImage={landscapingCategory.heroImage}
materials={landscapingMaterials}
intro={landscapingCategory.intro}
deliveryNote={landscapingCategory.deliveryNote}
/>
</>
);
}

73
app/layout.tsx Normal file
View File

@ -0,0 +1,73 @@
import type { Metadata } from "next";
import "./globals.css";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { ScrollReveal } from "@/components/scroll-reveal";
import { JsonLd } from "@/components/json-ld";
import {
buildMetadataBase,
businessSchema,
websiteSchema,
} from "@/lib/seo";
import { siteConfig } from "@/data/site-content";
export const metadata: Metadata = {
metadataBase: buildMetadataBase(),
title: {
default: `${siteConfig.name} | Masonry & Landscaping Supplies`,
template: `%s | ${siteConfig.name}`,
},
description: siteConfig.description,
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
title: `${siteConfig.name} | Masonry & Landscaping Supplies`,
description: siteConfig.description,
siteName: siteConfig.name,
images: [
{
url: siteConfig.defaultOgImage,
alt: siteConfig.name,
},
],
},
twitter: {
card: "summary_large_image",
title: `${siteConfig.name} | Masonry & Landscaping Supplies`,
description: siteConfig.description,
images: [siteConfig.defaultOgImage],
},
icons: {
icon: "/icon.png",
shortcut: "/icon.png",
apple: "/icon.png",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<ScrollReveal />
<JsonLd
id="global-schema"
data={{
"@context": "https://schema.org",
"@graph": [websiteSchema(), businessSchema()],
}}
/>
<div className="site-shell">
<SiteHeader />
<main>{children}</main>
<SiteFooter />
</div>
</body>
</html>
);
}

View File

@ -0,0 +1,101 @@
import NextImage from "next/image";
import { Breadcrumbs } from "@/components/breadcrumbs";
import { JsonLd } from "@/components/json-ld";
import { MaterialCatalog } from "@/components/material-catalog";
import {
masonryCategory,
siteConfig,
} from "@/data/site-content";
import { breadcrumbSchema, buildPageMetadata, itemListSchema } from "@/lib/seo";
import { masonryMaterials } from "@/data/site-content";
import { FadeUp, FadeIn } from "@/components/page-hero-motion";
export const metadata = buildPageMetadata({
title: "Masonry Supplies in Corpus Christi, TX",
description:
"Browse masonry tools and bagged cement from Southern Masonry Supply in Corpus Christi, TX.",
path: "/masonry-supplies",
image: "/images/hero_masonry.png",
});
export default function MasonrySuppliesPage() {
const breadcrumbs = [
{ name: "Home", path: "/" },
{ name: "Masonry Supplies", path: "/masonry-supplies" },
];
return (
<>
<JsonLd id="masonry-breadcrumbs" data={breadcrumbSchema(breadcrumbs)} />
<JsonLd
id="masonry-item-list"
data={itemListSchema({
name: "Masonry Supplies",
path: "/masonry-supplies",
items: [
{ name: "Masonry Tools" },
{ name: "Bagged Cement" },
{ name: "Alamo Type N Masonry Cement" },
{ name: "Alamo White Portland Cement" },
{ name: "Alamo White Masonry Cement" },
{ name: "Alamo Portland Cement Type IL" },
{ name: "Alamo Masonry Cement Type N" },
],
})}
/>
<div className="breadcrumb-strip">
<div className="container">
<Breadcrumbs items={breadcrumbs} />
</div>
</div>
<section className="page-hero">
<div className="container page-hero-shell">
<div className="page-hero-copy">
<FadeUp delay={0.05}>
<span className="eyebrow">Masonry supplies</span>
</FadeUp>
<FadeUp delay={0.15}>
<h1 style={{ marginTop: "1.25rem", marginBottom: "1.75rem" }}>{masonryCategory.title}</h1>
</FadeUp>
<FadeUp delay={0.25}>
<p className="hero-copy" style={{ marginBottom: "2rem" }}>
{masonryCategory.description}
</p>
</FadeUp>
<FadeUp delay={0.35}>
<div className="page-hero-meta">
<span>{siteConfig.cityRegion}</span>
<span>Quote-first service</span>
<span>Delivery available</span>
</div>
</FadeUp>
</div>
<FadeIn delay={0.2} className="page-hero-visual">
<NextImage
src="/images/hero_masonry.webp"
alt="Southern Masonry Supply tools and cement"
fill
priority
sizes="(max-width: 1100px) 100vw, 40vw"
quality={72}
className="cover-image"
/>
<span className="hero-visual-note">
Tools, cement, and fast follow-up quoting
</span>
</FadeIn>
</div>
</section>
<MaterialCatalog
heroImage={masonryCategory.heroImage}
materials={masonryMaterials}
intro={masonryCategory.intro}
deliveryNote={masonryCategory.deliveryNote}
/>
</>
);
}

62
app/not-found.tsx Normal file
View File

@ -0,0 +1,62 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="nf-hero">
{/* subtle brick grid overlay */}
<div className="nf-bg-grid" aria-hidden="true" />
{/* radial glow behind 404 */}
<div className="nf-glow" aria-hidden="true" />
<div className="nf-inner">
<p className="nf-eyebrow">ERROR</p>
<h1 className="nf-number" aria-label="404">404</h1>
<h2 className="nf-heading">
That page is not part of<br />the current yard layout.
</h2>
<p className="nf-sub">
Head back to the homepage, check our material catalogs,
or contact the yard we'll find what you're looking for.
</p>
<div className="nf-actions">
<Link href="/" className="button button-primary">
Back home
</Link>
<Link href="/contact" className="nf-btn-ghost">
Contact the yard
</Link>
</div>
<div className="nf-cards">
<Link href="/masonry-supplies#catalog" className="nf-card">
<span className="nf-card-label">Masonry Catalog</span>
<strong className="nf-card-title">
Brick, mortar, cement &amp; tools
</strong>
<span className="nf-card-cta">
Open masonry inventory
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
</span>
</Link>
<Link href="/landscaping-supplies#catalog" className="nf-card">
<span className="nf-card-label">Natural Stone</span>
<strong className="nf-card-title">
Flagstone, gravel &amp; decorative rock
</strong>
<span className="nf-card-cta">
Browse landscaping materials
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
</span>
</Link>
</div>
</div>
</div>
);
}

491
app/page.tsx Normal file
View File

@ -0,0 +1,491 @@
import Image from "next/image";
import Link from "next/link";
import { siteConfig, processSteps, faqs, featuredMaterials, googleReviews, homeStats } from "@/data/site-content";
import { JsonLd } from "@/components/json-ld";
import { ProcessTimeline } from "@/components/process-timeline";
import { Breadcrumbs } from "@/components/breadcrumbs";
import { buildPageMetadata, breadcrumbSchema, faqPageSchema } from "@/lib/seo";
import { TestimonialsCarousel } from "@/components/testimonials-carousel";
import { HomeCTASection } from "@/components/home-cta-section";
import { MotionSection } from "@/components/motion-section";
import { HeroCinema } from "@/components/hero-cinema";
import { CountUpStat } from "@/components/count-up-stat";
export const metadata = buildPageMetadata({
title: "South Texas's Most Trusted Masonry Supply",
description:
"Providing premium brick, stone, and landscaping materials to Corpus Christi's contractors and homeowners since 1990.",
path: "/",
});
export default function Home() {
return (
<main>
<JsonLd
id="home-breadcrumbs"
data={breadcrumbSchema([{ name: "Home", path: "/" }])}
/>
<JsonLd id="home-faq-schema" data={faqPageSchema(faqs)} />
{/* Hero Section */}
<section className="home-hero">
<div className="home-hero-left">
<div className="home-hero-copy reveal">
<span className="eyebrow" style={{ color: "var(--primary)" }}>
SINCE 1990
</span>
<h1>South Texas's Most Trusted Masonry Supply</h1>
<p>
Providing premium brick, stone, and landscaping materials to
Corpus Christi's contractors and homeowners with dependable
on-site delivery.
</p>
<div className="hero-reviews">
<div className="stars">
{[...Array(5)].map((_, i) => (
<svg
key={i}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
))}
</div>
<span>4.9 Stars (14 Google Reviews)</span>
</div>
<div className="hero-actions">
<Link href="/contact" className="button button-primary">
GET A FREE QUOTE
</Link>
<Link
href="/products"
className="button button-outline"
style={{
borderColor: "white",
color: "white",
marginLeft: "1rem",
}}
>
VIEW INVENTORY
</Link>
</div>
</div>
</div>
<div className="home-hero-visual" aria-hidden="true">
<HeroCinema />
</div>
</section>
{/* Quick Service Band */}
<div className="quick-service-band">
<div className="container">
<div className="quick-service-grid">
<div className="service-item">
<h4>Phone</h4>
<span>{siteConfig.phoneDisplay}</span>
</div>
<div className="service-item">
<h4>Address</h4>
<span>{siteConfig.address.street}</span>
</div>
<div className="service-item">
<h4>Hours</h4>
<span>Mon - Fri 8 AM - 5 PM</span>
</div>
</div>
</div>
</div>
{/* Stats Band */}
<section className="stats-band">
<div className="container">
<div className="stats-grid">
{homeStats.map((stat, i) => (
<MotionSection key={stat.label} delay={i * 0.08} direction="up">
<CountUpStat value={stat.value} label={stat.label} />
</MotionSection>
))}
</div>
</div>
</section>
{/* Product Categories */}
<section className="section bg-soft">
<div className="container">
<div
className="section-header reveal"
style={{ textAlign: "center", marginBottom: "4rem" }}
>
<span className="eyebrow">OUR PRODUCTS</span>
<h2>Premium Materials for Any Project</h2>
<p style={{ maxWidth: "600px", margin: "1rem auto" }}>
From foundation to finish, we carry the materials you need for
professional masonry and landscaping results.
</p>
</div>
<div
className="category-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "2rem",
}}
>
<div
className="category-card reveal reveal-delay-1"
style={{
background: "white",
borderRadius: "12px",
overflow: "hidden",
boxShadow: "var(--shadow)",
}}
>
<div
style={{
position: "relative",
height: "240px",
background: "#f1f3f5",
}}
>
<Image
src="/images/hero_masonry_landscaping_png_1773134515262.webp"
alt="Masonry Supplies"
fill
sizes="(max-width: 900px) 100vw, (max-width: 1200px) 50vw, 33vw"
quality={70}
className="cover-image"
/>
</div>
<div style={{ padding: "2rem" }}>
<h3 style={{ marginBottom: "1rem" }}>Masonry Supplies</h3>
<p>
Brick, concrete blocks, mortar, and lintels for structural and
aesthetic projects.
</p>
<Link
href="/masonry-supplies#catalog"
style={{ color: "var(--primary)", fontWeight: "700" }}
>
LEARN MORE
</Link>
</div>
</div>
<div
className="category-card reveal reveal-delay-2"
style={{
background: "white",
borderRadius: "12px",
overflow: "hidden",
boxShadow: "var(--shadow)",
}}
>
<div
style={{
position: "relative",
height: "240px",
background: "#f1f3f5",
}}
>
<Image
src="/images/flagstone_stack_premium_png_1773134568102.webp"
alt="Natural Stone"
fill
sizes="(max-width: 900px) 100vw, (max-width: 1200px) 50vw, 33vw"
quality={70}
className="cover-image"
/>
</div>
<div style={{ padding: "2rem" }}>
<h3 style={{ marginBottom: "1rem" }}>Natural Stone</h3>
<p>
Flagstone, limestone, and decorative rock to elevate your
landscape and architecture.
</p>
<Link
href="/landscaping-supplies#catalog"
style={{ color: "var(--primary)", fontWeight: "700" }}
>
LEARN MORE
</Link>
</div>
</div>
<div
className="category-card reveal reveal-delay-3"
style={{
background: "white",
borderRadius: "12px",
overflow: "hidden",
boxShadow: "var(--shadow)",
}}
>
<div
style={{
position: "relative",
height: "240px",
background: "#f1f3f5",
}}
>
<Image
src="/images/masonry_tools_display_png_1773134531475.webp"
alt="Tools & Materials"
fill
sizes="(max-width: 900px) 100vw, (max-width: 1200px) 50vw, 33vw"
quality={70}
className="cover-image"
/>
</div>
<div style={{ padding: "2rem" }}>
<h3 style={{ marginBottom: "1rem" }}>Tools & Materials</h3>
<p>
High-quality masonry tools, expansion joints, and sealers to
get the job done right.
</p>
<Link
href="/masonry-supplies#catalog"
style={{ color: "var(--primary)", fontWeight: "700" }}
>
LEARN MORE
</Link>
</div>
</div>
</div>
</div>
</section>
{/* Featured Products */}
<section className="section reveal">
<div className="container">
<div className="featured-header">
<div>
<span className="eyebrow">IN STOCK NOW</span>
<h2>Featured Masonry Products</h2>
</div>
<Link href="/products" className="button button-outline">
VIEW FULL INVENTORY
</Link>
</div>
<div className="featured-grid">
{featuredMaterials.slice(0, 4).map((product, i) => (
<div
key={product.slug}
className={`reveal reveal-delay-${(i % 3) + 1}`}
style={{
background: "white",
borderRadius: "8px",
overflow: "hidden",
border: "1px solid var(--border)",
}}
>
<div
style={{
position: "relative",
height: "200px",
overflow: "hidden",
background: "#f1f3f5",
}}
>
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
quality={68}
className="cover-image"
/>
</div>
<div style={{ padding: "1.5rem" }}>
<h4 style={{ fontSize: "1.125rem", marginBottom: "0.5rem" }}>
{product.name}
</h4>
<p style={{ fontSize: "0.875rem", marginBottom: "0" }}>
Available for delivery
</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Why Choose Us */}
<section className="section bg-soft">
<div className="container">
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "5rem",
alignItems: "center",
}}
>
<div>
<span className="eyebrow">THE SMS DIFFERENCE</span>
<h2>Professional Supply, Personal Service</h2>
<p style={{ fontSize: "1.125rem", marginTop: "1.5rem" }}>
We aren't just a yard; we're your project partner. With over
34 years of experience serving South Texas, we know our
materials and we know our customers.
</p>
<ul
style={{
marginTop: "2rem",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "2rem",
}}
>
<li>
<strong
style={{
display: "block",
fontSize: "1.25rem",
color: "var(--primary)",
}}
>
34+ YEARS
</strong>
Experience in the Corpus Christi area.
</li>
<li>
<strong
style={{
display: "block",
fontSize: "1.25rem",
color: "var(--primary)",
}}
>
RELIABLE DELIVERY
</strong>
Dependable site drops when you need them.
</li>
<li>
<strong
style={{
display: "block",
fontSize: "1.25rem",
color: "var(--primary)",
}}
>
OPEN TO PUBLIC
</strong>
Serving both contractors and homeowners.
</li>
<li>
<strong
style={{
display: "block",
fontSize: "1.25rem",
color: "var(--primary)",
}}
>
LOCAL EXPERTISE
</strong>
Knowledgeable staff for all project types.
</li>
</ul>
</div>
<div style={{ position: "relative" }}>
<Image
src="/images/delivery_truck_logistics_png_1773134721043.webp"
alt="Southern Masonry Supply Delivery Truck"
width={640}
height={640}
sizes="(max-width: 1024px) 100vw, 50vw"
quality={72}
style={{
borderRadius: "12px",
boxShadow: "var(--shadow-lg)",
width: "100%",
height: "auto",
}}
/>
<div className="hero-visual-note">RELIABLE ON-SITE DELIVERY</div>
</div>
</div>
</div>
</section>
{/* Process Section */}
<section className="section reveal">
<div className="container">
<div
className="section-header"
style={{ textAlign: "center", marginBottom: "4rem" }}
>
<span className="eyebrow">OUR PROCESS</span>
<h2>How to Get Your Materials</h2>
</div>
<ProcessTimeline steps={processSteps} />
</div>
</section>
{/* Testimonials */}
<section className="section bg-soft">
<div className="container">
<MotionSection>
<div className="section-header" style={{ textAlign: "center", marginBottom: "3rem" }}>
<span className="eyebrow">WHAT CUSTOMERS SAY</span>
<h2>Trusted by Corpus Christi</h2>
</div>
</MotionSection>
</div>
<TestimonialsCarousel reviews={googleReviews} />
</section>
{/* FAQ Section */}
<section className="section reveal">
<div className="container">
<div
className="section-header"
style={{ textAlign: "center", marginBottom: "4rem" }}
>
<span className="eyebrow">COMMON QUESTIONS</span>
<h2>Frequently Asked Questions</h2>
</div>
<div
style={{
maxWidth: "800px",
margin: "0 auto",
display: "grid",
gap: "1rem",
}}
>
{faqs.map((faq) => (
<details
key={faq.question}
className="faq-item"
style={{
background: "white",
padding: "1.5rem",
borderRadius: "8px",
border: "1px solid var(--border)",
}}
>
<summary
style={{
fontWeight: "700",
cursor: "pointer",
listStyle: "none",
}}
>
{faq.question}
</summary>
<p style={{ marginTop: "1rem", color: "var(--text-muted)" }}>
{faq.answer}
</p>
</details>
))}
</div>
</div>
</section>
{/* Call to Action with inline form */}
<HomeCTASection />
</main>
);
}

169
app/products/page.tsx Normal file
View File

@ -0,0 +1,169 @@
import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Breadcrumbs } from "@/components/breadcrumbs";
import { JsonLd } from "@/components/json-ld";
import { breadcrumbSchema, buildPageMetadata, itemListSchema } from "@/lib/seo";
export const metadata = buildPageMetadata({
title: "Masonry & Landscaping Supplies in Corpus Christi, TX",
description:
"Browse masonry supplies and landscaping supplies from Southern Masonry Supply in Corpus Christi, TX.",
path: "/products",
image: "/images/hero_main.png",
});
type ProductsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
function firstValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
export default async function ProductsPage({
searchParams,
}: ProductsPageProps) {
const params = await searchParams;
const category = firstValue(params.category)?.toLowerCase();
if (category === "masonry" || category === "tools") {
redirect("/masonry-supplies#catalog");
}
if (
category === "stone" ||
category === "natural-stone" ||
category === "landscaping"
) {
redirect("/landscaping-supplies#catalog");
}
const breadcrumbs = [
{ name: "Home", path: "/" },
{ name: "Products", path: "/products" },
];
return (
<>
<JsonLd id="products-breadcrumbs" data={breadcrumbSchema(breadcrumbs)} />
<JsonLd
id="products-item-list"
data={itemListSchema({
name: "Masonry & Landscaping Supplies",
path: "/products",
items: [
{ name: "Masonry Supplies", path: "/masonry-supplies" },
{ name: "Landscaping Supplies", path: "/landscaping-supplies" },
{ name: "Contact Us", path: "/contact" },
],
})}
/>
<div className="breadcrumb-strip">
<div className="container">
<Breadcrumbs items={breadcrumbs} />
</div>
</div>
<section className="section bg-soft">
<div className="container">
<div className="inventory-overview-header">
<span className="eyebrow">Masonry & Landscaping Supplies</span>
<h1>Masonry &amp; Landscaping Supplies</h1>
<p>
Southern Masonry Supply offers masonry supplies and landscaping
supplies in Corpus Christi, TX and surrounding cities. Browse the
catalog that matches your project and get in touch for delivery
information.
</p>
</div>
<div className="inventory-overview-grid">
<article className="inventory-overview-card">
<div className="inventory-overview-media">
<Image
src="/images/hero_masonry.webp"
alt="Masonry supplies and tools"
fill
sizes="(max-width: 900px) 100vw, 50vw"
quality={72}
className="cover-image"
/>
</div>
<div className="inventory-overview-copy">
<span className="not-found-card-label">Masonry supplies</span>
<h2>Masonry tools and bagged cement.</h2>
<p>
Browse masonry tools and bagged cement from Southern Masonry
Supply.
</p>
<div
className="inventory-chip-list"
aria-label="Masonry highlights"
>
<span className="inventory-chip">Masonry Tools</span>
<span className="inventory-chip">Bagged Cement</span>
<span className="inventory-chip">Get in Touch</span>
</div>
<Link
href="/masonry-supplies#catalog"
className="button button-primary"
>
Open masonry catalog
</Link>
</div>
</article>
<article className="inventory-overview-card">
<div className="inventory-overview-media">
<Image
src="/images/hero_landscaping.webp"
alt="Natural stone and landscaping materials"
fill
sizes="(max-width: 900px) 100vw, 50vw"
quality={72}
className="cover-image"
/>
</div>
<div className="inventory-overview-copy">
<span className="not-found-card-label">Natural stone</span>
<h2>Flagstone, gravel, pebbles, and boulders.</h2>
<p>
Browse Mexico Beach Pebbles, Sand &amp; Gravel, Flagstone,
and Boulders &amp; Stone from Southern Masonry Supply.
</p>
<div
className="inventory-chip-list"
aria-label="Landscaping highlights"
>
<span className="inventory-chip">Mexico Beach Pebbles</span>
<span className="inventory-chip">Flagstone</span>
<span className="inventory-chip">Sand &amp; Gravel</span>
<span className="inventory-chip">Boulders &amp; Stone</span>
</div>
<Link
href="/landscaping-supplies#catalog"
className="button button-secondary"
>
Open landscaping catalog
</Link>
</div>
</article>
</div>
<div className="inventory-overview-header" style={{ marginTop: "3rem" }}>
<p>
Delivery is also available and quoted at time of purchase. For
flagstone there is a minimum of one ton, and for landscaping
aggregates there is a 3 yard minimum.
</p>
<Link href="/contact" className="button button-outline">
Contact us
</Link>
</div>
</div>
</section>
</>
);
}

12
app/robots.ts Normal file
View File

@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { buildAbsoluteUrl } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: buildAbsoluteUrl("/sitemap.xml"),
};
}

20
app/sitemap.ts Normal file
View File

@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
import { buildAbsoluteUrl } from "@/lib/seo";
const routes = [
"/",
"/about",
"/products",
"/masonry-supplies",
"/landscaping-supplies",
"/contact",
];
export default function sitemap(): MetadataRoute.Sitemap {
return routes.map((route) => ({
url: buildAbsoluteUrl(route),
lastModified: new Date(),
changeFrequency: route === "/" ? "weekly" : "monthly",
priority: route === "/" ? 1 : 0.8,
}));
}

12
components/brand-logo.tsx Normal file
View File

@ -0,0 +1,12 @@
export function BrandLogo({ className = "" }: { className?: string }) {
return (
<span className={`brand-logo-text ${className}`} aria-hidden="true">
<span className="blt-name">Southern Masonry Supply</span>
<span className="blt-tagline">
<span className="blt-line" />
<span className="blt-city">CORPUS CHRISTI, TX</span>
<span className="blt-line" />
</span>
</span>
);
}

View File

@ -0,0 +1,24 @@
import Link from "next/link";
type BreadcrumbItem = {
name: string;
path: string;
};
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
return (
<nav aria-label="Breadcrumb">
<ol className="breadcrumbs">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={item.path}>
{isLast ? <span>{item.name}</span> : <Link href={item.path}>{item.name}</Link>}
</li>
);
})}
</ol>
</nav>
);
}

176
components/contact-form.tsx Normal file
View File

@ -0,0 +1,176 @@
"use client";
import { useSearchParams } from "next/navigation";
import { FormEvent, useState } from "react";
type ContactResponse = {
success: boolean;
message: string;
fieldErrors?: Record<string, string>;
};
const projectOptions = [
{ value: "", label: "Select a project type" },
{ value: "masonry-supplies", label: "Masonry supplies" },
{ value: "landscaping-supplies", label: "Landscaping supplies" },
{ value: "delivery-quote", label: "Delivery quote" },
{ value: "bulk-order", label: "Bulk order" },
{ value: "general-question", label: "General question" },
];
export function ContactForm() {
const searchParams = useSearchParams();
const materialInterest = searchParams.get("material") ?? "";
const [formData, setFormData] = useState({
name: "",
phone: "",
email: "",
projectType: "",
message: "",
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [status, setStatus] = useState<ContactResponse | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
setStatus(null);
setFieldErrors({});
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...formData,
materialInterest,
}),
});
const payload = (await response.json()) as ContactResponse;
setStatus(payload);
setFieldErrors(payload.fieldErrors ?? {});
if (payload.success) {
setFormData({
name: "",
phone: "",
email: "",
projectType: "",
message: "",
});
}
} catch {
setStatus({
success: false,
message: "Something went wrong while sending the form. Please call the yard directly.",
});
} finally {
setIsSubmitting(false);
}
}
return (
<form className="form-grid" onSubmit={handleSubmit} noValidate>
<div className="form-row">
<div className="field">
<label htmlFor="name">Full name</label>
<input
id="name"
name="name"
autoComplete="name"
value={formData.name}
onChange={(event) =>
setFormData((current) => ({ ...current, name: event.target.value }))
}
/>
{fieldErrors.name ? <span className="field-error">{fieldErrors.name}</span> : null}
</div>
<div className="field">
<label htmlFor="phone">Phone number</label>
<input
id="phone"
name="phone"
autoComplete="tel"
value={formData.phone}
onChange={(event) =>
setFormData((current) => ({ ...current, phone: event.target.value }))
}
/>
{fieldErrors.phone ? <span className="field-error">{fieldErrors.phone}</span> : null}
</div>
</div>
<div className="field">
<label htmlFor="email">Email address</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={formData.email}
onChange={(event) =>
setFormData((current) => ({ ...current, email: event.target.value }))
}
/>
{fieldErrors.email ? <span className="field-error">{fieldErrors.email}</span> : null}
</div>
<div className="field">
<label htmlFor="projectType">Project type</label>
<select
id="projectType"
name="projectType"
value={formData.projectType}
onChange={(event) =>
setFormData((current) => ({
...current,
projectType: event.target.value,
}))
}
>
{projectOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{materialInterest ? (
<div className="field">
<label htmlFor="materialInterest">Material interest</label>
<input id="materialInterest" value={materialInterest} readOnly />
</div>
) : null}
<div className="field">
<label htmlFor="message">Project details</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={(event) =>
setFormData((current) => ({ ...current, message: event.target.value }))
}
placeholder="Tell us what material you need, approximate quantity, and whether delivery is required."
/>
{fieldErrors.message ? <span className="field-error">{fieldErrors.message}</span> : null}
</div>
{status ? (
<div className={`form-status ${status.success ? "success" : "error"}`}>
{status.message}
</div>
) : null}
<button type="submit" className="button button-primary" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send message"}
</button>
</form>
);
}

View File

@ -0,0 +1,86 @@
"use client";
import { animate, useInView, useReducedMotion } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
type CountUpStatProps = {
value: string;
label: string;
};
type ParsedValue = {
prefix: string;
target: number;
suffix: string;
};
const smoothEase = [0.22, 1, 0.36, 1] as const;
function parseValue(value: string): ParsedValue | null {
const match = value.match(/^([^0-9]*)([\d,]+)(.*)$/);
if (!match) {
return null;
}
const [, prefix, rawNumber, suffix] = match;
const target = Number.parseInt(rawNumber.replace(/,/g, ""), 10);
if (Number.isNaN(target)) {
return null;
}
return { prefix, target, suffix };
}
function formatValue(parsed: ParsedValue, current: number) {
return `${parsed.prefix}${new Intl.NumberFormat("en-US").format(current)}${parsed.suffix}`;
}
export function CountUpStat({ value, label }: CountUpStatProps) {
const ref = useRef<HTMLDivElement | null>(null);
const isInView = useInView(ref, { once: true, amount: 0.45 });
const shouldReduceMotion = useReducedMotion();
const parsed = useMemo(() => parseValue(value), [value]);
const [displayValue, setDisplayValue] = useState(() =>
parsed ? formatValue(parsed, 0) : value,
);
const hasAnimated = useRef(false);
useEffect(() => {
if (!parsed) {
setDisplayValue(value);
return;
}
if (!isInView || hasAnimated.current) {
return;
}
hasAnimated.current = true;
if (shouldReduceMotion) {
setDisplayValue(formatValue(parsed, parsed.target));
return;
}
const controls = animate(0, parsed.target, {
duration: 1.4,
ease: smoothEase,
onUpdate(latest) {
setDisplayValue(formatValue(parsed, Math.round(latest)));
},
});
return () => {
controls.stop();
};
}, [isInView, parsed, shouldReduceMotion, value]);
return (
<div ref={ref} className="stat-item">
<span className="stat-value">{displayValue}</span>
<span className="stat-label">{label}</span>
</div>
);
}

130
components/hero-cinema.tsx Normal file
View File

@ -0,0 +1,130 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { siteConfig } from "@/data/site-content";
const slides = [
{
src: "/herosection/A_massive_perfectly_organized_masonry_supply_yard__delpmaspu.webp",
alt: "Masonry Supply Yard",
},
{
src: "/herosection/Closeup_cinematic_macro_shot_of_a_stack_of_premium_delpmaspu.webp",
alt: "Premium Masonry Materials",
},
{
src: "/herosection/Ultrarealistic_cinematic_wide_shot_for_a_professio_delpmaspu.webp",
alt: "Professional Masonry Project",
},
{
src: "/herosection/Wide_angle_architectural_shot_of_a_contemporary_st_delpmaspu.webp",
alt: "Contemporary Stone Architecture",
},
];
export function HeroCinema() {
const [current, setCurrent] = useState(0);
const [previous, setPrevious] = useState<number | null>(null);
const currentRef = useRef(0);
const clearPreviousTimerRef = useRef<number | null>(null);
function transitionTo(nextIndex: number) {
if (nextIndex === currentRef.current) {
return;
}
setPrevious(currentRef.current);
setCurrent(nextIndex);
currentRef.current = nextIndex;
if (clearPreviousTimerRef.current) {
window.clearTimeout(clearPreviousTimerRef.current);
}
clearPreviousTimerRef.current = window.setTimeout(() => {
setPrevious(null);
clearPreviousTimerRef.current = null;
}, 1400);
}
useEffect(() => {
const timer = window.setInterval(() => {
transitionTo((currentRef.current + 1) % slides.length);
}, 4500);
return () => {
window.clearInterval(timer);
if (clearPreviousTimerRef.current) {
window.clearTimeout(clearPreviousTimerRef.current);
}
};
}, []);
const renderedSlides =
previous === null
? [current]
: [previous, current].filter(
(index, position, values) => values.indexOf(index) === position,
);
return (
<div className="hc-root">
{renderedSlides.map((index) => {
const slide = slides[index];
const isCurrent = index === current;
return (
<div
key={`${index}-${isCurrent ? "current" : "previous"}`}
className="hc-slide-full"
style={{
opacity: isCurrent ? 1 : 0,
transition: "opacity 1.4s ease",
zIndex: isCurrent ? 2 : 1,
}}
>
<Image
src={slide.src}
alt={slide.alt}
fill
sizes="(max-width: 1100px) 100vw, 50vw"
quality={72}
className="cover-image"
priority={isCurrent && index === 0}
/>
</div>
);
})}
<div className="hc-overlay" />
<div className="hc-dots">
{slides.map((_, i) => (
<button
key={i}
className={`hc-dot${i === current ? " hc-dot-active" : ""}`}
onClick={() => transitionTo(i)}
aria-label={`Show image ${i + 1}`}
/>
))}
</div>
<div className="hc-video-card">
<video
className="hc-video-small"
autoPlay
muted
loop
playsInline
poster={siteConfig.heroMedia.featureCardImage}
aria-label={siteConfig.heroMedia.featureCardAlt}
>
<source src={siteConfig.heroMedia.featureCardVideo} type="video/mp4" />
</video>
<div className="hc-video-card-badge">LIVE FROM THE YARD</div>
</div>
</div>
);
}

View File

@ -0,0 +1,136 @@
"use client";
import { FormEvent, useState } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import { siteConfig } from "@/data/site-content";
type Status = { success: boolean; message: string } | null;
export function HomeCTASection() {
const [formData, setFormData] = useState({ name: "", phone: "", message: "" });
const [status, setStatus] = useState<Status>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitting(true);
setStatus(null);
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...formData, email: "", projectType: "general-question" }),
});
const data = await res.json() as { success: boolean; message: string };
setStatus(data);
if (data.success) setFormData({ name: "", phone: "", message: "" });
} catch {
setStatus({ success: false, message: "Something went wrong. Please call us directly." });
} finally {
setSubmitting(false);
}
}
return (
<section className="home-cta-section">
<div className="container">
<div className="home-cta-grid">
{/* Left — copy */}
<motion.div
className="home-cta-copy"
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
viewport={{ once: true, amount: 0.3 }}
>
<span className="eyebrow" style={{ color: "var(--primary)" }}>GET STARTED</span>
<h2>Ready to Start Your Project?</h2>
<p>
Visit our yard or drop us a message. We'll get back to you with the right
materials, quantities, and delivery details.
</p>
<div className="home-cta-contact-items">
<a href={siteConfig.phoneHref} className="home-cta-contact-item">
<span className="home-cta-contact-icon">📞</span>
<span>{siteConfig.phoneDisplay}</span>
</a>
<div className="home-cta-contact-item">
<span className="home-cta-contact-icon">📍</span>
<span>{siteConfig.address.street}, {siteConfig.address.cityStateZip}</span>
</div>
<div className="home-cta-contact-item">
<span className="home-cta-contact-icon">🕐</span>
<span>Mon Fri &nbsp;8 AM 5 PM</span>
</div>
</div>
<Link href="/contact" className="button button-outline home-cta-full-link">
Full Contact Page
</Link>
</motion.div>
{/* Right — quick form */}
<motion.div
className="home-cta-form-wrap"
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
viewport={{ once: true, amount: 0.3 }}
>
<div className="home-cta-form-card">
<h3>Send a Quick Message</h3>
<p style={{ fontSize: "0.875rem", marginBottom: "1.5rem" }}>
We'll respond during business hours.
</p>
{status?.success ? (
<div className="home-cta-success">
<span></span>
<p>{status.message}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="home-cta-form" noValidate>
<div className="home-cta-row">
<input
type="text"
placeholder="Your name"
required
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
className="home-cta-input"
/>
<input
type="tel"
placeholder="Phone number"
value={formData.phone}
onChange={(e) => setFormData((p) => ({ ...p, phone: e.target.value }))}
className="home-cta-input"
/>
</div>
<textarea
placeholder="What material do you need? Approximate quantity?"
rows={4}
value={formData.message}
onChange={(e) => setFormData((p) => ({ ...p, message: e.target.value }))}
className="home-cta-input home-cta-textarea"
/>
{status && !status.success && (
<p className="home-cta-error">{status.message}</p>
)}
<button
type="submit"
className="button button-primary"
disabled={submitting}
style={{ width: "100%" }}
>
{submitting ? "Sending…" : "Send Message →"}
</button>
</form>
)}
</div>
</motion.div>
</div>
</div>
</section>
);
}

15
components/json-ld.tsx Normal file
View File

@ -0,0 +1,15 @@
export function JsonLd({
id,
data,
}: {
id: string;
data: Record<string, unknown>;
}) {
return (
<script
id={id}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}

View File

@ -0,0 +1,159 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import type { MaterialItem } from "@/data/site-content";
type MaterialCatalogProps = {
heroImage: string;
intro: string;
deliveryNote: string;
materials: MaterialItem[];
};
export function MaterialCatalog({
heroImage,
intro,
deliveryNote,
materials,
}: MaterialCatalogProps) {
const [selectedSubcategories, setSelectedSubcategories] = useState<string[]>(
[],
);
const subcategories = Array.from(
new Set(materials.map((material) => material.subcategory)),
).sort((left, right) => left.localeCompare(right));
const filteredMaterials =
selectedSubcategories.length === 0
? materials
: materials.filter((material) =>
selectedSubcategories.includes(material.subcategory),
);
function toggleFilter(subcategory: string) {
setSelectedSubcategories((current) =>
current.includes(subcategory)
? current.filter((value) => value !== subcategory)
: [...current, subcategory],
);
}
return (
<section id="catalog" className="section catalog-section">
<div className="container catalog-shell">
<aside className="catalog-sidebar" aria-label="Material filters">
<div>
<span className="eyebrow">Filter materials</span>
<h2>Sort by category</h2>
<p>{deliveryNote}</p>
</div>
<div className="filter-group">
<span className="filter-label">Subcategories</span>
<div className="filter-chips">
{subcategories.map((subcategory) => (
<button
key={subcategory}
type="button"
className={`filter-chip ${selectedSubcategories.includes(subcategory) ? "active" : ""
}`}
onClick={() => toggleFilter(subcategory)}
>
{subcategory}
</button>
))}
</div>
</div>
<button
type="button"
className="button button-secondary invert button-block"
onClick={() => setSelectedSubcategories([])}
>
Clear filters
</button>
</aside>
<div className="catalog-main">
<div className="catalog-hero">
<div className="catalog-hero-media">
<Image
src={heroImage}
alt="Material category hero"
fill
sizes="(max-width: 1024px) 100vw, 60vw"
quality={72}
className="cover-image"
/>
</div>
<div className="catalog-hero-content">
<span className="eyebrow">Project-ready inventory</span>
<h2>Materials organized for faster quoting.</h2>
<p>{intro}</p>
</div>
</div>
<div className="catalog-toolbar">
<p className="catalog-count">
Showing {filteredMaterials.length}{" "}
{filteredMaterials.length === 1 ? "material" : "materials"}
</p>
<div className="selected-filters" aria-live="polite">
{selectedSubcategories.map((subcategory) => (
<span key={subcategory} className="selected-chip">
{subcategory}
</span>
))}
</div>
</div>
{filteredMaterials.length > 0 ? (
<div className="material-grid">
{filteredMaterials.map((material) => (
<article
key={material.slug}
className="material-card material-card--catalog reveal"
>
<div className="material-card-media">
<Image
src={material.image}
alt={material.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
quality={68}
className="cover-image"
/>
</div>
<div className="material-card-content">
<span className="material-card-tag">{material.subcategory}</span>
<h3>{material.name}</h3>
<p>{material.description}</p>
<div className="material-card-meta">
<span className="unit">
{material.purchaseUnit}
</span>
<Link
href={`/contact?material=${material.slug}`}
className="text-link"
>
Request quote
</Link>
</div>
</div>
</article>
))}
</div>
) : (
<div className="catalog-empty">
<h3>No materials match that filter yet.</h3>
<p>Clear the filters to see the full inventory list again.</p>
</div>
)}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
type Props = {
children: ReactNode;
className?: string;
delay?: number;
direction?: "up" | "left" | "right" | "none";
};
const smoothEase = [0.16, 1, 0.3, 1] as const;
const variants = {
up: { hidden: { opacity: 0, y: 32 }, visible: { opacity: 1, y: 0 } },
left: { hidden: { opacity: 0, x: -32 }, visible: { opacity: 1, x: 0 } },
right: { hidden: { opacity: 0, x: 32 }, visible: { opacity: 1, x: 0 } },
none: { hidden: { opacity: 0 }, visible: { opacity: 1 } },
};
export function MotionSection({ children, className = "", delay = 0, direction = "up" }: Props) {
return (
<motion.div
className={className}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.15 }}
variants={variants[direction]}
transition={{ duration: 0.55, ease: smoothEase, delay }}
>
{children}
</motion.div>
);
}

View File

@ -0,0 +1,76 @@
"use client";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
const smoothEase = [0.16, 1, 0.3, 1] as const;
const fadeUp = (delay = 0) => ({
initial: { opacity: 0, y: 28 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.6, ease: smoothEase, delay },
});
export function PageHeroMotion({ children }: { children: ReactNode }) {
return <>{children}</>;
}
export function FadeUp({
children,
delay = 0,
className = "",
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
return (
<motion.div className={className} {...fadeUp(delay)}>
{children}
</motion.div>
);
}
export function FadeIn({
children,
delay = 0,
className = "",
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, ease: "easeOut", delay }}
>
{children}
</motion.div>
);
}
export function SlideIn({
children,
delay = 0,
direction = "left",
className = "",
}: {
children: ReactNode;
delay?: number;
direction?: "left" | "right";
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, x: direction === "left" ? -40 : 40 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.65, ease: smoothEase, delay }}
>
{children}
</motion.div>
);
}

View File

@ -0,0 +1,133 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ProcessStep } from "@/data/site-content";
type ProcessTimelineProps = {
steps: ProcessStep[];
};
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function ProcessTimeline({ steps }: ProcessTimelineProps) {
const sectionRef = useRef<HTMLDivElement | null>(null);
const stepRefs = useRef<Array<HTMLDivElement | null>>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [progress, setProgress] = useState(0);
const markers = useMemo(() => steps.map((step) => step.step), [steps]);
useEffect(() => {
function updateTimeline() {
const section = sectionRef.current;
if (!section) {
return;
}
const sectionRect = section.getBoundingClientRect();
const viewportAnchor = window.innerHeight * 0.42;
const rawProgress =
(viewportAnchor - sectionRect.top) /
Math.max(sectionRect.height - window.innerHeight * 0.3, 1);
setProgress(clamp(rawProgress, 0, 1));
let closestIndex = 0;
let closestDistance = Number.POSITIVE_INFINITY;
stepRefs.current.forEach((stepNode, index) => {
if (!stepNode) {
return;
}
const rect = stepNode.getBoundingClientRect();
const center = rect.top + rect.height / 2;
const distance = Math.abs(center - viewportAnchor);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = index;
}
});
setActiveIndex(closestIndex);
}
updateTimeline();
let frame = 0;
function onScrollOrResize() {
cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(updateTimeline);
}
window.addEventListener("scroll", onScrollOrResize, { passive: true });
window.addEventListener("resize", onScrollOrResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", onScrollOrResize);
window.removeEventListener("resize", onScrollOrResize);
};
}, []);
return (
<div
ref={sectionRef}
className="process-timeline"
style={
{
"--timeline-progress": `${progress}`,
"--timeline-step-count": `${steps.length}`,
} as CSSProperties
}
>
<div className="process-rail" aria-hidden="true">
<div className="process-rail-track" />
<div className="process-rail-fill" />
<div className="process-rail-markers">
{markers.map((marker, index) => (
<span
key={marker}
className={`process-rail-marker ${
index <= activeIndex ? "is-active" : ""
}`}
/>
))}
</div>
</div>
<div className="process-rows">
{steps.map((step, index) => {
const sideClass = index % 2 === 0 ? "is-right" : "is-left";
const isActive = index <= activeIndex;
return (
<div
key={step.step}
ref={(node) => {
stepRefs.current[index] = node;
}}
className={`process-row ${sideClass} ${
isActive ? "is-active" : ""
}`}
>
<div className="process-row-spacer" aria-hidden="true" />
<div className="process-row-pin" aria-hidden="true" />
<article className="process-step-card">
<span className="process-step-number">{step.step}</span>
<h3>{step.title}</h3>
<p>{step.description}</p>
</article>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
export function ScrollReveal() {
useEffect(() => {
const observerOptions = {
threshold: 0.1,
rootMargin: "0px 0px -50px 0px",
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("active");
}
});
}, observerOptions);
const revealElements = document.querySelectorAll(".reveal");
revealElements.forEach((el) => observer.observe(el));
return () => {
revealElements.forEach((el) => observer.unobserve(el));
};
}, []);
return null;
}

View File

@ -0,0 +1,98 @@
import Link from "next/link";
import { siteConfig } from "@/data/site-content";
import { BrandLogo } from "@/components/brand-logo";
export function SiteFooter() {
return (
<footer className="site-footer">
<div className="container">
<div className="site-footer-top">
<div className="footer-brand">
<Link href="/" className="brand" aria-label={siteConfig.name}>
<span className="brand-mark brand-mark-logo" aria-hidden="true">
<BrandLogo />
</span>
</Link>
<p className="footer-description">
South Texas's premier masonry and landscaping supply yard. Providing
dependable stock, expert advice, and professional delivery since 1990.
</p>
<div className="footer-contact-list">
<div className="footer-contact-item">
<strong>ADDRESS</strong>
<span>{siteConfig.address.street}, {siteConfig.address.cityStateZip}</span>
</div>
<div className="footer-contact-item">
<strong>PHONE</strong>
<a href={siteConfig.phoneHref}>{siteConfig.phoneDisplay}</a>
</div>
<div className="footer-contact-item">
<strong>HOURS</strong>
<span>Mon Fri &nbsp;8 AM 5 PM</span>
</div>
</div>
</div>
{siteConfig.footerGroups.map((group) => (
<div key={group.title} className="footer-group">
<h3 className="footer-group-title">{group.title}</h3>
<ul className="footer-links">
{group.links.map((link) => (
<li key={link.label}>
<Link href={link.href} className="footer-link">
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
{/* Map column */}
<div className="footer-group">
<span className="footer-group-title">Find Us</span>
<div className="footer-map-embed">
<iframe
src="https://www.google.com/maps?q=5205+Agnes+St,+Corpus+Christi,+TX+78405&output=embed"
title="Southern Masonry Supply location"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
aria-label="Map showing Southern Masonry Supply at 5205 Agnes St, Corpus Christi TX"
/>
</div>
<Link
href={siteConfig.mapUrl}
target="_blank"
rel="noreferrer"
className="footer-link"
style={{ display: "inline-block", marginTop: "0.75rem", fontSize: "0.8125rem" }}
>
Open in Google Maps
</Link>
</div>
</div>
<div className="site-footer-bottom">
<div className="footer-bottom-inner">
<div className="footer-meta">
<span>&copy; {new Date().getFullYear()} {siteConfig.name}. All Rights Reserved.</span>
</div>
<div className="footer-socials">
{siteConfig.socials.map((social) => (
<Link
key={social.label}
href={social.href}
target="_blank"
rel="noreferrer"
className="footer-link"
>
{social.label}
</Link>
))}
</div>
</div>
</div>
</div>
</footer>
);
}

100
components/site-header.tsx Normal file
View File

@ -0,0 +1,100 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { siteConfig } from "@/data/site-content";
import { BrandLogo } from "@/components/brand-logo";
export function SiteHeader() {
const [menuOpen, setMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const close = () => setMenuOpen(false);
useEffect(() => {
const handler = () => setScrolled(window.scrollY > 60);
window.addEventListener("scroll", handler, { passive: true });
return () => window.removeEventListener("scroll", handler);
}, []);
return (
<>
<div className="site-utility-bar">
<div className="container utility-inner">
<div className="utility-list">
<a href={siteConfig.phoneHref} className="utility-link">
<span className="utility-label">PHONE:</span> {siteConfig.phoneDisplay}
</a>
<span className="utility-text">LOCATION: {siteConfig.address.cityStateZip}</span>
<span className="utility-text">HOURS: Mon - Fri 8 AM - 5 PM</span>
</div>
<div className="utility-list">
<span className="utility-text">DELIVERY QUOTED AT PURCHASE</span>
<Link href={siteConfig.mapUrl} target="_blank" rel="noreferrer" className="utility-link">
OPEN MAP
</Link>
</div>
</div>
</div>
<header className={`site-header${scrolled ? " site-header--scrolled" : ""}`}>
<div className="container site-header-inner">
<Link href="/" className="brand" aria-label={siteConfig.name} onClick={close}>
<span className="brand-mark brand-mark-logo" aria-hidden="true">
<BrandLogo />
</span>
</Link>
<nav className="main-nav" aria-label="Primary">
{siteConfig.nav.map((item) => (
<Link key={item.href} href={item.href} className="nav-link">
{item.label}
</Link>
))}
</nav>
<div className="header-actions">
<Link href="/contact" className="button button-primary">
REQUEST A QUOTE
</Link>
</div>
<button
className="mobile-menu-toggle"
aria-label={menuOpen ? "Menü schließen" : "Menü öffnen"}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((o) => !o)}
>
<span className={`hamburger${menuOpen ? " hamburger--open" : ""}`}>
<span />
<span />
<span />
</span>
</button>
</div>
</header>
{/* Mobile drawer */}
<div
className={`mobile-nav${menuOpen ? " mobile-nav--open" : ""}`}
aria-hidden={!menuOpen}
aria-label="Mobile Navigation"
>
<nav className="mobile-nav-links">
{siteConfig.nav.map((item) => (
<Link key={item.href} href={item.href} className="mobile-nav-link" onClick={close}>
{item.label}
</Link>
))}
<Link href="/contact" className="button button-primary mobile-nav-cta" onClick={close}>
REQUEST A QUOTE
</Link>
</nav>
</div>
{/* Backdrop */}
{menuOpen && (
<div className="mobile-nav-overlay" onClick={close} aria-hidden="true" />
)}
</>
);
}

View File

@ -0,0 +1,76 @@
"use client";
import { motion } from "framer-motion";
type Review = {
name: string;
rating: string;
dateLabel: string;
quote: string;
};
type Props = {
reviews: Review[];
};
function StarRating({ rating }: { rating: string }) {
const score = parseFloat(rating);
const full = Math.floor(score);
return (
<div className="tc-stars" aria-label={`${rating} stars`}>
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={i < full ? "tc-star filled" : "tc-star"}>
</span>
))}
</div>
);
}
function ReviewCard({ review }: { review: Review }) {
return (
<motion.article
className="tc-card"
whileHover={{ y: -6, boxShadow: "0 20px 40px rgba(0,0,0,0.12)" }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
<div className="tc-card-top">
<StarRating rating={review.rating} />
<span className="tc-date">{review.dateLabel}</span>
</div>
<blockquote className="tc-quote">"{review.quote}"</blockquote>
<footer className="tc-author">
<div className="tc-avatar" aria-hidden="true">
{review.name[0]}
</div>
<div>
<strong className="tc-name">{review.name}</strong>
<span className="tc-source">Google Review</span>
</div>
</footer>
</motion.article>
);
}
export function TestimonialsCarousel({ reviews }: Props) {
// Triple the items for a seamless infinite loop at any scroll speed
const tripled = [...reviews, ...reviews, ...reviews];
return (
<div className="tc-wrapper">
<div className="tc-track-container">
<div className="tc-track">
{tripled.map((review, i) => (
<div className="tc-slide" key={i}>
<ReviewCard review={review} />
</div>
))}
</div>
</div>
{/* Edge fade overlays */}
<div className="tc-fade-left" aria-hidden="true" />
<div className="tc-fade-right" aria-hidden="true" />
</div>
);
}

15
content_audit.md Normal file
View File

@ -0,0 +1,15 @@
# Content Accuracy Audit: Southern Masonry Supply
## Comparison: `info.md` vs. Current Website
| Category | Source of Truth (`info.md`) | Current Website / Code | Match? |
| :--- | :--- | :--- | :--- |
| **Location** | 5205 Agnes St, Corpus Christi, TX 78405 | [TBD] | [TBD] |
| **Phone** | (361) 289-1074 | [TBD] | [TBD] |
| **Years of Service** | Since 1990 (36 years as of 2026) | [TBD] | [TBD] |
| **Service Area** | Corpus Christi, TX and surroundings | [TBD] | [TBD] |
| **Owner** | Sid Smith Jr. | [TBD] | [TBD] |
## Discrepancies Found
- [ ] Verify "Upstate South Carolina" claim.
- [ ] Verify "30+ years" claim on website.

1225
data/site-content.ts Normal file

File diff suppressed because it is too large Load Diff

9
devserver.err.log Normal file
View File

@ -0,0 +1,9 @@
Failed to start server
Error: listen EADDRINUSE: address already in use 127.0.0.1:3000
at <unknown> (Error: listen EADDRINUSE: address already in use 127.0.0.1:3000) {
code: 'EADDRINUSE',
errno: -4091,
syscall: 'listen',
address: '127.0.0.1',
port: 3000
}

BIN
devserver.out.log Normal file

Binary file not shown.

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
services:
web:
build:
context: .
target: dev
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_SITE_URL: http://localhost:3000
volumes:
- .:/app
- /app/node_modules
- /app/.next

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

331
info.md Normal file
View File

@ -0,0 +1,331 @@
Skip to content
Southern Masonry Supply
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Masonry &
Landscaping Supplies
Masonry Tools • Flagstone • Aggregates
Get in Touch
Corpus Christi's #1
Masonry Supply Store
Southern Masonry Supply is a family-owned-and-operated business that offers a large selection of masonry products and tools. For more than 30 years, we have been proudly serving clients in Corpus Christi, TX and its surrounding cities.
We take pride in providing exceptional customer service and do our best to make sure our products are always in stock for fast delivery.
What We Offer
Our products are sourced in the United States & Mexico to provide top quality materials to our clients. We offer a variety of ground cover, including decomposed granite and Mexico pebbles, along with a variety of flagstones including Arkansas and Oklahoma. Ground cover can be purchased by the shovel, by the pound, by the yard or by bulk bag. Flagstone can be purchased by the pound or ton. Delivery is also available and quoted at time of purchase.
Delivery Information
Our delivery service for flagstone has a minimum of one ton. For landscaping aggregates there is a 3 yard minimum.
The delivery charge depends on where you want your purchases delivered. If you have any questions, please do not hesitate to reach out to us.
Site Links
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Get In Touch!
5205 Agnes St,
Corpus Christi, TX 78405
(361) 289-1074
Monday-Friday 8 AM to 5 PM
Closed Saturday & Sunday
Follow Us on Facebook
© 2026 Southern Masonry Supply
GoDaddy Web Design
Apollo
Skip to content
Southern Masonry Supply
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Providing You With
Landscaping Supplies & Masonry Materials
Southern Masonry Supply has been serving clients in Corpus Christi, TX and its surrounding cities since 1990. We offer high-quality groundskeeping and masonry supplies for any building project.
As our client, we are proud to share your successful work featuring our products. We strive to exceed your expectations and make sure all your landscaping and masonry needs are met.
Our Mission
We are committed to providing building professionals and the general public with the tools, service and support they need for all their projects large or small.
What Sets Us Apart
Southern Masonry Supply is owned by Sid Smith Jr. His years of experience in masonry work have made him one of our best resources. He is also well versed in landscaping work. With his expertise, we aim to become one of the best businesses in the industry.
We are committed to offering building material products of exceptional quality to all, including landscape architects, designers, and contractors. Our dedication to excellence is reflected in the materials we supply and customer service we provide.
Site Links
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Get In Touch!
5205 Agnes St,
Corpus Christi, TX 78405
(361) 289-1074
Monday-Friday 8 AM to 5 PM
Closed Saturday & Sunday
Follow Us on Facebook
© 2026 Southern Masonry Supply
GoDaddy Web Design
Apollo
Skip to content
Southern Masonry Supply
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Masonry Tools
Bagged Cement
Alamo Type N Masonry Cement
Alamo White Portland Cement
Alamo White Masonry Cement
Alamo Portland Cement Type IL
Alamo Masonry Cement Type N
Alamo White Portland Cement
Site Links
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Get In Touch!
5205 Agnes St,
Corpus Christi, TX 78405
(361) 289-1074
Monday-Friday 8 AM to 5 PM
Closed Saturday & Sunday
Follow Us on Facebook
© 2026 Southern Masonry Supply
GoDaddy Web Design
Apollo
Skip to content
Southern Masonry Supply
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Check Out Our
Landscaping Supplies
Mexico Beach Pebbles
1" - 2" Mexico Beach Pebbles Multicolor
Mexico Beach Pebbles 40 lb. Bags
Mexico Beach Pebbles 2 Ton Basket
Sand & Gravel
Flagstone
1" Oklahoma
1" Oklahoma Silver Mist
1" Arkansas Chestnut
1/2" Arkansas Blue
1" Arkansas Blue
2" Arkansas Blue
1/2" Arkansas Brown
1" Arkansas Brown
2" Arkansas Brown
1-1/2" Mexico White
1-1/2" Mexico Creama
1" Mexico Rosa Flagstone
1-1/2" Mexico Rosa Flagstone
1-1/2" Mexico Gray Flagstone
1-1/2" Mexico Gray Flagstone
1-1/2" Mexico Cafe Flagstone
1-1/2" Mexico Cafe Flagstone
Boulders & Stone
Site Links
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Get In Touch!
5205 Agnes St,
Corpus Christi, TX 78405
(361) 289-1074
Monday-Friday 8 AM to 5 PM
Closed Saturday & Sunday
Follow Us on Facebook
© 2026 Southern Masonry Supply
GoDaddy Web Design
Skip to content
Southern Masonry Supply
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Reach Out to Us
Have questions? We have answers. Fill in your info below and one of our representatives will contact you directly.
First Name*
Last Name*
Phone Number*
Email Address*
Inquiry/Message
Site Links
Home
About
Masonry Supplies
Landscaping Supplies
Contact Us
Get In Touch!
5205 Agnes St,
Corpus Christi, TX 78405
(361) 289-1074
Monday-Friday 8 AM to 5 PM
Closed Saturday & Sunday
Follow Us on Facebook
© 2026 Southern Masonry Supply
GoDaddy Web Design
Apollo
socials
https://www.facebook.com/southernmasonrysupply/
https://www.instagram.com/southernmasonrysupply/

166
lib/seo.ts Normal file
View File

@ -0,0 +1,166 @@
import type { Metadata } from "next";
import { siteConfig } from "@/data/site-content";
type PageMetadataInput = {
title: string;
description: string;
path: string;
image?: string;
};
type FaqEntry = {
question: string;
answer: string;
};
type ItemListEntry = {
name: string;
path?: string;
};
export function buildMetadataBase() {
const rawUrl = process.env.NEXT_PUBLIC_SITE_URL || siteConfig.siteUrl;
return new URL(rawUrl.endsWith("/") ? rawUrl : `${rawUrl}/`);
}
export function buildAbsoluteUrl(path: string) {
return new URL(path, buildMetadataBase()).toString();
}
export function buildPageMetadata({
title,
description,
path,
image = siteConfig.defaultOgImage,
}: PageMetadataInput): Metadata {
return {
title,
description,
alternates: {
canonical: path,
},
openGraph: {
title,
description,
url: path,
images: [
{
url: image,
alt: title,
},
],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [image],
},
};
}
export function websiteSchema() {
return {
"@type": "WebSite",
name: siteConfig.name,
url: buildAbsoluteUrl("/"),
};
}
export function businessSchema() {
return {
"@type": "LocalBusiness",
name: siteConfig.name,
url: buildAbsoluteUrl("/"),
description: siteConfig.description,
logo: buildAbsoluteUrl(siteConfig.brand.primaryLogo),
image: buildAbsoluteUrl(siteConfig.defaultOgImage),
telephone: siteConfig.phoneDisplay,
address: {
"@type": "PostalAddress",
streetAddress: siteConfig.address.street,
addressLocality: "Corpus Christi",
addressRegion: "TX",
postalCode: "78405",
addressCountry: "US",
},
sameAs: siteConfig.socials.map((social) => social.href),
contactPoint: [
{
"@type": "ContactPoint",
telephone: siteConfig.phoneDisplay,
contactType: "customer service",
areaServed: "Corpus Christi, TX",
},
],
openingHoursSpecification: [
{
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
opens: "08:00",
closes: "17:00",
},
],
};
}
export function localBusinessSchema() {
return {
"@context": "https://schema.org",
...businessSchema(),
};
}
export function breadcrumbSchema(items: { name: string; path: string }[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: buildAbsoluteUrl(item.path),
})),
};
}
export function faqPageSchema(items: FaqEntry[]) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
};
}
export function itemListSchema({
name,
path,
items,
}: {
name: string;
path: string;
items: ItemListEntry[];
}) {
return {
"@context": "https://schema.org",
"@type": "ItemList",
name,
url: buildAbsoluteUrl(path),
numberOfItems: items.length,
itemListOrder: "https://schema.org/ItemListOrderAscending",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
url: item.path ? buildAbsoluteUrl(item.path) : undefined,
})),
};
}

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

11
next.config.ts Normal file
View File

@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
formats: ["image/avif", "image/webp"],
minimumCacheTTL: 60 * 60 * 24 * 30,
},
};
export default nextConfig;

1019
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "southern-masonry-supply",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"framer-motion": "^12.35.2",
"next": "16.1.6",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "22.13.10",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"typescript": "5.8.2"
}
}

99
plan.md Normal file
View File

@ -0,0 +1,99 @@
# Southern Masonry Supply Next.js Rebuild Plan
## Summary
Build a production-ready Next.js site in TypeScript with App Router, using the provided screenshots as structural
inspiration, info.md as content source, and public/images as the visual asset base. The v1 will ship as a 5-page
marketing/catalog site with strong local SEO, reusable content-driven sections, a placeholder-ready contact API,
and Dockerized local/dev deployment so npm run dev works immediately.
## Key Changes
- Initialize a new Next.js App Router project in TypeScript with a clean component/content structure, responsive
layout system, shared header/footer, global theme tokens, and SEO-safe metadata defaults.
- Create these routes with preserved slugs:
- /
- /about
- /masonry-supplies
- /landscaping-supplies
- /contact
- Implement a strong local-business visual direction rather than a generic template:
- warm stone/terracotta/sand palette
- editorial serif display + refined body font
- real masonry/landscaping imagery from public/images
- clear quote-first CTA treatment
- Model content in static TypeScript data modules for v1:
- siteConfig for business identity, address, phone, hours, socials, CTAs
- materials dataset for masonry and landscaping items with category, subcategory, image, description, purchase
unit, tags, featured flag
- page-specific hero, trust, delivery, and FAQ content
- Home page behavior:
- local SEO hero with Corpus Christi positioning
- split entry cards for masonry vs landscaping
- trust/value section
- featured materials
- delivery minimums and service summary
- direct links into contact/quote flow
- About page behavior:
- family-owned story and 30+ year credibility
- mission/service philosophy
- sourcing and expertise differentiators
- no invented team bios or fake history beyond info.md
- Supplies pages behavior:
- hero/banner
- filterable client-side product grid by subcategory/tags
- quote CTA on every card
- delivery/purchase-unit notes surfaced in-page
- breadcrumb support and internal links between supply pages and contact
- Contact page behavior:
- business info, hours, address, phone, social links
- delivery rules from info.md
- contact form with client validation and a Next.js API placeholder endpoint returning structured success/error
responses
- map section as external Google Maps link/embed placeholder, not a hard dependency
- SEO and schema:
- route-level metadata, titles, descriptions, canonical defaults, Open Graph/Twitter cards
- JSON-LD for LocalBusiness/Organization, WebSite, and BreadcrumbList
- semantic headings, accessible nav, internal linking, crawl-friendly page structure
- Delivery/runtime assets:
- Dockerfile for containerized app build/run
- docker-compose.yml for local container startup
- .dockerignore
- README updates with npm install, npm run dev, docker compose up instructions
## Public Interfaces and Data Contracts
- Route contract:
- /contact submits to /api/contact
- Contact API response contract:
- success: { success: true, message: string }
- validation/server error: { success: false, message: string, fieldErrors?: Record<string,string> }
- Material data contract:
- slug
- name
- category
- subcategory
- image
- description
- purchaseUnit
- availabilityNote?
- featured
- tags
- Shared config contract:
- business name, tagline, NAP, hours, socials, delivery notes, nav items, footer groups, default SEO fields
## Test Plan
- Verify header nav, footer links, breadcrumbs, and CTA flows between pages.
- Verify /api/contact handles valid submission, missing required fields, and invalid email/phone formats.
- Verify metadata and JSON-LD render per page and match visible content.
- Verify images load through Next.js without layout shift regressions.
- Verify Docker build succeeds and docker compose up serves the app successfully.
- Preserve the existing English content direction from info.md, even though the planning conversation is in German.
- Keep the classic page slugs: /about, /masonry-supplies, /landscaping-supplies, /contact.
- Contact form will be implementation-ready but not wired to real email delivery yet; the placeholder API is the
accepted v1 behavior.
- No cart, checkout, pricing engine, CMS, or live inventory in v1.
- Only provided assets in public/images are used unless implementation reveals a missing critical visual, in which
case the design falls back to text-first sections rather than invented media.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Some files were not shown because too many files have changed in this diff Show More