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
189 lines
6.7 KiB
TypeScript
189 lines
6.7 KiB
TypeScript
"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;
|