Harden onion boot flow and deepen site surfaces
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
This commit is contained in:
188
components/ProductCard.tsx
Normal file
188
components/ProductCard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { ProductSocialBlock } from "@/components/shop/ProductSocialBlock";
|
||||
import { SHOP_PRODUCTS, type ShopProduct } from "@/lib/shopCatalog";
|
||||
import { getProductSocialMetrics } from "@/lib/shopProductSocial";
|
||||
|
||||
interface ProductCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: "BTC" | "ETH" | "USD" | "XMR";
|
||||
category: string;
|
||||
imageUrl: string;
|
||||
sellerHandle: string;
|
||||
sellerSlug: string;
|
||||
limited?: boolean;
|
||||
glowColor?: string;
|
||||
/** When set, add-to-cart uses this row; otherwise looks up by `id` in SHOP_PRODUCTS */
|
||||
shopProduct?: ShopProduct;
|
||||
}
|
||||
|
||||
const ProductCard = ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
currency,
|
||||
category,
|
||||
imageUrl,
|
||||
sellerHandle,
|
||||
sellerSlug,
|
||||
limited = false,
|
||||
glowColor: _glow = "neon-cyan",
|
||||
shopProduct,
|
||||
}: ProductCardProps) => {
|
||||
void _glow;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [justAdded, setJustAdded] = useState(false);
|
||||
const { addToCart, hydrated: cartReady } = useCart();
|
||||
|
||||
const metrics = useMemo(() => getProductSocialMetrics(id), [id]);
|
||||
|
||||
const currencySymbols = {
|
||||
BTC: "₿",
|
||||
ETH: "Ξ",
|
||||
USD: "$",
|
||||
XMR: "⏣",
|
||||
};
|
||||
|
||||
const formatPrice = (p: number) => {
|
||||
if (currency === "BTC") return p.toFixed(6);
|
||||
if (currency === "ETH") return p.toFixed(4);
|
||||
if (currency === "XMR") return p.toFixed(3);
|
||||
return p.toFixed(2);
|
||||
};
|
||||
|
||||
const resolveProduct = (): ShopProduct | undefined => shopProduct ?? SHOP_PRODUCTS.find((x) => x.id === id);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
const p = resolveProduct();
|
||||
if (!p) return;
|
||||
addToCart(p, 1);
|
||||
setJustAdded(true);
|
||||
window.setTimeout(() => setJustAdded(false), 1800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/10 transition-all duration-500 hover:scale-[1.02] hover:shadow-2xl hover:shadow-neon-cyan/10"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`absolute -inset-1 -z-10 rounded-2xl opacity-0 blur-xl transition-opacity duration-500 ${
|
||||
isHovered ? "bg-neon-cyan/30 opacity-20" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
{limited && (
|
||||
<div className="absolute left-4 top-4 z-10">
|
||||
<div className="relative">
|
||||
<div className="animate-glow rounded-full bg-gradient-to-r from-neon-pink to-neon-purple px-3 py-1 text-xs font-bold text-background">
|
||||
LIMITED
|
||||
</div>
|
||||
<div className="absolute -inset-1 -z-10 animate-pulse rounded-full bg-neon-pink blur-md opacity-30"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-4 z-10 glass rounded-full p-2"
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<span className={`text-lg ${isFavorite ? "text-neon-pink" : "text-foreground/60"}`}>
|
||||
{isFavorite ? "❤️" : "🤍"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
unoptimized={imageUrl.startsWith("/")}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
|
||||
<div className="glass border-t border-white/10 p-6 backdrop-blur-sm">
|
||||
<div className="mb-3">
|
||||
<ProductSocialBlock metrics={metrics} compact />
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="rounded-full bg-white/5 px-3 py-1 text-xs font-medium text-neon-cyan">{category}</span>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<span className="font-orbitron text-xl font-bold text-foreground md:text-2xl">
|
||||
{currencySymbols[currency]}
|
||||
{formatPrice(price)}
|
||||
</span>
|
||||
<span className="text-sm text-foreground/50">{currency}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 font-orbitron text-xl font-bold">{name}</h3>
|
||||
<p className="mb-1 text-xs text-foreground/50">
|
||||
Seller:{" "}
|
||||
<Link href={`/vendor/${sellerSlug}`} className="text-neon-cyan hover:text-neon-purple hover:underline">
|
||||
{sellerHandle}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-4 line-clamp-3 text-sm text-foreground/70">{description}</p>
|
||||
|
||||
<div className="mb-4 space-y-2 border-t border-white/10 pt-4">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-foreground/35">Verified chatter</p>
|
||||
{metrics.testimonials.slice(0, 1).map((t, i) => (
|
||||
<p key={i} className="text-xs italic leading-relaxed text-foreground/55">
|
||||
“{t.text}”
|
||||
<span className="mt-1 block font-mono text-[10px] not-italic text-neon-cyan/70">
|
||||
— @{t.handle} · ★{t.rating} · {t.daysAgo}d ago
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!cartReady || !resolveProduct()}
|
||||
className="flex-1 rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple py-3 text-sm font-bold text-background transition-transform enabled:hover:scale-[1.02] disabled:opacity-50"
|
||||
>
|
||||
{justAdded ? "ADDED ✓" : "ADD TO CART"}
|
||||
</button>
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="glass flex items-center justify-center rounded-full p-3 transition-colors hover:bg-white/10"
|
||||
title="Checkout"
|
||||
>
|
||||
<span className="text-lg">🛒</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-foreground/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-neon-green"></div>
|
||||
<span>In stock · Ships discreetly</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>🔐</span>
|
||||
<span>Encrypted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
Reference in New Issue
Block a user