Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bff3dcc200 | ||
|
|
ea8db4bef3 |
@@ -6,6 +6,13 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🔐 P4-3 인증 메시지 + 지도 접근성 (v0.1.22)
|
||||||
|
- #266: Google verifier 실패 메시지 고정 + log.warn (정보 누출 차단)
|
||||||
|
- #278: boundsTimerRef cleanup, '내 위치' 44px + aria-label, dead code 제거
|
||||||
|
- #277: 결함 모두 후속(#338) — deep health/version/테스트는 별도
|
||||||
|
- 후속 분리: #338(deep health), #339(브랜드 토큰화/마커 ARIA), #340(다중 audience)
|
||||||
|
- Refs: #266 #277 #278 (close)
|
||||||
|
|
||||||
### ⚙️ P4-2 데몬/캐시/통계 결함 (v0.1.21)
|
### ⚙️ P4-2 데몬/캐시/통계 결함 (v0.1.21)
|
||||||
- #275: updateConfig 가드(1+ 정수), Scheduler try-finally updateLastX, GET config admin-only
|
- #275: updateConfig 가드(1+ 정수), Scheduler try-finally updateLastX, GET config admin-only
|
||||||
- #276: ping try-with-resources + ConnectionFactory null 가드, makeKey null 가드
|
- #276: ping try-with-resources + ConnectionFactory null 가드, makeKey null 가드
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { GoogleLogin } from "@react-oauth/google";
|
import { GoogleLogin } from "@react-oauth/google";
|
||||||
|
import { useEscapeKey, useFocusTrap, useBodyScrollLock } from "@/lib/hooks/useModalA11y";
|
||||||
|
|
||||||
interface LoginMenuProps {
|
interface LoginMenuProps {
|
||||||
onGoogleSuccess: (credential: string) => void;
|
onGoogleSuccess: (credential: string) => void;
|
||||||
@@ -10,6 +11,22 @@ interface LoginMenuProps {
|
|||||||
|
|
||||||
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = "login-dialog-title";
|
||||||
|
|
||||||
|
// #283 — 모달 접근성: ESC / focus trap / body scroll lock
|
||||||
|
useEscapeKey(open, () => setOpen(false));
|
||||||
|
useFocusTrap(open, dialogRef);
|
||||||
|
useBodyScrollLock(open);
|
||||||
|
|
||||||
|
const handleSuccess = (res: { credential?: string }) => {
|
||||||
|
setErrorMsg(null);
|
||||||
|
if (res.credential) {
|
||||||
|
onGoogleSuccess(res.credential);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -26,26 +43,34 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
|||||||
style={{ zIndex: 99999 }}
|
style={{ zIndex: 99999 }}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
||||||
>
|
>
|
||||||
<div className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-sm space-y-4"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
<h3 id={titleId} className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none"
|
aria-label="로그인 창 닫기"
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none p-2 -m-2"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
||||||
|
{errorMsg && (
|
||||||
|
<p role="alert" className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 rounded p-2">
|
||||||
|
{errorMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<GoogleLogin
|
<GoogleLogin
|
||||||
onSuccess={(res) => {
|
onSuccess={handleSuccess}
|
||||||
if (res.credential) {
|
onError={() => setErrorMsg("Google 로그인에 실패했습니다. 팝업 차단 또는 네트워크 상태를 확인해주세요.")}
|
||||||
onGoogleSuccess(res.credential);
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onError={() => console.error("Google login failed")}
|
|
||||||
size="large"
|
size="large"
|
||||||
width="260"
|
width="260"
|
||||||
text="signin_with"
|
text="signin_with"
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { api } from "@/lib/api";
|
|||||||
import type { Memo } from "@/lib/api";
|
import type { Memo } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import Stars from "@/components/Stars";
|
||||||
|
|
||||||
interface MemoSectionProps {
|
interface MemoSectionProps {
|
||||||
restaurantId: string;
|
restaurantId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #281 — ReviewSection의 StarSelector와 동일 UX (0.5 단위 + 44px 터치 + ARIA radiogroup)
|
||||||
function StarSelector({
|
function StarSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -18,38 +20,32 @@ function StarSelector({
|
|||||||
onChange: (v: number) => void;
|
onChange: (v: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div role="radiogroup" aria-label="별점 선택" className="flex items-center gap-0.5">
|
||||||
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
||||||
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
|
{[1, 2, 3, 4, 5].map((v) => {
|
||||||
<button
|
const nextVal = value === v ? v - 0.5 : v;
|
||||||
key={v}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onChange(v)}
|
key={v}
|
||||||
className={`w-6 h-6 text-xs rounded border ${
|
type="button"
|
||||||
value === v
|
role="radio"
|
||||||
? "bg-yellow-500 text-white border-yellow-600"
|
aria-checked={value >= v - 0.5 && value <= v}
|
||||||
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
|
aria-label={`${nextVal}점`}
|
||||||
}`}
|
onClick={() => onChange(nextVal)}
|
||||||
>
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center touch-manipulation"
|
||||||
{v}
|
title={`${nextVal}점`}
|
||||||
</button>
|
>
|
||||||
))}
|
<span className={`text-xl ${v <= value ? "text-yellow-500" : v - 0.5 === value ? "text-yellow-400" : "text-gray-300"}`}>
|
||||||
|
{v <= value ? "★" : v - 0.5 === value ? "⯨" : "☆"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{value > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{value}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarDisplay({ rating }: { rating: number }) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
stars.push(
|
|
||||||
<span key={i} className={rating >= i - 0.5 ? "text-yellow-500" : "text-gray-300"}>
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span className="text-sm">{stars}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [memo, setMemo] = useState<Memo | null>(null);
|
const [memo, setMemo] = useState<Memo | null>(null);
|
||||||
@@ -104,6 +100,9 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
|||||||
setMemo(saved);
|
setMemo(saved);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
// #281 — 사용자 피드백
|
||||||
|
alert(`메모 저장 실패: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -111,8 +110,12 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm("메모를 삭제하시겠습니까?")) return;
|
if (!confirm("메모를 삭제하시겠습니까?")) return;
|
||||||
await api.deleteMemo(restaurantId);
|
try {
|
||||||
setMemo(null);
|
await api.deleteMemo(restaurantId);
|
||||||
|
setMemo(null);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`메모 삭제 실패: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,7 +170,7 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
|||||||
) : memo ? (
|
) : memo ? (
|
||||||
<div className="border border-brand-200 rounded-lg p-3 bg-brand-50/30">
|
<div className="border border-brand-200 rounded-lg p-3 bg-brand-50/30">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{memo.rating && <StarDisplay rating={memo.rating} />}
|
{memo.rating && <Stars rating={memo.rating} />}
|
||||||
{memo.visited_at && (
|
{memo.visited_at && (
|
||||||
<span className="text-xs text-gray-400">방문일: {memo.visited_at}</span>
|
<span className="text-xs text-gray-400">방문일: {memo.visited_at}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Review, Memo } from "@/lib/api";
|
import type { Review, Memo } from "@/lib/api";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import Stars from "@/components/Stars";
|
||||||
|
|
||||||
interface MyReview extends Review {
|
interface MyReview extends Review {
|
||||||
restaurant_id: string;
|
restaurant_id: string;
|
||||||
@@ -82,9 +83,9 @@ export default function MyReviewsList({
|
|||||||
<span className="font-semibold text-sm truncate">
|
<span className="font-semibold text-sm truncate">
|
||||||
{r.restaurant_name || "알 수 없는 식당"}
|
{r.restaurant_name || "알 수 없는 식당"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
<span className="text-sm shrink-0 ml-2 flex items-center gap-1">
|
||||||
{"★".repeat(Math.round(r.rating))}
|
<Stars rating={r.rating} />
|
||||||
<span className="text-gray-500 ml-1">{r.rating}</span>
|
<span className="text-gray-500">{r.rating}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{r.review_text && (
|
{r.review_text && (
|
||||||
@@ -118,9 +119,9 @@ export default function MyReviewsList({
|
|||||||
{m.restaurant_name || "알 수 없는 식당"}
|
{m.restaurant_name || "알 수 없는 식당"}
|
||||||
</span>
|
</span>
|
||||||
{m.rating && (
|
{m.rating && (
|
||||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
<span className="text-sm shrink-0 ml-2 flex items-center gap-1">
|
||||||
{"★".repeat(Math.round(m.rating))}
|
<Stars rating={m.rating} />
|
||||||
<span className="text-gray-500 ml-1">{m.rating}</span>
|
<span className="text-gray-500">{m.rating}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,37 +4,12 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Review } from "@/lib/api";
|
import type { Review } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import Stars from "@/components/Stars";
|
||||||
|
|
||||||
interface ReviewSectionProps {
|
interface ReviewSectionProps {
|
||||||
restaurantId: string;
|
restaurantId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarDisplay({ rating }: { rating: number }) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
if (rating >= i) {
|
|
||||||
stars.push(
|
|
||||||
<span key={i} className="text-yellow-500">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else if (rating >= i - 0.5) {
|
|
||||||
stars.push(
|
|
||||||
<span key={i} className="text-yellow-500">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
stars.push(
|
|
||||||
<span key={i} className="text-gray-300">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return <span className="text-sm">{stars}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StarSelector({
|
function StarSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -43,22 +18,30 @@ function StarSelector({
|
|||||||
onChange: (v: number) => void;
|
onChange: (v: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div role="radiogroup" aria-label="별점 선택" className="flex items-center gap-0.5">
|
||||||
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
||||||
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
|
{[1, 2, 3, 4, 5].map((v) => {
|
||||||
<button
|
const isCurrent = value === v || value === v - 0.5;
|
||||||
key={v}
|
const nextVal = value === v ? v - 0.5 : v;
|
||||||
type="button"
|
return (
|
||||||
onClick={() => onChange(v)}
|
<button
|
||||||
className={`w-6 h-6 text-xs rounded border ${
|
key={v}
|
||||||
value === v
|
type="button"
|
||||||
? "bg-yellow-500 text-white border-yellow-600"
|
role="radio"
|
||||||
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
|
aria-checked={value >= v - 0.5 && value <= v}
|
||||||
}`}
|
aria-label={`${nextVal}점`}
|
||||||
>
|
onClick={() => onChange(nextVal)}
|
||||||
{v}
|
// #281 — 최소 터치 영역 44×44
|
||||||
</button>
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center touch-manipulation"
|
||||||
))}
|
title={`${nextVal}점`}
|
||||||
|
>
|
||||||
|
<span className={`text-xl ${v <= value ? "text-yellow-500" : v - 0.5 === value ? "text-yellow-400" : "text-gray-300"}`}>
|
||||||
|
{v <= value ? "★" : v - 0.5 === value ? "⯨" : "☆"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{value > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{value}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,29 +153,42 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
|||||||
? reviews.find((r) => r.user_id === user.id)
|
? reviews.find((r) => r.user_id === user.id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// #281 — API 실패 시 unhandled rejection 방지 + 사용자 피드백
|
||||||
const handleCreate = async (data: {
|
const handleCreate = async (data: {
|
||||||
rating: number;
|
rating: number;
|
||||||
review_text?: string;
|
review_text?: string;
|
||||||
visited_at?: string;
|
visited_at?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await api.createReview(restaurantId, data);
|
try {
|
||||||
setShowForm(false);
|
await api.createReview(restaurantId, data);
|
||||||
loadReviews();
|
setShowForm(false);
|
||||||
|
loadReviews();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`리뷰 작성 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (
|
const handleUpdate = async (
|
||||||
reviewId: string,
|
reviewId: string,
|
||||||
data: { rating: number; review_text?: string; visited_at?: string }
|
data: { rating: number; review_text?: string; visited_at?: string }
|
||||||
) => {
|
) => {
|
||||||
await api.updateReview(reviewId, data);
|
try {
|
||||||
setEditingId(null);
|
await api.updateReview(reviewId, data);
|
||||||
loadReviews();
|
setEditingId(null);
|
||||||
|
loadReviews();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`리뷰 수정 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (reviewId: string) => {
|
const handleDelete = async (reviewId: string) => {
|
||||||
if (!confirm("리뷰를 삭제하시겠습니까?")) return;
|
if (!confirm("리뷰를 삭제하시겠습니까?")) return;
|
||||||
await api.deleteReview(reviewId);
|
try {
|
||||||
loadReviews();
|
await api.deleteReview(reviewId);
|
||||||
|
loadReviews();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`리뷰 삭제 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +212,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
|||||||
<>
|
<>
|
||||||
{reviewCount > 0 && avgRating !== null && (
|
{reviewCount > 0 && avgRating !== null && (
|
||||||
<div className="flex items-center gap-2 mb-3 text-sm">
|
<div className="flex items-center gap-2 mb-3 text-sm">
|
||||||
<StarDisplay rating={Math.round(avgRating * 2) / 2} />
|
<Stars rating={Math.round(avgRating * 2) / 2} />
|
||||||
<span className="font-medium">{avgRating.toFixed(1)}</span>
|
<span className="font-medium">{avgRating.toFixed(1)}</span>
|
||||||
<span className="text-gray-500">({reviewCount}개)</span>
|
<span className="text-gray-500">({reviewCount}개)</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +266,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{review.user_nickname || "익명"}
|
{review.user_nickname || "익명"}
|
||||||
</span>
|
</span>
|
||||||
<StarDisplay rating={review.rating} />
|
<Stars rating={review.rating} />
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{new Date(review.created_at).toLocaleDateString(
|
{new Date(review.created_at).toLocaleDateString(
|
||||||
"ko-KR"
|
"ko-KR"
|
||||||
|
|||||||
37
frontend/src/components/Stars.tsx
Normal file
37
frontend/src/components/Stars.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// #281 공통 별점 컴포넌트 — ReviewSection/MemoSection/MyReviewsList 재사용.
|
||||||
|
// 0.5 단위 시각 구분: 빈 별 위에 황색 절반 별을 절대배치 + clip으로 표시.
|
||||||
|
|
||||||
|
interface StarsProps {
|
||||||
|
rating: number; // 0~5, 0.5 단위
|
||||||
|
size?: "sm" | "md";
|
||||||
|
showNumber?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Stars({ rating, size = "sm", showNumber = false, className = "" }: StarsProps) {
|
||||||
|
const r = Math.max(0, Math.min(5, rating));
|
||||||
|
const textSize = size === "md" ? "text-base" : "text-sm";
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-0.5 ${textSize} ${className}`} aria-label={`${r}점`}>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => {
|
||||||
|
const full = r >= i;
|
||||||
|
const half = !full && r >= i - 0.5;
|
||||||
|
return (
|
||||||
|
<span key={i} className="relative inline-block leading-none">
|
||||||
|
<span className="text-gray-300">★</span>
|
||||||
|
{(full || half) && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 text-yellow-500 overflow-hidden"
|
||||||
|
style={{ width: full ? "100%" : "50%" }}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showNumber && r > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{r}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user