Fertig
This commit is contained in:
12
components/brand-logo.tsx
Normal file
12
components/brand-logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
components/breadcrumbs.tsx
Normal file
24
components/breadcrumbs.tsx
Normal 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
176
components/contact-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
components/count-up-stat.tsx
Normal file
86
components/count-up-stat.tsx
Normal 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
130
components/hero-cinema.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
components/home-cta-section.tsx
Normal file
136
components/home-cta-section.tsx
Normal 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 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
15
components/json-ld.tsx
Normal 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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
159
components/material-catalog.tsx
Normal file
159
components/material-catalog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
components/motion-section.tsx
Normal file
35
components/motion-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
components/page-hero-motion.tsx
Normal file
76
components/page-hero-motion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
components/process-timeline.tsx
Normal file
133
components/process-timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
components/scroll-reveal.tsx
Normal file
29
components/scroll-reveal.tsx
Normal 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;
|
||||
}
|
||||
98
components/site-footer.tsx
Normal file
98
components/site-footer.tsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
100
components/site-header.tsx
Normal file
100
components/site-header.tsx
Normal 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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
components/testimonials-carousel.tsx
Normal file
76
components/testimonials-carousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user