-
Notifications
You must be signed in to change notification settings - Fork 2
[#16] 리뷰 평점(별점) 컴포넌트 구현 #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2b3d00b
feat: 별점 및 세부 평점 컴포넌트 구현 #16
suuuuya d56a553
Merge branch 'develop' into design/star-rating-component
suuuuya 30bc0de
style: 반응형 스타일 적용 #16
suuuuya da7fd4a
design: 스토리북 추가 #16
suuuuya 95deba6
fix: 배열 방식 수정 #16
suuuuya f4f6199
Merge branch 'develop' into design/star-rating-component
suuuuya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | ||
| /> | ||
| ); | ||
| })} | ||
| </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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: "반응형", | ||
| }, | ||
| }, | ||
| }, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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가 출력됩니다.따라서 스프레드 연산자를 통해 사용하신 것 같은데, 이러면 배열을 두 번 만드는셈이 되니 한 번에 생성해서 사용하는게 나아보입니다!