reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

MarketplaceCatalog

surface

카드 그리드 기반 마켓플레이스 카탈로그. 검색, 탭 필터, 정렬 지원.

컴포넌트 의존 관계

깊이
▼ USES (5)MarketplaceCatalogcardbadgeinputtabsempty-state
100%

마켓플레이스

에이전트와 스킬을 찾아보세요

테스트 커버리지

2026년 2월 4일

생성된 테스트 결과를 찾지 못했습니다.

MarketplaceCatalog 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.

테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.

MarketplaceCatalog Props

Prop타입기본값설명
items*MarketplaceCatalogItem[]—카탈로그 아이템 배열
renderCard(item: MarketplaceCatalogItem) => ReactNode—커스텀 카드 렌더러
onItemClick(item: MarketplaceCatalogItem) => void—카드 클릭 핸들러
tabsMarketplaceCatalogTab[]—카테고리 탭 정의
activeTabstring—활성 탭 ID
onTabChange(tabId: string) => void—탭 변경 핸들러
sortOptionsMarketplaceCatalogSortOption[]—정렬 옵션
activeSortstring—활성 정렬 ID
onSortChange(sortId: string) => void—정렬 변경 핸들러
gridClassNamestring"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"그리드 컬럼 Tailwind 클래스
loadingbooleanfalse로딩 상태
headerReactNode—헤더 슬롯
actionsReactNode—액션 슬롯
labelsMarketplaceCatalogLabels—i18n 라벨
classNamestring—최외곽 CSS 클래스

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add marketplace-catalog

Consumer target

복사된 파일은 components/surfaces 아래에 저장됩니다.

tsx
import { MarketplaceCatalog } from "@/components/surfaces/marketplace-catalog";

Registry metadata

설명
카드 그리드 기반 마켓플레이스 카탈로그. 검색, 탭 필터, 정렬 지원.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
없음
Install notes
없음

포함 파일

  • marketplace-catalog.tsx→marketplace-catalog.tsx
Surface 소스 보기
marketplace-catalog.tsx
"use client";

import * as React from "react";
import {
  Input,
  Badge,
  Card,
  CardContent,
  EmptyState,
  SurfaceLayout,
  TabsRoot,
  TabList,
  Tab,
  cn,
} from "@reopt-ai/opt-ui";

/** Item shape for `MarketplaceCatalog`. */
export interface MarketplaceCatalogItem {
  id: string;
  name: string;
  description?: string;
  icon?: string;
  image?: string;
  price?: number | string;
  category?: string;
  tags?: string[];
  author?: { name: string; avatar?: string };
  stats?: { label: string; value: string | number }[];
  rating?: number;
}

/** Tab definition for `MarketplaceCatalog`. */
export interface MarketplaceCatalogTab {
  id: string;
  label: string;
  count?: number;
}

/** Option shape for `MarketplaceCatalogSort`. */
export interface MarketplaceCatalogSortOption {
  id: string;
  label: string;
}

/** Labels for `MarketplaceCatalog`. */
export interface MarketplaceCatalogLabels {
  title?: string;
  searchPlaceholder?: string;
  sortLabel?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  freeBadge?: string;
}

const defaultLabels: Required<MarketplaceCatalogLabels> = {
  title: "Catalog",
  searchPlaceholder: "Search...",
  sortLabel: "Sort",
  emptyTitle: "No items found",
  emptyDescription: "There are no items to display.",
  freeBadge: "Free",
};

/** Props for `MarketplaceCatalog`. */
export interface MarketplaceCatalogProps {
  items: MarketplaceCatalogItem[];
  /** Custom card renderer. Falls back to default card layout. */
  renderCard?: (item: MarketplaceCatalogItem) => React.ReactNode;
  /** Called when a card is clicked */
  onItemClick?: (item: MarketplaceCatalogItem) => void;
  /** Category tab definitions */
  tabs?: MarketplaceCatalogTab[];
  activeTab?: string;
  onTabChange?: (tabId: string) => void;
  /** Sort dropdown options */
  sortOptions?: MarketplaceCatalogSortOption[];
  activeSort?: string;
  onSortChange?: (sortId: string) => void;
  /** Grid column count (Tailwind class like "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3") */
  gridClassName?: string;
  loading?: boolean;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: MarketplaceCatalogLabels;
  className?: string;
}

