shadimage
channel3
componentproduct-attributessource: ok · hit

Product Attributes

Renders a product's extracted attributes (structured_attributes plus materials, gender, and age) as a two-column definition list.

npx shadcn add "https://ui.trychannel3.com/r/product-attributes.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@channel3/sdk

Files1 · source cached 10/8/2026, 9:41:23 PM

registry/default/components/product-attributes.tsx
import * as React from "react";
import type { Product } from "@channel3/sdk/resources";

import { cn } from "@/lib/utils";

interface Attribute {
  label: string;
  value: string;
}

const unique = <T,>(values: T[]): T[] => Array.from(new Set(values));

const humanizeKey = (key: string) =>
  key
    .replace(/[_-]+/g, " ")
    .trim()
    .replace(/\b\w/g, (char) => char.toUpperCase());

const titleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);

/**
 * Flattens a product's extracted attributes into a deduplicated list of
 * label/value rows: `category`, then each `structured_attributes` entry,
 * `materials`, `gender`, and `age`. A `material(s)` structured entry is dropped
 * when the richer `materials` array is present so the same fact isn't shown
 * twice.
 */
function buildAttributes(product: Product): Attribute[] {
  const rows: Attribute[] = [];

  if (product.category?.title) {
    rows.push({ label: "Category", value: product.category.title });
  }

  const materials = unique((product.materials ?? []).map((value) => value.trim()).filter(Boolean));
  const hasMaterials = materials.length > 0;

  for (const [key, values] of Object.entries(product.structured_attributes ?? {})) {
    const normalized = key.replace(/[_-]+/g, "").toLowerCase();
    if (hasMaterials && (normalized === "material" || normalized === "materials")) {
      continue;
    }
    const cleaned = unique((values ?? []).map((value) => String(value).trim()).filter(Boolean));
    if (cleaned.length === 0) {
      continue;
    }
    rows.push({ label: humanizeKey(key), value: cleaned.join(", ") });
  }

  if (hasMaterials) {
    rows.push({
      label: materials.length > 1 ? "Materials" : "Material",
      value: materials.join(", "),
    });
  }
  if (product.gender) {
    rows.push({ label: "Gender", value: titleCase(product.gender) });
  }
  if (product.age) {
    rows.push({ label: "Age", value: titleCase(product.age) });
  }

  return rows;
}

export interface ProductAttributesProps extends React.ComponentProps<"dl"> {
  product: Product;
}

export function ProductAttributes({ product, className, ...props }: ProductAttributesProps) {
  const attributes = buildAttributes(product);
  if (attributes.length === 0) {
    return null;
  }

  return (
    <dl
      data-slot="product-attributes"
      className={cn(
        "grid grid-cols-[minmax(5rem,auto)_1fr] gap-x-6 gap-y-2 text-sm",
        className,
      )}
      {...props}
    >
      {attributes.map(({ label, value }) => (
        <React.Fragment key={label}>
          <dt className="text-muted-foreground">{label}</dt>
          <dd className="text-foreground">{value}</dd>
        </React.Fragment>
      ))}
    </dl>
  );
}

More from channel3

Browse all components in this library →