feat(ui): P4-4 별점 공통화 + 로그인 모달 접근성 (#281+#283)

#281 (리뷰/메모 UI):
- Stars 컴포넌트 신규 (lib 분리 가능한 공통 별점) — 0.5 단위 절반 채우기 시각 구분
- ReviewSection/MemoSection의 StarDisplay 제거 → 공통 Stars 사용 (시각 일관성)
- StarSelector: role='radiogroup'/role='radio' + aria-checked, 44×44px 터치 영역,
  반쪽 별 '⯨' 표시로 시각 차별화
- ReviewSection/MemoSection: API 실패 try/catch + alert 사용자 피드백
- MyReviewsList: Math.round 별점 → Stars 0.5단위 정확 렌더

#283 (로그인 메뉴):
- LoginMenu: useEscapeKey + useFocusTrap + useBodyScrollLock 적용
- role='dialog' / aria-modal / aria-labelledby / aria-label='로그인 창 닫기'
- onError 콘솔만 → 인라인 role='alert' 메시지로 사용자 피드백
- max-w-xs → max-w-sm (위젯 260px + 패딩 24px = 308px 안전 수용)

후속 분리:
- #343 (next/image + ARIA Tabs + Stars 테스트)
- #344 (z-index 토큰 + i18n)

Refs: #281 #283
This commit is contained in:
joungmin
2026-06-15 14:33:15 +09:00
parent ea8db4bef3
commit bff3dcc200
5 changed files with 160 additions and 98 deletions

View File

@@ -4,37 +4,12 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { Review } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import Stars from "@/components/Stars";
interface ReviewSectionProps {
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({
value,
onChange,
@@ -43,22 +18,30 @@ function StarSelector({
onChange: (v: number) => void;
}) {
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>
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
<button
key={v}
type="button"
onClick={() => onChange(v)}
className={`w-6 h-6 text-xs rounded border ${
value === v
? "bg-yellow-500 text-white border-yellow-600"
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
}`}
>
{v}
</button>
))}
{[1, 2, 3, 4, 5].map((v) => {
const isCurrent = value === v || value === v - 0.5;
const nextVal = value === v ? v - 0.5 : v;
return (
<button
key={v}
type="button"
role="radio"
aria-checked={value >= v - 0.5 && value <= v}
aria-label={`${nextVal}`}
onClick={() => onChange(nextVal)}
// #281 — 최소 터치 영역 44×44
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>
);
}
@@ -170,29 +153,42 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
? reviews.find((r) => r.user_id === user.id)
: null;
// #281 — API 실패 시 unhandled rejection 방지 + 사용자 피드백
const handleCreate = async (data: {
rating: number;
review_text?: string;
visited_at?: string;
}) => {
await api.createReview(restaurantId, data);
setShowForm(false);
loadReviews();
try {
await api.createReview(restaurantId, data);
setShowForm(false);
loadReviews();
} catch (e) {
alert(`리뷰 작성 실패: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleUpdate = async (
reviewId: string,
data: { rating: number; review_text?: string; visited_at?: string }
) => {
await api.updateReview(reviewId, data);
setEditingId(null);
loadReviews();
try {
await api.updateReview(reviewId, data);
setEditingId(null);
loadReviews();
} catch (e) {
alert(`리뷰 수정 실패: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleDelete = async (reviewId: string) => {
if (!confirm("리뷰를 삭제하시겠습니까?")) return;
await api.deleteReview(reviewId);
loadReviews();
try {
await api.deleteReview(reviewId);
loadReviews();
} catch (e) {
alert(`리뷰 삭제 실패: ${e instanceof Error ? e.message : String(e)}`);
}
};
return (
@@ -216,7 +212,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
<>
{reviewCount > 0 && avgRating !== null && (
<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="text-gray-500">({reviewCount})</span>
</div>
@@ -270,7 +266,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
<span className="text-sm font-medium">
{review.user_nickname || "익명"}
</span>
<StarDisplay rating={review.rating} />
<Stars rating={review.rating} />
<span className="text-xs text-gray-400">
{new Date(review.created_at).toLocaleDateString(
"ko-KR"