reopt designreopt design
DocsExploreToolsPricingBuilder
시작하기
개요
시작하기
Next.js 설치
Private install
핵심 개념
아키텍처
컴포지션 패턴
접근성
키보드 패턴
스타일링
테마 시스템
고급 패턴
구축·운영
Skills
AI 연동
CLI (opt surface add)
의존 그래프
도구
Canvas 카탈로그
Theme Builder
Form Builder
템플릿
템플릿
릴리즈
릴리즈 노트
Oopt-ui
reopt designreopt design

AI 시대를 위한 디자인 시스템

  • 문서
  • 가격
  • 릴리즈 노트
  • GitHub
  • 서비스 약관
  • 개인정보처리방침

© 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>
  );
}