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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user