Patterns
PatternsTier 5 · Blocks
Seller card
The maker: avatar, place, year, Assured tick, and their quote.
bunx --bun shadcn@latest add @woohl/seller-cardPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/seller-cardregistry/tier-5-blocks/seller-card.tsx
import type { ReactElement } from "react";
import type { ElementType, ReactNode } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { Avatar } from "@/registry/tier-3-components/avatar";
import { Badge } from "@/registry/tier-3-components/badge";
import { Rating } from "@/registry/tier-5-blocks/rating";
/**
* SellerCard.
*
* The maker, as a card. Appears on a product page, in search, and at the top of
* a maker's own profile.
*
* THIS IS THE COMPONENT WOOHL'S POSITIONING RESTS ON
* The claim is that you can name the person who made the thing. So this card
* carries the specifics that make that true — the workshop's place, how long
* they have been going, what they actually make — and not "Trusted seller ·
* 4.8 ★", which is what every marketplace shows and which says nothing.
*
* `location` is a real place ("Bhopal, MP"), not a country. "Made in India" as
* a caption is explicitly called out as the thing not to do.
*/
export type SellerCardProps = {
name: string;
href?: string;
avatar?: string;
/** "Bhopal, MP" — the actual place, never just the country. */
location?: string;
/** The year the workshop started. */
since?: number;
/** Woohl Assured. Only for makers who have actually been verified. */
verified?: boolean;
rating?: number;
reviewCount?: number;
productCount?: number;
/** One or two lines in the maker's own register. Never generated. */
bio?: string;
/** Glyphs from the app's own icon set — the Assured tick, the place, the year. */
verifiedIcon?: ReactElement;
locationIcon?: ReactElement;
sinceIcon?: ReactElement;
/** Craft tags — "Handloom", "Natural dyes". */
tags?: string[];
as?: ElementType;
action?: ReactNode;
variant?: "compact" | "full";
className?: string;
};
export function SellerCard({
verifiedIcon,
locationIcon,
sinceIcon,
name,
href,
avatar,
location,
since,
verified = false,
rating,
reviewCount,
productCount,
bio,
tags,
as: LinkComponent = "a",
action,
variant = "full",
className,
}: SellerCardProps) {
const compact = variant === "compact";
return (
<article
data-slot="seller-card"
className={cn(
"font-ui flex gap-4",
!compact &&
"border-border-subtle bg-surface-raised shadow-e1 rounded-xl border p-5",
className,
)}
>
<Avatar
name={name}
src={avatar}
size={compact ? 40 : 56}
verified={verified}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3
className={cn(
"text-text-primary font-bold",
compact ? "text-body" : "text-h3",
)}
>
{href ? (
<LinkComponent href={href} className="hover:text-text-accent">
{name}
</LinkComponent>
) : (
name
)}
</h3>
{verified && (
<Badge tone="accent" size="sm" icon={verifiedIcon}>
Woohl Assured
</Badge>
)}
</div>
{(location || since) && (
<p className="text-caption text-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
{location && (
<span className="inline-flex items-center gap-1">
{locationIcon && (
<span
aria-hidden
className="grid flex-none place-items-center [&>svg]:size-[13px]"
>
{locationIcon}
</span>
)}
{location}
</span>
)}
{since && (
<span className="inline-flex items-center gap-1">
{sinceIcon && (
<span
aria-hidden
className="grid flex-none place-items-center [&>svg]:size-[13px]"
>
{sinceIcon}
</span>
)}
Since {since}
</span>
)}
</p>
)}
{!compact && bio && (
<p className="text-body text-text-secondary mt-3 leading-[1.6]">
{bio}
</p>
)}
{(rating !== undefined || productCount !== undefined) && (
<div className="mt-3 flex flex-wrap items-center gap-4">
{rating !== undefined && (
<Rating value={rating} count={reviewCount} size={14} />
)}
{productCount !== undefined && (
<span className="text-caption text-text-muted">
{productCount.toLocaleString("en-IN")}{" "}
{productCount === 1 ? "piece" : "pieces"}
</span>
)}
</div>
)}
{!compact && tags && tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} tone="neutral" size="sm">
{tag}
</Badge>
))}
</div>
)}
</div>
{action && <div className="flex-none self-start">{action}</div>}
</article>
);
}
/**
* The maker's own words, as a pull quote.
*
* The ONE place Playfair goes non-headline in the product, and the one place
* the editorial serif is allowed on a product surface — a direct quotation is
* its own register, so it doesn't count as mixing the two type systems.
*
* Never paraphrased, never tidied, never generated. If there is no real quote,
* there is no pull quote.
*/
export function SellerQuote({
children,
attribution,
className,
}: {
children: ReactNode;
attribution?: string;
className?: string;
}) {
return (
<figure
data-slot="seller-quote"
className={cn("border-surface-accent border-l-2 pl-5", className)}
>
<blockquote className="text-h3 text-text-primary font-serif leading-[1.4] italic">
{children}
</blockquote>
{attribution && (
<figcaption className="font-ui text-caption text-text-muted mt-3">
— {attribution}
</figcaption>
)}
</figure>
);
}
SellerCardProps
| Prop | Type | Default |
|---|---|---|
name* | string | — |
action | ReactNode | — |
as | ElementType | — |
avatar | string | — |
bioOne or two lines in the maker's own register. Never generated. | string | — |
className | string | — |
href | string | — |
location"Bhopal, MP" — the actual place, never just the country. | string | — |
locationIcon | ReactElement | — |
productCount | number | — |
rating | number | — |
reviewCount | number | — |
sinceThe year the workshop started. | number | — |
sinceIcon | ReactElement | — |
tagsCraft tags — "Handloom", "Natural dyes". | string[] | — |
variant | "compact" | "full" | — |
verifiedWoohl Assured. Only for makers who have actually been verified. | boolean | — |
verifiedIconGlyphs from the app's own icon set — the Assured tick, the place, the year. | ReactElement | — |
Registry items
npm packages
None beyond React itself.