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

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

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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