/** Renders the `MarketplaceCatalog` component. */
export function MarketplaceCatalog({
  items,
  renderCard,
  onItemClick,
  tabs,
  activeTab,
  onTabChange,
  sortOptions,
  activeSort,
  onSortChange,
  gridClassName = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
  loading = false,
  header,
  actions,
  labels: customLabels,
  className,
}: MarketplaceCatalogProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const [search, setSearch] = React.useState("");

  const filtered = React.useMemo(() => {
    if (!search) return items;
    const q = search.toLowerCase();
    return items.filter(
      (item) =>
        item.name.toLowerCase().includes(q) ||
        item.description?.toLowerCase().includes(q) ||
        item.tags?.some((t) => t.toLowerCase().includes(q)),
    );
  }, [items, search]);

  const isEmpty = filtered.length === 0;

  return (
    <SurfaceLayout loading={loading} className={className}>
      {/* Header */}
      <div className="flex items-center justify-between">
        {header ?? (
          <h2 className="text-text-primary text-lg font-semibold">
            {labels.title}
          </h2>
        )}
        <div className="gap-element flex items-center">{actions}</div>
      </div>

      {/* Controls: Tabs / Search / Sort */}
      {(tabs || sortOptions || items.length > 0) && (
        <div className="gap-group flex flex-col sm:flex-row sm:items-center sm:justify-between">
          {tabs && tabs.length > 0 && onTabChange && (
            <TabsRoot
              selectedId={activeTab ?? tabs[0]?.id}
              setSelectedId={(id) => {
                if (id) onTabChange(id);
              }}
            >
              <TabList aria-label={labels.title}>
                {tabs.map((tab) => (
                  <Tab key={tab.id} id={tab.id}>
                    <span className="flex items-center gap-1.5">
                      {tab.label}
                      {tab.count != null && (
                        <Badge variant="default" size="sm">
                          {tab.count}
                        </Badge>
                      )}
                    </span>
                  </Tab>
                ))}
              </TabList>
            </TabsRoot>
          )}

          <div className="gap-element flex items-center">
            <div className="w-56">
              <Input
                placeholder={labels.searchPlaceholder}
                value={search}
                onChange={(e) => setSearch(e.target.value)}
                aria-label={labels.searchPlaceholder}
              />
            </div>
            {sortOptions && sortOptions.length > 0 && onSortChange && (
              <select
                value={activeSort ?? sortOptions[0]?.id}
                onChange={(e) => onSortChange(e.target.value)}
                aria-label={labels.sortLabel}
                className={cn(
                  "border-border bg-bg-primary text-text-primary rounded-md border px-3 py-1.5 text-sm",
                  "focus-visible:outline-accent focus-visible:outline-2 focus-visible:outline-offset-2",
                )}
              >
                {sortOptions.map((opt) => (
                  <option key={opt.id} value={opt.id}>
                    {opt.label}
                  </option>
                ))}
              </select>
            )}
          </div>
        </div>
      )}

      {/* Content */}
      {isEmpty ? (
        <EmptyState
          title={labels.emptyTitle}
          description={labels.emptyDescription}
        />
      ) : (
        <div className={cn("gap-group grid", gridClassName)}>
          {filtered.map((item) =>
            renderCard ? (
              <React.Fragment key={item.id}>{renderCard(item)}</React.Fragment>
            ) : (
              <DefaultCard
                key={item.id}
                item={item}
                labels={labels}
                onClick={onItemClick ? () => onItemClick(item) : undefined}
              />
            ),
          )}
        </div>
      )}
    </SurfaceLayout>
  );
}

MarketplaceCatalog.displayName = "MarketplaceCatalog";

/* ── Default Card ── */

function DefaultCard({
  item,
  labels,
  onClick,
}: {
  item: MarketplaceCatalogItem;
  labels: Required<MarketplaceCatalogLabels>;
  onClick?: () => void;
}) {
  const priceLabel =
    item.price == null || item.price === 0 || item.price === "0"
      ? labels.freeBadge
      : typeof item.price === "number"
        ? `$${item.price}`
        : item.price;

  const Wrapper = onClick ? "button" : "div";

  return (
    <Card
      className={cn(
        onClick && "cursor-pointer transition-shadow hover:shadow-md",
      )}
    >
      <Wrapper
        type={onClick ? "button" : undefined}
        onClick={onClick}
        className="w-full text-left"
      >
        <CardContent className="gap-element flex flex-col">
          {/* Top row: icon + name + price */}
          <div className="flex items-start justify-between">
            <div className="gap-element flex items-center">
              {item.icon && (
                <span className="text-2xl" aria-hidden="true">
                  {item.icon}
                </span>
              )}
              <h3 className="text-text-primary font-medium">{item.name}</h3>
            </div>
            <Badge
              variant={priceLabel === labels.freeBadge ? "success" : "default"}
              size="sm"
            >
              {priceLabel}
            </Badge>
          </div>

          {/* Description */}
          {item.description && (
            <p className="text-text-secondary line-clamp-2 text-sm">
              {item.description}
            </p>
          )}

          {/* Tags */}
          {item.tags && item.tags.length > 0 && (
            <div className="flex flex-wrap gap-1">
              {item.tags.map((tag) => (
                <Badge key={tag} variant="default" size="sm">
                  {tag}
                </Badge>
              ))}
            </div>
          )}

          {/* Bottom: author + stats */}
          <div className="flex items-center justify-between">
            {item.author && (
              <div className="flex items-center gap-1.5">
                {item.author.avatar && (
                  <img
                    src={item.author.avatar}
                    alt=""
                    className="h-5 w-5 rounded-full"
                  />
                )}
                <span className="text-text-tertiary text-xs">
                  {item.author.name}
                </span>
              </div>
            )}
            {item.stats && item.stats.length > 0 && (
              <div className="gap-element flex items-center">
                {item.stats.map((stat) => (
                  <span key={stat.label} className="text-text-tertiary text-xs">
                    {stat.label} {stat.value}
                  </span>
                ))}
              </div>
            )}
          </div>
        </CardContent>
      </Wrapper>
    </Card>
  );
}