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.

ItemDetail

surface

상품/아이템 상세 페이지. 태그, 저자, 콘텐츠, 리뷰 섹션 포함.

컴포넌트 의존 관계

깊이
▼ USES (6)ItemDetailbadgebreadcrumbavatarbuttonstar-ratingtextarea
100%
  1. 에이전트 목록/
  2. 코드 리뷰 에이전트
🤖

코드 리뷰 에이전트

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—아이템 이름
descriptionstring—아이템 설명
pricenumber | string—가격 (0 = 무료)
iconstring—아이콘 이모지
tagsstring[]—태그/뱃지 배열
author{ name: string; avatar?: string }—저자 정보
statsItemDetailStat[]—통계 항목 (다운로드, 평점 등)
contentReactNode—메인 콘텐츠 블록
reviewsItemDetailReview[]—리뷰 목록
onReviewSubmit(rating: number, comment: string) => void—리뷰 제출 핸들러
maxRatingnumber5최대 별점
backHrefstring—뒤로가기 링크
backLabelstring—뒤로가기 라벨
onBack() => void—뒤로가기 핸들러
actionsReactNode—헤더 액션 슬롯
loadingbooleanfalse로딩 상태
labelsItemDetailLabels—i18n 라벨
classNamestring—최외곽 CSS 클래스

Surface 설치

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

bash
npx @reopt-ai/opt-cli surface add item-detail

Consumer target

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

tsx
import { ItemDetail } from "@/components/surfaces/item-detail";

Registry metadata

설명
상품/아이템 상세 페이지. 태그, 저자, 콘텐츠, 리뷰 섹션 포함.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
없음
Install notes
없음

포함 파일

  • item-detail.tsx→item-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";