"use client";
import React, { useEffect, useMemo, useState } from "react";
/**
* Elmotor — Reclaimed Wood Floors (Next.js App Router)
* Single‑focus storefront for reclaimed floors with RFQ (request‑for‑quote) cart.
* Tailwind CSS required.
*
* Quick start:
* npx create-next-app@latest reclaimed-huggins --typescript --eslint --tailwind --app --src-dir false
* Replace /app/page.tsx with this file. npm run dev
*/
// ---------- Types ----------
export type Product = {
id: string;
name: string;
species: "Reclaimed White Oak" | "Reclaimed Heart Pine" | "Reclaimed Chestnut" | "Reclaimed Hickory";
grade: "Select" | "Character" | "Rustic";
widths: string; // e.g. 6"–10"
lengths: string; // e.g. 2'–10'
construction: "Solid" | "Engineered" | "Solid or Engineered";
priceFrom: number; // MSRP or starting price for reference
image: string;
notes?: string;
};
export type CartItem = { id: string; qty: number; sqft: number };
// ---------- Catalog (uses your uploaded images) ----------
const CATALOG: Product[] = [
{
id: "re-oak-01",
name: "Antique Reclaimed Oak",
species: "Reclaimed White Oak",
grade: "Character",
widths: "6\"–10\" (wider on request)",
lengths: "2'–10' (avg. 5'–7')",
construction: "Solid or Engineered",
priceFrom: 16.5,
image: "/images/Antique-Reclaimed-oak-Flooring.jpg",
notes: "Warm honey to tobacco browns with nail holes and tight grain."
},
{
id: "re-oak-02",
name: "Reclaimed Oak – Mixed Grades",
species: "Reclaimed White Oak",
grade: "Rustic",
widths: "5\"–9\"",
lengths: "2'–10'",
construction: "Solid or Engineered",
priceFrom: 14.9,
image: "/images/Antique-Wide-Plank-Flooring-Oak_1.jpg",
notes: "High character with patina, kerf marks and occasional knots."
},
{
id: "re-oak-03",
name: "Antique Oak Plank",
species: "Reclaimed White Oak",
grade: "Character",
widths: "5\"–8\"",
lengths: "2'–8'",
construction: "Solid or Engineered",
priceFrom: 15.75,
image: "/images/Antique-Oak-Floor.jpg",
},
{
id: "re-heartpine-01",
name: "Reclaimed Heart Pine",
species: "Reclaimed Heart Pine",
grade: "Select",
widths: "5\"–9\"",
lengths: "2'–12'",
construction: "Solid or Engineered",
priceFrom: 13.9,
image: "/images/Heart-Pine.jpg",
notes: "Amber & cinnamon tones with dramatic growth rings; extremely durable."
},
{
id: "re-oak-04",
name: "Reclaimed Oak (Restaurant Grade)",
species: "Reclaimed White Oak",
grade: "Rustic",
widths: "5\"–8\"",
lengths: "Random",
construction: "Solid or Engineered",
priceFrom: 12.9,
image: "/images/Antique-Oak-Floor.jpg",
notes: "Ideal for commercial spaces; heavy wear character."
},
{
id: "re-oak-05",
name: "Reclaimed Oak — Kitchen Install",
species: "Reclaimed White Oak",
grade: "Character",
widths: "6\"–10\"",
lengths: "2'–10'",
construction: "Solid or Engineered",
priceFrom: 15.25,
image: "/images/Reclaimed-Oak-Floor.jpg"
},
{
id: "re-oak-06",
name: "Antique Oak—Mixed Board",
species: "Reclaimed White Oak",
grade: "Rustic",
widths: "Mixed",
lengths: "Mixed",
construction: "Solid or Engineered",
priceFrom: 13.5,
image: "/images/Oak-floor.jpg"
},
{
id: "re-oak-07",
name: "Antique Oak Diagonal",
species: "Reclaimed White Oak",
grade: "Character",
widths: "3\"–5\"",
lengths: "Random",
construction: "Solid or Engineered",
priceFrom: 12.5,
image: "/images/Antique-Oak-Floor.jpg"
}
];
// ---------- Helpers ----------
const money = (n: number) => `$${n.toFixed(2)}`;
// ---------- UI Atoms ----------
function SmartImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [s, setS] = useState
(src);
// Try public/ path first, then /mnt/data fallback, then a generic placeholder
const fallbacks = useMemo(() => [src, s.startsWith("/images/") ? src.replace("/images/", "/mnt/data/") : src, "https://images.unsplash.com/photo-1484154218962-a197022b5858?q=80&w=1200&auto=format&fit=crop"], [src, s]);
return (
{
const next = fallbacks.find((f) => f !== s);
if (next) setS(next);
}}
/>
);
}
function Button({ children, variant = "solid", ...props }: any) {
const base = "inline-flex items-center gap-2 rounded-full border px-4 py-2 font-semibold";
const cls =
variant === "solid"
? `${base} border-zinc-900 bg-zinc-900 text-white`
: variant === "light"
? `${base} border-white bg-white text-zinc-900`
: `${base} border-zinc-900 bg-transparent text-zinc-900`;
return (
);
}
function Pill({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
// ---------- Header ----------
function Header({ count, openCart }: { count: number; openCart: () => void }) {
return (
);
}
// ---------- Product Card ----------
function ProductCard({ p, onAdd }: { p: Product; onAdd: (id: string) => void }) {
return (
{p.grade} • {p.construction}
Widths {p.widths} • Lengths {p.lengths}
{p.notes &&
{p.notes}
}
from {money(p.priceFrom)}/sf
);
}
// ---------- RFQ Drawer ----------
function RFQDrawer({ open, onClose, items, products, setItems }: {
open: boolean;
onClose: () => void;
items: CartItem[];
products: Product[];
setItems: (fn: (prev: CartItem[]) => CartItem[]) => void;
}) {
const lines = items.map((it) => ({ it, prod: products.find((p) => p.id === it.id)! })).filter(Boolean);
const est = lines.reduce((s, l) => s + l.it.sqft * l.prod.priceFrom, 0);
const update = (id: string, f: (n: CartItem) => CartItem | null) =>
setItems((prev) => prev.map((i) => (i.id === id ? (f(i) || i) : i)).filter((x) => x && (x as any).qty !== 0) as any);
const submit = () => {
const payload = {
type: "RFQ",
items: lines.map(({ it, prod }) => ({ id: prod.id, name: prod.name, sqft: it.sqft, qty: it.qty })),
estimateUSD: est,
};
alert("RFQ sent (demo): " + JSON.stringify(payload, null, 2));
};
return (
Request for Quote
{lines.length === 0 &&
No items yet. Add products to your RFQ.
}
{lines.map(({ it, prod }) => (
{prod.name}
from {money(prod.priceFrom)}/sf
update(prod.id, (x)=> ({...x, sqft: Math.max(0, Number(e.target.value)||0)}))}
className="col-span-2 w-full rounded-lg border border-zinc-200 p-1"
/>
{it.qty}
Est.
{money(prod.priceFrom * it.sqft)}
))}
This is a demo. In production, post this payload to an API route that emails info@elmotor.com and logs to your CRM.
);
}
// ---------- (Dev) Lightweight runtime tests ----------
function runDevTests() {
try {
console.assert(money(12.3) === "$12.30", "money() should format to two decimals");
const mockProducts: Product[] = [
{ id: "x", name: "Test", species: "Reclaimed White Oak", grade: "Select", widths: "5\"", lengths: "2'", construction: "Solid", priceFrom: 10, image: "" }
];
const items: CartItem[] = [{ id: "x", qty: 2, sqft: 100 }];
const est = items[0].sqft * mockProducts[0].priceFrom;
console.assert(est === 1000, "estimate should be sqft * priceFrom");
const payload = { type: "RFQ", items: [{ id: "x", name: "Test", sqft: 100, qty: 2 }], estimateUSD: est } as const;
console.assert(payload.items.length === 1 && payload.estimateUSD === 1000, "payload shape ok");
// additional: total count test
const count = items.reduce((s, i) => s + i.qty, 0);
console.assert(count === 2, "cart count should sum qty");
// eslint-disable-next-line no-console
console.log("Dev tests passed ✔");
} catch (e) {
// eslint-disable-next-line no-console
console.warn("Dev tests failed", e);
}
}
// ---------- Page ----------
export default function Page() {
const [items, setItems] = useState([]);
const [drawer, setDrawer] = useState(false);
const count = useMemo(() => items.reduce((s, i) => s + i.qty, 0), [items]);
useEffect(() => {
if (process.env.NODE_ENV !== "production") runDevTests();
}, []);
const add = (id: string) => {
setItems((prev) => {
const found = prev.find((i) => i.id === id);
if (found) return prev.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i));
return [...prev, { id, qty: 1, sqft: 250 }]; // default sqft seed
});
setDrawer(true);
};
return (
);
}