ItemDetail
surface상품/아이템 상세 페이지. 태그, 저자, 콘텐츠, 리뷰 섹션 포함.
컴포넌트 의존 관계
깊이
100%
코드 리뷰 에이전트
PR 코드를 자동 분석하고 리뷰 코멘트를 생성합니다.
Free
코드리뷰자동화v1.2.0
AAlice
1234 다운로드4.8 평점text
1You are a code review agent. Analyze the PR diff and provide constructive feedback.리뷰(2)
AAlice
2026-03-10정말 유용합니다! 코드 리뷰 시간이 절반으로 줄었어요.
BBob
2026-03-08대체로 좋지만 가끔 오탐이 있습니다.
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
ItemDetail 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
ItemDetail Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
name* | string | — | 아이템 이름 |
description | string | — | 아이템 설명 |
price | number | string | — | 가격 (0 = 무료) |
icon | string | — | 아이콘 이모지 |
tags | string[] | — | 태그/뱃지 배열 |
author | { name: string; avatar?: string } | — | 저자 정보 |
stats | ItemDetailStat[] | — | 통계 항목 (다운로드, 평점 등) |
content | ReactNode | — | 메인 콘텐츠 블록 |
reviews | ItemDetailReview[] | — | 리뷰 목록 |
onReviewSubmit | (rating: number, comment: string) => void | — | 리뷰 제출 핸들러 |
maxRating | number | 5 | 최대 별점 |
backHref | string | — | 뒤로가기 링크 |
backLabel | string | — | 뒤로가기 라벨 |
onBack | () => void | — | 뒤로가기 핸들러 |
actions | ReactNode | — | 헤더 액션 슬롯 |
loading | boolean | false | 로딩 상태 |
labels | ItemDetailLabels | — | i18n 라벨 |
className | string | — | 최외곽 CSS 클래스 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add item-detailConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { ItemDetail } from "@/components/surfaces/item-detail";Registry metadata
- 설명
- 상품/아이템 상세 페이지. 태그, 저자, 콘텐츠, 리뷰 섹션 포함.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- 없음
- Install notes
- 없음
포함 파일
item-detail.tsxitem-detail.tsx
Surface 소스 보기
item-detail.tsx
"use client";
import * as React from "react";
import {
Badge,
Breadcrumb,
Avatar,
Button,
SurfaceLayout,
StarRating,
Textarea,
} from "@reopt-ai/opt-ui";
/** Review data for `ItemDetail`. */
export interface ItemDetailReview {
id: string;
author: { name: string; avatar?: string };
rating: number;
comment: string;
date: string;
}
/** Labels for `ItemDetail`. */
export interface ItemDetailLabels {
backLabel?: string;
tagsLabel?: string;
authorLabel?: string;
contentLabel?: string;
reviewsLabel?: string;
reviewPlaceholder?: string;
submitReview?: string;
noReviews?: string;
statsLabel?: string;
}
const defaultLabels: Required<ItemDetailLabels> = {
backLabel: "Back",
tagsLabel: "Tags",
authorLabel: "Author",
contentLabel: "Content",
reviewsLabel: "Reviews",
reviewPlaceholder: "Write a review...",
submitReview: "Submit",
noReviews: "No reviews yet.",
statsLabel: "Stats",
};
/** Statistic shape for `ItemDetail`. */
export interface ItemDetailStat {
label: string;
value: string | number;
}
/** Props for `ItemDetail`. */
export interface ItemDetailProps {
/** Item name / title */
name: string;
description?: string;
/** Price display string or number (0 = free) */
price?: number | string;
/** Icon emoji or image URL */
icon?: string;
/** Badge tags (category, version, custom) */
tags?: string[];
/** Author info */
author?: { name: string; avatar?: string };
/** Key-value stats (downloads, rating, date, etc.) */
stats?: ItemDetailStat[];
/** Main content block (code, text, etc.) */
content?: React.ReactNode;
/** Review list */
reviews?: ItemDetailReview[];
/** Called when a review is submitted */
onReviewSubmit?: (rating: number, comment: string) => void;
/** Max star rating value */
maxRating?: number;
/** Breadcrumb back navigation */
backHref?: string;
backLabel?: string;
onBack?: () => void;
/** Header actions slot (edit button, etc.) */
actions?: React.ReactNode;
loading?: boolean;
labels?: ItemDetailLabels;
className?: string;
}
/** Renders the `ItemDetail` component. */
export function ItemDetail({
name,
description,
price,
icon,
tags,
author,
stats,
content,
reviews,
onReviewSubmit,
maxRating = 5,
backHref,
backLabel,
onBack,
actions,
loading = false,
labels: customLabels,
className,
}: ItemDetailProps) {
const labels = { ...defaultLabels, ...customLabels };
const [reviewRating, setReviewRating] = React.useState(0);
const [reviewComment, setReviewComment] = React.useState("");
const handleSubmitReview = () => {
if (!onReviewSubmit || reviewRating === 0) return;
onReviewSubmit(reviewRating, reviewComment);
setReviewRating(0);
setReviewComment("");
};
const priceDisplay =
price == null || price === 0 || price === "0"
? "Free"
: typeof price === "number"
? `$${price}`
: price;
const hasMeta =
(tags && tags.length > 0) || author || (stats && stats.length > 0);
return (
<SurfaceLayout loading={loading} className={className}>
{/* ── Hero: Breadcrumb → Identity → Meta ── */}
<header className="gap-group flex flex-col">
{/* Breadcrumb */}
{(backHref || onBack) && (
<Breadcrumb
items={[
{
id: "back",
label: backLabel ?? labels.backLabel,
href: backHref,
},
{ id: "current", label: name },
]}
onNavigate={(item) => {
if (item.id === "back" && onBack) onBack();
}}
/>
)}
{/* Identity row */}
<div className="flex items-start justify-between">
<div className="gap-group flex items-center">
{icon && (
<span className="text-4xl leading-none" aria-hidden="true">
{icon}
</span>
)}
<div>
<h1 className="text-text-primary text-xl font-bold">{name}</h1>
{description && (
<p className="text-text-secondary mt-0.5 text-sm">
{description}
</p>
)}
</div>
</div>
<div className="gap-element flex items-center">
{price != null && (
<Badge
variant={priceDisplay === "Free" ? "success" : "default"}
size="md"
>
{priceDisplay}
</Badge>
)}
{actions}
</div>
</div>
{/* Meta bar: tags · author · stats */}
{hasMeta && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{tags && tags.length > 0 && (
<div
role="group"
aria-label={labels.tagsLabel}
className="flex flex-wrap gap-1.5"
>
{tags.map((tag) => (
<Badge key={tag} variant="default" size="sm">
{tag}
</Badge>
))}
</div>
)}
{author && (
<div className="flex items-center gap-1.5">
<Avatar name={author.name} src={author.avatar} size="sm" />
<span className="text-text-primary text-sm font-medium">
{author.name}
</span>
</div>
)}
{stats &&
stats.map((stat) => (
<span key={stat.label} className="text-text-tertiary text-sm">
<span className="text-text-secondary font-medium">
{stat.value}
</span>{" "}
{stat.label}
</span>
))}
</div>
)}
</header>
{/* ── Content ── */}
{content && <section aria-label={labels.contentLabel}>{content}</section>}
{/* ── Reviews ── */}
{(reviews || onReviewSubmit) && (
<section className="border-border pt-section gap-group flex flex-col border-t">
<h2 className="text-text-primary text-base font-semibold">
{labels.reviewsLabel}
{reviews && reviews.length > 0 && (
<span className="text-text-tertiary ml-1.5 text-sm font-normal">
({reviews.length})
</span>
)}
</h2>
{/* Review Form */}
{onReviewSubmit && (
<div className="bg-bg-subtle gap-group flex flex-col rounded-lg p-4">
<StarRating
value={reviewRating}
onChange={setReviewRating}
max={maxRating}
/>
<Textarea
placeholder={labels.reviewPlaceholder}
value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)}
rows={3}
/>
<Button
onClick={handleSubmitReview}
disabled={reviewRating === 0}
size="sm"
className="self-start"
>
{labels.submitReview}
</Button>
</div>
)}
{/* Review List */}
{reviews && reviews.length > 0 ? (
<div className="flex flex-col">
{reviews.map((review, idx) => (
<div
key={review.id}
className={`gap-element flex flex-col py-3 ${idx < reviews.length - 1 ? "border-border border-b" : ""}`}
>
<div className="flex items-center justify-between">
<div className="gap-element flex items-center">
<Avatar
name={review.author.name}
src={review.author.avatar}
size="sm"
/>
<span className="text-text-primary text-sm font-medium">
{review.author.name}
</span>
<StarRating
value={review.rating}
max={maxRating}
readOnly
size="sm"
/>
</div>
<span className="text-text-tertiary text-xs">
{review.date}
</span>
</div>
{review.comment && (
<p className="text-text-secondary pl-9 text-sm">
{review.comment}
</p>
)}
</div>
))}
</div>
) : (
<p className="text-text-tertiary text-sm">{labels.noReviews}</p>
)}
</section>
)}
</SurfaceLayout>
);
}
ItemDetail.displayName = "ItemDetail";