diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx index f30c3c14..f8437025 100644 --- a/src/components/icon/icon.tsx +++ b/src/components/icon/icon.tsx @@ -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", diff --git a/src/components/index.ts b/src/components/index.ts index 1ddf5e47..c5c7bf62 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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"; diff --git a/src/components/star-rating/rating-breakdown.tsx b/src/components/star-rating/rating-breakdown.tsx new file mode 100644 index 00000000..dbe0bd1d --- /dev/null +++ b/src/components/star-rating/rating-breakdown.tsx @@ -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; // 별점 : 해당 별점을 받은 리뷰 수 +} + +const RatingBreakdown = ({ + average, + maxRating = 5, + distribution, +}: RatingBreakdownProps) => { + const reviewCounts = Object.values(distribution); + const totalReviews = reviewCounts.reduce((total, count) => total + count, 0); + + return ( +
+
+
+ +
+
+ {Array.from({ length: maxRating }).map((_, i) => { + const score = maxRating - i; + const count = distribution[score] ?? 0; + return ( + + ); + })} +
+
+
+ ); +}; + +export default RatingBreakdown; diff --git a/src/components/star-rating/scorebar.tsx b/src/components/star-rating/scorebar.tsx new file mode 100644 index 00000000..12ae8367 --- /dev/null +++ b/src/components/star-rating/scorebar.tsx @@ -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 ( +
+ {score}점 +
+ +
+
+ ); +}; + +export default ScoreBar; diff --git a/src/components/star-rating/star-rating.stories.tsx b/src/components/star-rating/star-rating.stories.tsx new file mode 100644 index 00000000..c08fb51f --- /dev/null +++ b/src/components/star-rating/star-rating.stories.tsx @@ -0,0 +1,193 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import StarRating from "./star-rating"; +import RatingBreakdown from "./rating-breakdown"; + +const meta: Meta = { + 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; + +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: () => ( +
+
+

+ Extra Small (xs) +

+ +
+
+

Small (sm)

+ +
+
+

Medium (md)

+ +
+
+

Medium 2 (md2)

+ +
+
+

Large (lg)

+ +
+
+

+ Extra Large (xl) +

+ +
+
+

2XL (2xl)

+ +
+
+ ), + parameters: { + docs: { + description: { + story: "다양한 크기의 별점을 보여줍니다.", + }, + }, + }, +}; + +export const MaxRatingVariations: Story = { + render: () => ( +
+
+

5점 만점

+ +
+
+

10점 만점

+ +
+
+

3점 만점

+ +
+
+ ), + parameters: { + docs: { + description: { + story: "다양한 최대 평점 설정을 보여줍니다.", + }, + }, + }, +}; + +export const RatingBreakdownResponsive: Story = { + render: () => ( +
+
+
+

+ 반응형 (확인 가능) +

+
+
+ pc / tablet / mobile +
+ +
+
+
+
+ ), + parameters: { + layout: "fullscreen", + docs: { + description: { + story: "반응형", + }, + }, + }, +}; diff --git a/src/components/star-rating/star-rating.tsx b/src/components/star-rating/star-rating.tsx new file mode 100644 index 00000000..c6ae7401 --- /dev/null +++ b/src/components/star-rating/star-rating.tsx @@ -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 ( +
+
+ {starFills.map((fill, i) => ( + + ))} +
+ {/* score 타입 */} + {score && ( + + {ratingValue} + + )} + {/* totalScore 타입 */} + {totalScore && ( +
+ {ratingValue} + / {maxRatingValue} +
+ )} +
+ ); +}; + +export default StarRating; diff --git a/src/components/star-rating/star.tsx b/src/components/star-rating/star.tsx new file mode 100644 index 00000000..b4b450fb --- /dev/null +++ b/src/components/star-rating/star.tsx @@ -0,0 +1,36 @@ +import Icon from "@/components/icon/icon"; + +interface RatingStarProps { + fill: number; + size?: "xs" | "sm" | "md" | "md2" | "lg" | "xl" | "2xl"; +} + +const Star = ({ fill, size = "sm" }: RatingStarProps) => { + const widthPercent = Math.min(Math.max(fill, 0), 100); + + return ( +
+ +
+ +
+
+ ); +}; + +export default Star; diff --git a/tailwind.config.ts b/tailwind.config.ts index 3446753d..bfe12730 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -7,7 +7,7 @@ export default { "./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], - safelist: ["ic-xs", "ic-sm", "ic-md", "ic-2md", "ic-lg", "ic-xl", "ic-2xl"], + safelist: ["ic-xs", "ic-sm", "ic-md", "ic-md2", "ic-lg", "ic-xl", "ic-2xl"], theme: { extend: { colors: { @@ -85,7 +85,7 @@ export default { ".ic-xs": { width: "16px", height: "16px" }, ".ic-sm": { width: "20px", height: "20px" }, ".ic-md": { width: "24px", height: "24px" }, - ".ic-2md": { width: "28px", height: "28px" }, + ".ic-md2": { width: "28px", height: "28px" }, ".ic-lg": { width: "32px", height: "32px" }, ".ic-xl": { width: "40px", height: "40px" }, ".ic-2xl": { width: "48px", height: "48px" },