Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion messages/en/shop.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
},
"detail": {
"backToShop": "Back to shop",
"checkoutComingSoon": "Checkout coming soon"
"checkoutComingSoon": "Checkout coming soon",
"emailToBuy": "Email to buy",
"emailToBuyHint": "Opens your email app to send a purchase inquiry to the vendor."
},
"notFound": {
"title": "Product Not Found",
Expand Down
4 changes: 3 additions & 1 deletion messages/pt-br/shop.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
},
"detail": {
"backToShop": "Voltar à loja",
"checkoutComingSoon": "Finalização de compra em breve"
"checkoutComingSoon": "Finalização de compra em breve",
"emailToBuy": "Comprar por e-mail",
"emailToBuyHint": "Abre seu app de e-mail para enviar uma solicitação de compra ao vendedor."
},
"notFound": {
"title": "Produto não encontrado",
Expand Down
Binary file added public/shop/nogenta-jersey-rio-back.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/shop/nogenta-jersey-rio-front.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/shop/nogenta-logo-tee-black.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/shop/nogenta-logo-tee-blue.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/shop/nogenta-ngnt-tee-white.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/shop/nogenta-noggles-tee-black.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/shop/nogenta-noggles-tee-white.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/shop/nogenta-pixel-noggles-tee-white.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 9 additions & 4 deletions src/components/shop/FeaturedShop.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import Image from "next/image";
import { ArrowUpRight, Sparkles } from "lucide-react";
import { Link } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
import type { ShopItem } from "@/types/shop";
import { formatPrice, type ShopCardLabels } from "./shared";
import { formatPrice, isDirectBuyLink, type ShopCardLabels } from "./shared";

function FeaturedCard({ item, labels }: { item: ShopItem; labels: ShopCardLabels }) {
const cover = item.images[0];
const price = formatPrice(item.priceUSD);
const isExternal = item.type === "affiliate";
const isExternal = isDirectBuyLink(item);
const isComingSoon = item.status === "coming-soon";

const content = (
<div className="group flex h-full flex-col">
Expand All @@ -19,7 +21,10 @@ function FeaturedCard({ item, labels }: { item: ShopItem; labels: ShopCardLabels
alt={item.title}
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-contain p-4 drop-shadow-md transition-transform duration-500 ease-out group-hover:-translate-y-1.5 group-hover:scale-105"
className={cn(
"object-contain p-4 drop-shadow-md transition-transform duration-500 ease-out group-hover:-translate-y-1.5 group-hover:scale-105",
isComingSoon && "grayscale",
)}
/>
)}
<span className="absolute left-1 top-1 inline-flex items-center gap-1 rounded-full bg-primary px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-primary-foreground">
Expand Down Expand Up @@ -48,7 +53,7 @@ function FeaturedCard({ item, labels }: { item: ShopItem; labels: ShopCardLabels
</div>
);

if (isExternal && item.externalUrl) {
if (isExternal) {
return (
<a
href={item.externalUrl}
Expand Down
13 changes: 9 additions & 4 deletions src/components/shop/ShopCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import Image from "next/image";
import { ArrowUpRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Link } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
import type { ShopItem } from "@/types/shop";
import { formatPrice, type ShopCardLabels } from "./shared";
import { formatPrice, isDirectBuyLink, type ShopCardLabels } from "./shared";

function CardInner({ item, labels }: { item: ShopItem; labels: ShopCardLabels }) {
const cover = item.images[0];
const price = formatPrice(item.priceUSD);
const isExternal = item.type === "affiliate";
const isExternal = isDirectBuyLink(item);
const isComingSoon = item.status === "coming-soon";

return (
<div className="group flex h-full flex-col">
Expand All @@ -20,7 +22,10 @@ function CardInner({ item, labels }: { item: ShopItem; labels: ShopCardLabels })
alt={item.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-contain p-2 drop-shadow-sm transition-transform duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-105"
className={cn(
"object-contain p-2 drop-shadow-sm transition-transform duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-105",
isComingSoon && "grayscale",
)}
/>
)}

Expand Down Expand Up @@ -58,7 +63,7 @@ function CardInner({ item, labels }: { item: ShopItem; labels: ShopCardLabels })
}

export function ShopCard({ item, labels }: { item: ShopItem; labels: ShopCardLabels }) {
if (item.type === "affiliate" && item.externalUrl) {
if (isDirectBuyLink(item)) {
return (
<a
href={item.externalUrl}
Expand Down
87 changes: 68 additions & 19 deletions src/components/shop/ShopDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
"use client";

import { useState } from "react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { ArrowLeft, ExternalLink } from "lucide-react";
import { ArrowLeft, ExternalLink, Mail } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Link } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
import type { ShopItem } from "@/types/shop";
import { formatPrice } from "./shared";

export function ShopDetail({ item }: { item: ShopItem }) {
const t = useTranslations("shop");
const cover = item.images[0];
const [activeImage, setActiveImage] = useState(0);
const images = item.images.length > 0 ? item.images : [];
const cover = images[Math.min(activeImage, images.length - 1)];
const price = formatPrice(item.priceUSD);
const isComingSoon = item.status === "coming-soon";
const isAffiliate = item.type === "affiliate";
const isEmail = item.externalUrl?.startsWith("mailto:") ?? false;
const canBuy = isAffiliate && !!item.externalUrl && !isComingSoon;

return (
<div className="container mx-auto max-w-5xl px-4">
Expand All @@ -26,18 +33,48 @@ export function ShopDetail({ item }: { item: ShopItem }) {
</Link>

<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{cover && (
<div className="relative aspect-square w-full">
<div className="pointer-events-none absolute inset-0 hidden dark:block [background:radial-gradient(circle_at_center,rgba(255,255,255,0.10),transparent_65%)]" />
<Image
src={cover}
alt={item.title}
fill
priority
className="object-contain p-4 drop-shadow-md"
/>
</div>
)}
<div className="flex flex-col gap-3">
{cover && (
<div className="relative aspect-square w-full">
<div className="pointer-events-none absolute inset-0 hidden dark:block [background:radial-gradient(circle_at_center,rgba(255,255,255,0.10),transparent_65%)]" />
<Image
src={cover}
alt={item.title}
fill
priority
className={cn("object-contain p-4 drop-shadow-md", isComingSoon && "grayscale")}
/>
</div>
)}

{images.length > 1 && (
<div className="flex flex-wrap gap-2">
{images.map((src, i) => (
<button
key={src}
type="button"
onClick={() => setActiveImage(i)}
aria-label={`${item.title} — ${i + 1}`}
aria-pressed={i === activeImage}
className={cn(
"relative h-16 w-16 shrink-0 cursor-pointer overflow-hidden rounded-md border transition-colors",
i === activeImage
? "border-primary"
: "border-border hover:border-muted-foreground",
)}
>
<Image
src={src}
alt=""
fill
sizes="64px"
className={cn("object-contain p-1", isComingSoon && "grayscale")}
/>
</button>
))}
</div>
)}
</div>

<div className="flex flex-col">
{item.vendor && (
Expand All @@ -58,19 +95,31 @@ export function ShopDetail({ item }: { item: ShopItem }) {
{item.description && <p className="mt-4 text-muted-foreground">{item.description}</p>}

<div className="mt-8">
{isAffiliate && item.externalUrl ? (
{canBuy ? (
<Button asChild size="lg">
<a href={item.externalUrl} target="_blank" rel="noopener noreferrer sponsored">
{t("card.shopNow")}
<ExternalLink className="ml-2 h-4 w-4" />
<a
href={item.externalUrl}
target={isEmail ? undefined : "_blank"}
rel={isEmail ? undefined : "noopener noreferrer sponsored"}
>
{isEmail ? t("detail.emailToBuy") : t("card.shopNow")}
{isEmail ? (
<Mail className="ml-2 h-4 w-4" />
) : (
<ExternalLink className="ml-2 h-4 w-4" />
)}
</a>
</Button>
) : (
<Button size="lg" disabled>
{t("detail.checkoutComingSoon")}
{isComingSoon ? t("card.comingSoon") : t("detail.checkoutComingSoon")}
</Button>
)}
</div>

{canBuy && isEmail && (
<p className="mt-3 text-sm text-muted-foreground">{t("detail.emailToBuyHint")}</p>
)}
</div>
</div>
</div>
Expand Down
14 changes: 14 additions & 0 deletions src/components/shop/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,17 @@ export function formatPrice(priceUSD?: number) {
if (priceUSD == null) return null;
return `$${priceUSD.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
}

/**
* True when the card should link straight to an external storefront.
* `mailto:` inquiries and multi-image listings route through the detail
* page instead so the buyer sees the product before contacting the vendor.
*/
export function isDirectBuyLink(item: { type: string; externalUrl?: string; images: string[] }) {
return (
item.type === "affiliate" &&
!!item.externalUrl &&
!item.externalUrl.startsWith("mailto:") &&
item.images.length <= 1
);
}
102 changes: 80 additions & 22 deletions src/data/shop.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,34 +82,92 @@
"featured": true
},
{
"id": "keepkey-ethdenver-tee",
"slug": "keepkey-ethdenver-tee",
"title": "KeepKey ETHDenver 2025 Tee",
"description": "KeepKey ETHDenver 2025 t-shirt. Print-on-demand crypto merch for the self-custody crowd.",
"images": ["/shop/keepkey-ethdenver-tee.png"],
"priceUSD": 30,
"id": "nogenta-noggles-tee-white",
"slug": "nogenta-noggles-tee-white",
"title": "Nogenta Noggles Tee (White)",
"description": "White Nogenta tee — oval script logo and pixel-block noggles across the front chest.",
"images": ["/shop/nogenta-noggles-tee-white.jpeg"],
"type": "affiliate",
"externalUrl": "https://keepkey.printify.me/product/17250533",
"vendor": "KeepKey",
"tags": ["apparel", "crypto", "merch"],
"status": "active"
"externalUrl": "mailto:nogentaskate@gmail.com?subject=Nogenta%20Noggles%20Tee%20(White)%20-%20Purchase%20Inquiry",
"vendor": "Nogenta",
"tags": ["apparel", "merch", "tee"],
"status": "coming-soon"
},
{
"id": "keepkey-laser-eyes-tee",
"slug": "keepkey-laser-eyes-tee",
"title": "KeepKey Laser Eyes Tee",
"description": "American Apparel Laser Eyes KeepKey t-shirt. Print-on-demand Bitcoin maxi merch.",
"images": ["/shop/keepkey-laser-eyes-tee.png"],
"priceUSD": 30,
"id": "nogenta-noggles-tee-black",
"slug": "nogenta-noggles-tee-black",
"title": "Nogenta Noggles Tee (Black)",
"description": "Black Nogenta tee — oval script logo and pixel-block noggles across the front chest.",
"images": ["/shop/nogenta-noggles-tee-black.jpeg"],
"type": "affiliate",
"externalUrl": "https://keepkey.printify.me/product/13163479",
"vendor": "KeepKey",
"tags": ["apparel", "crypto", "merch"],
"status": "active"
"externalUrl": "mailto:nogentaskate@gmail.com?subject=Nogenta%20Noggles%20Tee%20(Black)%20-%20Purchase%20Inquiry",
"vendor": "Nogenta",
"tags": ["apparel", "merch", "tee"],
"status": "coming-soon"
},
{
"id": "nogenta-pixel-noggles-tee-white",
"slug": "nogenta-pixel-noggles-tee-white",
"title": "Nogenta Pixel Noggles Tee (White)",
"description": "White Nogenta tee with the Gnars noggles rendered as a color pixel block at center chest.",
"images": ["/shop/nogenta-pixel-noggles-tee-white.jpeg"],
"type": "affiliate",
"externalUrl": "mailto:nogentaskate@gmail.com?subject=Nogenta%20Pixel%20Noggles%20Tee%20(White)%20-%20Purchase%20Inquiry",
"vendor": "Nogenta",
"tags": ["apparel", "merch", "tee"],
"status": "coming-soon"
},
{
"id": "nogenta-ngnt-tee-white",
"slug": "nogenta-ngnt-tee-white",
"title": "Nogenta NGNT Tee (White)",
"description": "White Nogenta tee — faded pixel-block noggles at center chest with an NGNT graffiti tag at the hem.",
"images": ["/shop/nogenta-ngnt-tee-white.jpeg"],
"type": "affiliate",
"externalUrl": "mailto:nogentaskate@gmail.com?subject=Nogenta%20NGNT%20Tee%20(White)%20-%20Purchase%20Inquiry",
"vendor": "Nogenta",
"tags": ["apparel", "merch", "tee"],
"status": "coming-soon"
},
{
"id": "nogenta-logo-tee-black",
"slug": "nogenta-logo-tee-black",
"title": "Nogenta Logo Tee (Black)",
"description": "Black Nogenta tee with the oval script logo at center chest.",
"images": ["/shop/nogenta-logo-tee-black.jpeg"],
"type": "affiliate",
"externalUrl": "mailto:nogentaskate@gmail.com?subject=Nogenta%20Logo%20Tee%20(Black)%20-%20Purchase%20Inquiry",
"vendor": "Nogenta",
"tags": ["apparel", "merch", "tee"],
"status": "coming-soon"
},
{
"id": "nogenta-logo-tee-blue",
"slug": "nogenta-logo-tee-blue",
"title": "Nogenta Logo Tee (Blue)",
"description": "Light blue Nogenta tee with the oval script logo at center chest.",
"images": ["/shop/nogenta-logo-tee-blue.jpeg"],
"type": "affiliate",
"externalUrl": "mailto:nogentaskate@gmail.com?subject=Nogenta%20Logo%20Tee%20(Blue)%20-%20Purchase%20Inquiry",
"vendor": "Nogenta",
"tags": ["apparel", "merch", "tee"],
"status": "coming-soon"
},
{
"id": "nogenta-jersey-rio",
"slug": "nogenta-jersey-rio",
"title": "Nogenta Rio de Janeiro Jersey",
"description": "Nogenta football jersey, Rio de Janeiro edition. Black with a white collar and trim — NGNT script and pixel-block noggles on the front, GNARLY 13 on the back.",
"images": ["/shop/nogenta-jersey-rio-front.jpeg", "/shop/nogenta-jersey-rio-back.jpeg"],
"type": "affiliate",
"externalUrl": "mailto:nogentaskate@gmail.com?subject=Nogenta%20Rio%20de%20Janeiro%20Jersey%20-%20Purchase%20Inquiry",
"vendor": "Nogenta",
"tags": ["apparel", "merch", "jersey", "football"],
"status": "coming-soon"
}
],
"meta": {
"total": 8,
"lastUpdated": "2026-06-20"
"total": 13,
"lastUpdated": "2026-08-27"
}
}
Loading