Fertig
|
|
@ -0,0 +1,7 @@
|
|||
.git
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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 |
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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 & Gravel, Flagstone,
|
||||
and Boulders & 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 & Gravel</span>
|
||||
<span className="inventory-chip">Boulders & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 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>© {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
|
@ -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/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 494 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 457 KiB |