Add persistent onion key backup and restore, improve startup resilience, and flesh out the major site verticals with richer navigation, search coverage, and operator documentation. Made-with: Cursor
186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useMemo, useState } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { ProductSocialBlock } from "@/components/shop/ProductSocialBlock";
|
|
import { useCart } from "@/contexts/CartContext";
|
|
import {
|
|
SHOP_PRODUCT_COUNT,
|
|
SHOP_PRODUCTS,
|
|
resolveProductImage,
|
|
shopCategoryCounts,
|
|
type ShopCurrency,
|
|
type ShopProduct,
|
|
} from "@/lib/shopCatalog";
|
|
import { getProductSocialMetrics } from "@/lib/shopProductSocial";
|
|
import { SHOP_SELLER_COUNT, getSellerById } from "@/lib/shopSellers";
|
|
|
|
function formatPrice(price: number, currency: ShopCurrency): string {
|
|
if (currency === "BTC") return price.toFixed(6);
|
|
if (currency === "ETH") return price.toFixed(4);
|
|
if (currency === "XMR") return price.toFixed(3);
|
|
return price.toFixed(2);
|
|
}
|
|
|
|
const SYM: Record<ShopCurrency, string> = {
|
|
BTC: "₿",
|
|
ETH: "Ξ",
|
|
USD: "$",
|
|
XMR: "⏣",
|
|
};
|
|
|
|
export default function MarketPage() {
|
|
const sp = useSearchParams();
|
|
const initialCat = sp.get("category") ?? "";
|
|
const [q, setQ] = useState("");
|
|
const [category, setCategory] = useState(initialCat);
|
|
|
|
const facet = useMemo(() => shopCategoryCounts(), []);
|
|
|
|
const filtered = useMemo(() => {
|
|
const ql = q.trim().toLowerCase();
|
|
const tokens = ql.split(/\s+/).filter(Boolean);
|
|
return SHOP_PRODUCTS.filter((p) => {
|
|
if (category && p.category !== category) return false;
|
|
if (tokens.length === 0) return true;
|
|
const seller = getSellerById(p.sellerId);
|
|
const sellerHay = seller ? `${seller.handle} ${seller.bio} ${seller.specialty}` : "";
|
|
const hay = `${p.name} ${p.description} ${p.category} ${p.id} ${sellerHay}`.toLowerCase();
|
|
return tokens.every((t) => hay.includes(t));
|
|
});
|
|
}, [q, category]);
|
|
|
|
return (
|
|
<div className="font-mono text-[#00ff41]">
|
|
<section className="mb-8 border-b border-[#00ff41]/20 pb-6">
|
|
<p className="text-[10px] uppercase tracking-[0.4em] text-[#00ff41]/50">Layer 1 · live floor</p>
|
|
<p className="mt-2 max-w-3xl text-sm text-[#00ff41]/65">
|
|
<strong className="text-[#7af598]">{SHOP_PRODUCT_COUNT}</strong> SKUs ·{" "}
|
|
<Link href="/vendors" className="font-bold text-[#9dffc4] underline hover:text-white">
|
|
{SHOP_SELLER_COUNT} vendor stalls
|
|
</Link>
|
|
— per-listing reviews, velocity, and cart handoff into checkout (USD estimate from spot). Use the left rail for ops, trust, and intel layers.
|
|
</p>
|
|
</section>
|
|
|
|
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div className="flex-1">
|
|
<label className="text-[10px] uppercase tracking-widest text-[#00ff41]/50">Search catalog</label>
|
|
<input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="tokens match title, description, category…"
|
|
className="mt-1 w-full max-w-xl border-2 border-[#00ff41]/30 bg-black/80 px-4 py-3 text-sm text-[#c8ffd8] placeholder:text-[#00ff41]/25 focus:border-[#00ff41] focus:outline-none"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-[#00ff41]/50">
|
|
Showing <span className="text-[#7af598]">{filtered.length}</span> / {SHOP_PRODUCT_COUNT}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mb-8 flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCategory("")}
|
|
className={`px-3 py-1.5 text-xs uppercase ${category === "" ? "bg-[#00ff41] text-black" : "border border-[#00ff41]/35 hover:bg-[#00ff41]/10"}`}
|
|
>
|
|
All
|
|
</button>
|
|
{facet.map((c) => (
|
|
<button
|
|
key={c.name}
|
|
type="button"
|
|
onClick={() => setCategory(c.name)}
|
|
className={`px-3 py-1.5 text-xs ${category === c.name ? "bg-[#00ff41] text-black" : "border border-[#00ff41]/35 hover:bg-[#00ff41]/10"}`}
|
|
>
|
|
<span className="mr-1">{c.icon}</span>
|
|
{c.name} ({c.count})
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
|
{filtered.map((p) => (
|
|
<MarketProductCard key={p.id} product={p} />
|
|
))}
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<p className="mt-16 border border-dashed border-[#00ff41]/20 py-12 text-center text-sm text-[#00ff41]/50">
|
|
No SKUs match — widen search or pick another category.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MarketProductCard({ product: p }: { product: ShopProduct }) {
|
|
const src = resolveProductImage(p);
|
|
const local = src.startsWith("/");
|
|
const v = getSellerById(p.sellerId);
|
|
const metrics = getProductSocialMetrics(p.id);
|
|
const { addToCart, hydrated } = useCart();
|
|
const [flash, setFlash] = useState(false);
|
|
|
|
return (
|
|
<article className="flex h-full flex-col border border-[#00ff41]/20 bg-[#0d0d0d] transition-all hover:border-[#00ff41]/55">
|
|
<div className="relative aspect-square w-full bg-black">
|
|
<Image
|
|
src={src}
|
|
alt=""
|
|
fill
|
|
className="object-cover"
|
|
sizes="(max-width: 640px) 100vw, (max-width: 1280px) 50vw, 25vw"
|
|
unoptimized={local}
|
|
/>
|
|
{p.limited ? (
|
|
<span className="absolute left-2 top-2 bg-[#ff00aa] px-2 py-0.5 text-[10px] font-bold uppercase text-black">Limited</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-1 flex-col p-4">
|
|
<p className="text-[10px] uppercase tracking-wider text-[#00ff41]/45">{p.category}</p>
|
|
<h2 className="mt-1 text-base font-bold leading-snug text-[#d8ffe8]">{p.name}</h2>
|
|
<div className="mt-2 text-[#9dffc4]">
|
|
<ProductSocialBlock metrics={metrics} compact />
|
|
</div>
|
|
{v ? (
|
|
<p className="mt-2 text-[10px] text-[#00ff41]/55">
|
|
<span className="text-[#00ff41]/40">Seller</span>{" "}
|
|
<Link href={`/vendor/${v.slug}`} className="text-[#7af598] hover:text-white hover:underline">
|
|
{v.handle}
|
|
</Link>
|
|
<span className="text-[#00ff41]/35"> · stall ★{v.rating.toFixed(2)}</span>
|
|
</p>
|
|
) : null}
|
|
<p className="mt-2 line-clamp-3 text-xs leading-relaxed text-[#00ff41]/70">{p.description}</p>
|
|
<blockquote className="mt-3 border-l-2 border-[#00ff41]/30 pl-3 text-[11px] italic leading-snug text-[#b4ffcc]/80">
|
|
“{metrics.testimonials[0]?.text}”
|
|
<span className="mt-0.5 block font-mono text-[10px] not-italic text-[#00ff41]/45">
|
|
@{metrics.testimonials[0]?.handle} · {metrics.testimonials[0]?.daysAgo}d
|
|
</span>
|
|
</blockquote>
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-[#00ff41]/15 pt-3">
|
|
<span className="text-lg font-bold text-[#7af598]">
|
|
{SYM[p.currency]}
|
|
{formatPrice(p.price, p.currency)} <span className="text-xs font-normal text-[#00ff41]/50">{p.currency}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={!hydrated}
|
|
onClick={() => {
|
|
addToCart(p, 1);
|
|
setFlash(true);
|
|
window.setTimeout(() => setFlash(false), 1400);
|
|
}}
|
|
className="border border-[#00ff41] bg-[#00ff41] px-3 py-1.5 text-xs font-bold uppercase text-black hover:bg-[#7af598] disabled:opacity-50"
|
|
>
|
|
{flash ? "Added" : "Add"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|