Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/icon/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const iconVariants = cva("inline-block", {
xs: "ic-xs",
sm: "ic-sm",
md: "ic-md",
"2md": "ic-2md",
md2: "ic-md2",
lg: "ic-lg",
xl: "ic-xl",
"2xl": "ic-2xl",
Expand Down
2 changes: 2 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ export { default as ReviewTaste } from "./taste/review-taste";
export { default as Button } from "./button/basic-button";
export { default as ArrowButton } from "./button/arrow-button";
export { default as IconButton } from "./button/icon-button";
export { default as StarRating } from "./star-rating/star-rating";
export { default as RatingBreakdown } from "./star-rating/rating-breakdown";
66 changes: 66 additions & 0 deletions src/components/star-rating/rating-breakdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";
import { cn } from "@/lib/utils";
import StarRating from "./star-rating";
import ScoreBar from "./scorebar";
import Button from "../button/basic-button";

interface RatingBreakdownProps {
average: number;
maxRating?: number;
distribution: Record<number, number>; // 별점 : 해당 별점을 받은 리뷰 수
}

const RatingBreakdown = ({
average,
maxRating = 5,
distribution,
}: RatingBreakdownProps) => {
const reviewCounts = Object.values(distribution);
const totalReviews = reviewCounts.reduce((total, count) => total + count, 0);

return (
<div className="relative grid gap-y-[24px] pc:gap-y-[40px]">
<div
className={cn(
"grid grid-cols-[110px_auto] items-center gap-x-[20px]",
"tablet:grid-cols-[280px_auto] tablet:items-start tablet:gap-x-[77px]",
"pc:grid pc:grid-cols-[auto] pc:items-start pc:gap-x-0 pc:gap-y-[40px]"
)}
>
<div>
<StarRating rating={average} size="md2" totalScore />
</div>
<div
className={cn(
"grid gap-y-[2px]",
"tablet:gap-y-[4px]",
"pc:gap-y-[8px]"
)}
>
{[...Array(maxRating)].map((_, i) => {
const score = maxRating - i;
const count = distribution[score] ?? 0;
return (
<ScoreBar
key={score}
score={score}
reviewCount={count}
totalCount={totalReviews}
/>
);
Copy link
Contributor

@huuitae huuitae Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 Array.from({length: 5})new Array(maxRating).fill(0)로 배열을 만들어 사용하는 것이 나을 것 같습니다!

Array(maxRating)으로 배열을 만들면 아무것도 들어있지 않은 빈 배열을 사용하기 때문에 map() 함수가 의도대로 동작하지 않을 수 있다고 합니다.
실제로 배열을 만들면 [ <5 empty items> ]이 출력되고, index를 사용하려해도 undefined가 출력됩니다.

따라서 스프레드 연산자를 통해 사용하신 것 같은데, 이러면 배열을 두 번 만드는셈이 되니 한 번에 생성해서 사용하는게 나아보입니다!

})}
</div>
</div>
<Button
label="리뷰 남기기"
onClick={() => alert("리뷰 작성 모달창!!")}
className={cn(
"w-full",
"tablet:absolute tablet:bottom-[-10px] tablet:mt-0 tablet:w-[280px]"
)}
/>
</div>
);
};

export default RatingBreakdown;
27 changes: 27 additions & 0 deletions src/components/star-rating/scorebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
interface ScoreBarProps {
score: number;
reviewCount: number;
totalCount: number;
}

const ScoreBar = ({ score, reviewCount, totalCount }: ScoreBarProps) => {
const percent = totalCount === 0 ? 0 : (reviewCount / totalCount) * 100;

return (
<div className="grid grid-cols-[40px_auto] items-center">
<span className="text-body-md text-secondary">{score}점</span>
<div
role="progressbar"
aria-label={`${score}점을 받은 리뷰 수는 ${reviewCount}개입니다.`}
className="relative h-[6px] flex-1 overflow-hidden rounded-full bg-gray-200"
>
<span
className="absolute left-0 top-0 h-full rounded-full bg-primary"
style={{ width: `${percent}%` }}
></span>
</div>
</div>
);
};

export default ScoreBar;
193 changes: 193 additions & 0 deletions src/components/star-rating/star-rating.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import StarRating from "./star-rating";
import RatingBreakdown from "./rating-breakdown";

const meta: Meta<typeof StarRating> = {
title: "Components/StarRating",
component: StarRating,
parameters: {
layout: "centered",
docs: {
description: {
component:
"별점을 표시하는 컴포넌트입니다. 다양한 크기와 옵션을 제공합니다.",
},
},
},
tags: ["autodocs"],
argTypes: {
rating: {
control: { type: "number", min: 0, max: 5, step: 0.1 },
description: "평점 값 (0-5)",
},
maxRating: {
control: { type: "number", min: 1, max: 10 },
description: "최대 평점",
},
size: {
control: "select",
options: ["xs", "sm", "md", "md2", "lg", "xl", "2xl"],
description: "별의 크기",
},
score: {
control: "boolean",
description: "점수 표시 여부",
},
totalScore: {
control: "boolean",
description: "총점 표시 여부 (점수/총점 형식)",
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
rating: 4.2,
maxRating: 5,
size: "md",
},
};

export const WithScore: Story = {
args: {
rating: 4.5,
maxRating: 5,
size: "md",
score: true,
},
parameters: {
docs: {
description: {
story: "점수를 함께 표시하는 별점입니다.",
},
},
},
};

export const WithTotalScore: Story = {
args: {
rating: 3.8,
maxRating: 5,
size: "lg",
totalScore: true,
},
parameters: {
docs: {
description: {
story: "총점과 함께 표시하는 큰 크기의 별점입니다.",
},
},
},
};

export const Sizes: Story = {
render: () => (
<div className="flex flex-col items-start gap-6">
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">
Extra Small (xs)
</h3>
<StarRating rating={4.0} size="xs" score />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">Small (sm)</h3>
<StarRating rating={4.2} size="sm" score />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">Medium (md)</h3>
<StarRating rating={4.5} size="md" score />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">Medium 2 (md2)</h3>
<StarRating rating={3.8} size="md2" score />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">Large (lg)</h3>
<StarRating rating={4.7} size="lg" score />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">
Extra Large (xl)
</h3>
<StarRating rating={5.0} size="xl" score />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">2XL (2xl)</h3>
<StarRating rating={4.3} size="2xl" score />
</div>
</div>
),
parameters: {
docs: {
description: {
story: "다양한 크기의 별점을 보여줍니다.",
},
},
},
};

export const MaxRatingVariations: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">5점 만점</h3>
<StarRating rating={4.2} maxRating={5} score />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">10점 만점</h3>
<StarRating rating={8.5} maxRating={10} score />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">3점 만점</h3>
<StarRating rating={2.1} maxRating={3} score />
</div>
</div>
),
parameters: {
docs: {
description: {
story: "다양한 최대 평점 설정을 보여줍니다.",
},
},
},
};

export const RatingBreakdownResponsive: Story = {
render: () => (
<div className="mx-auto grid gap-8 p-6">
<div>
<div className="grid gap-10">
<h3 className="mb-5 text-lg font-semibold text-gray-700">
반응형 (확인 가능)
</h3>
<div className="pc:max-w-[280px]">
<div className="mb-5 text-sm text-gray-600">
pc / tablet / mobile
</div>
<RatingBreakdown
average={4.2}
distribution={{
5: 150,
4: 80,
3: 30,
2: 10,
1: 5,
}}
/>
</div>
</div>
</div>
</div>
),
parameters: {
layout: "fullscreen",
docs: {
description: {
story: "반응형",
},
},
},
};
61 changes: 61 additions & 0 deletions src/components/star-rating/star-rating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { cn } from "@/lib/utils";
import Star from "./star";

const TOTAL_SCORE_STYLES = "mobile:grid tablet:grid pc:flex";

interface StarRatingProps {
rating: number; //받은 별점
maxRating?: number;
score?: boolean; //기본 점수 표시 여부 (점수)
totalScore?: boolean; // 총점 표시 여부 (점수/총점)
fillPercent?: number;
size?: "xs" | "sm" | "md" | "md2" | "lg" | "xl" | "2xl";
}

const StarRating = ({
rating,
maxRating = 5,
score = false,
totalScore = false,
size = "sm",
}: StarRatingProps) => {
const starFills = Array.from({ length: maxRating }, (_, i) => {
const fillPercent = rating - i;
if (fillPercent >= 1) return 100;
if (fillPercent > 0) return fillPercent * 100;
return 0;
});
const ratingValue = Number.isInteger(rating)
? rating.toString()
: rating.toFixed(1);
const maxRatingValue = maxRating.toFixed(1);

return (
<div
className={cn(
`flex items-center gap-x-[8px] gap-y-[12px] ${totalScore && TOTAL_SCORE_STYLES}`
)}
>
<div className="flex">
{starFills.map((fill, i) => (
<Star key={i} fill={fill} size={size} />
))}
</div>
{/* score 타입 */}
{score && (
<span className="relative top-[3px] text-body-lg text-default">
{ratingValue}
</span>
)}
{/* totalScore 타입 */}
{totalScore && (
<div className="text-[28px] font-bold leading-[32px] tracking-[-0.3px] pc:relative pc:top-[4px]">
{ratingValue}
<span className="text-gray-400"> / {maxRatingValue}</span>
</div>
)}
</div>
);
};

export default StarRating;
Loading