MarketplaceCatalog
surface카드 그리드 기반 마켓플레이스 카탈로그. 검색, 탭 필터, 정렬 지원.
컴포넌트 의존 관계
깊이
100%
마켓플레이스
에이전트와 스킬을 찾아보세요
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
MarketplaceCatalog 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
MarketplaceCatalog Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
items* | MarketplaceCatalogItem[] | — | 카탈로그 아이템 배열 |
renderCard | (item: MarketplaceCatalogItem) => ReactNode | — | 커스텀 카드 렌더러 |
onItemClick | (item: MarketplaceCatalogItem) => void | — | 카드 클릭 핸들러 |
tabs | MarketplaceCatalogTab[] | — | 카테고리 탭 정의 |
activeTab | string | — | 활성 탭 ID |
onTabChange | (tabId: string) => void | — | 탭 변경 핸들러 |
sortOptions | MarketplaceCatalogSortOption[] | — | 정렬 옵션 |
activeSort | string | — | 활성 정렬 ID |
onSortChange | (sortId: string) => void | — | 정렬 변경 핸들러 |
gridClassName | string | "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" | 그리드 컬럼 Tailwind 클래스 |
loading | boolean | false | 로딩 상태 |
header | ReactNode | — | 헤더 슬롯 |
actions | ReactNode | — | 액션 슬롯 |
labels | MarketplaceCatalogLabels | — | i18n 라벨 |
className | string | — | 최외곽 CSS 클래스 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add marketplace-catalogConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { MarketplaceCatalog } from "@/components/surfaces/marketplace-catalog";Registry metadata
- 설명
- 카드 그리드 기반 마켓플레이스 카탈로그. 검색, 탭 필터, 정렬 지원.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- 없음
- Install notes
- 없음
포함 파일
marketplace-catalog.tsxmarketplace-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>
);
}