Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -13,3 +13,5 @@ export { default as Profile } from "./profile/profile";
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.from({ length: maxRating }).map((_, i) => {
const score = maxRating - i;
const count = distribution[score] ?? 0;
return (
<ScoreBar
key={score}
score={score}
reviewCount={count}
totalCount={totalReviews}
/>
);
})}
</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