- Pulse theme tokens in app.schema.json: Archivo Black headings (weight 400) + Inter body, white bg / black pill buttons, xl radius, AI-generated athletic imagery - Add headerFontWeight theme prop so single-weight fonts (Archivo Black) load and render correctly; ThemeProvider applies font-family + weight inline so Typography works regardless of `as` element - New shared Heading component (tagline / title / subtitle with size + align + tone variants) and Typography caption variant for taglines; refactor features, faq, cta, testimonials, products-carousel, products-grid, collection-grid, recommended-products, image-gallery, newsletter-cta to use them - Hero accepts a `buttons` array (label / href / variant) replacing primaryCta/secondaryCta; cover-image component removed and existing cover blocks migrated to Hero blocks with `buttons: []` - Newsletter CTA uses shadcn Button + Input so it inherits theme radius; stacked layout fixed to keep the image - Product/collection card titles use Typography subtitle variants (font-body), heading font weight is theme-controlled - Remove orphan commerce/shop-header.tsx and commerce/shop-footer.tsx; the editor-driven navigation/footer are the live chrome Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
79 lines
2.3 KiB
TypeScript
79 lines
2.3 KiB
TypeScript
import * as React from "react";
|
|
import { Link } from "react-router";
|
|
import { Typography } from "@/components/Typography";
|
|
|
|
type ProductImage = { url: string; altText?: string };
|
|
type ProductPrice = { amount: string; currencyCode: string };
|
|
|
|
export type ProductCardData = {
|
|
id: string;
|
|
handle: string;
|
|
title: string;
|
|
images?: { edges?: Array<{ node: ProductImage }> };
|
|
priceRange?: { minVariantPrice?: ProductPrice };
|
|
compareAtPriceRange?: { minVariantPrice?: ProductPrice };
|
|
};
|
|
|
|
function format(price: ProductPrice) {
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: price.currencyCode,
|
|
}).format(parseFloat(price.amount));
|
|
}
|
|
|
|
export function ProductCard({
|
|
product,
|
|
aspect = "portrait",
|
|
}: {
|
|
product: ProductCardData;
|
|
aspect?: "portrait" | "square" | "landscape";
|
|
}) {
|
|
const image = product.images?.edges?.[0]?.node;
|
|
const price = product.priceRange?.minVariantPrice;
|
|
const compare = product.compareAtPriceRange?.minVariantPrice;
|
|
const onSale =
|
|
price && compare && parseFloat(compare.amount) > parseFloat(price.amount);
|
|
|
|
const aspectClass: Record<string, string> = {
|
|
portrait: "aspect-[4/5]",
|
|
square: "aspect-square",
|
|
landscape: "aspect-[4/3]",
|
|
};
|
|
|
|
return (
|
|
<Link to={`/products/${product.handle}`} className="group block">
|
|
<div
|
|
className={`relative w-full overflow-hidden rounded-md bg-muted ${aspectClass[aspect]}`}
|
|
>
|
|
{image ? (
|
|
<img
|
|
src={image.url}
|
|
alt={image.altText || product.title}
|
|
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-4 flex items-start justify-between gap-3">
|
|
<Typography
|
|
variant="subtitle2"
|
|
className="font-medium tracking-tight text-foreground"
|
|
>
|
|
{product.title}
|
|
</Typography>
|
|
{price ? (
|
|
<div className="flex flex-col items-end text-sm">
|
|
{onSale && compare ? (
|
|
<span className="text-xs text-muted-foreground line-through">
|
|
{format(compare)}
|
|
</span>
|
|
) : null}
|
|
<span className="font-medium">{format(price)}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export default ProductCard;
|