Files
tasteby/frontend/src/components/ReviewSection.tsx
joungmin 730727a7a6 test(frontend): #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns
테스트 인프라:
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers
- next/jest로 SWC/Next.js 자동 통합
- jest.config.ts (setupFilesAfterEnv) + jest.setup.ts
- npm scripts: test, test:watch
- 샘플 테스트 3개, 13/13 통과:
  - i18n/config: isLocale + detectBrowserLocale (5 케이스)
  - Stars 컴포넌트: 별점/aria/clamp/showNumber (5 케이스)
  - admin-utils: getAdminToken + authHeaders (4 케이스)

ARIA Tabs (MyReviewsList):
- role=tablist + tab + aria-selected + aria-controls + tabIndex
- panel에 role=tabpanel + aria-labelledby

next/image:
- next.config.ts remotePatterns: lh3.googleusercontent.com / i.ytimg.com / yt3.ggpht.com
- ReviewSection의 user_avatar_url에 명시적 eslint-disable + 사유

후속(별도): 전체 컴포넌트 테스트 점진 추가, 백엔드 JUnit 인프라, E2E (Playwright), CI 통합

설계서: docs/design/343-frontend-test-infra/README.md

Refs: #343
2026-06-15 16:25:55 +09:00

316 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
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 StarSelector({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
return (
<div role="radiogroup" aria-label="별점 선택" className="flex items-center gap-0.5">
<span className="text-xs text-gray-500 mr-1">:</span>
{[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>
);
}
function ReviewForm({
initialRating = 3,
initialText = "",
initialDate = "",
onSubmit,
onCancel,
submitLabel,
}: {
initialRating?: number;
initialText?: string;
initialDate?: string;
onSubmit: (data: {
rating: number;
review_text?: string;
visited_at?: string;
}) => Promise<void>;
onCancel: () => void;
submitLabel: string;
}) {
const [rating, setRating] = useState(initialRating);
const [text, setText] = useState(initialText);
const [visitedAt, setVisitedAt] = useState(initialDate);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await onSubmit({
rating,
review_text: text || undefined,
visited_at: visitedAt || undefined,
});
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-3 border rounded-lg p-3">
<StarSelector value={rating} onChange={setRating} />
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="리뷰를 작성해주세요 (선택)"
className="w-full border rounded p-2 text-sm resize-none"
rows={3}
/>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500">:</label>
<input
type="date"
value={visitedAt}
onChange={(e) => setVisitedAt(e.target.value)}
className="border rounded px-2 py-1 text-xs"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={submitting}
className="px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500 disabled:opacity-50"
>
{submitting ? "저장 중..." : submitLabel}
</button>
<button
type="button"
onClick={onCancel}
className="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
>
</button>
</div>
</form>
);
}
export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
const { user } = useAuth();
const [reviews, setReviews] = useState<Review[]>([]);
const [avgRating, setAvgRating] = useState<number | null>(null);
const [reviewCount, setReviewCount] = useState(0);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const loadReviews = useCallback(() => {
setLoading(true);
api
.getReviews(restaurantId)
.then((data) => {
setReviews(data.reviews);
setAvgRating(data.avg_rating);
setReviewCount(data.review_count);
})
.catch(() => setReviews([]))
.finally(() => setLoading(false));
}, [restaurantId]);
useEffect(() => {
loadReviews();
}, [loadReviews]);
const myReview = user
? 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;
}) => {
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 }
) => {
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;
try {
await api.deleteReview(reviewId);
loadReviews();
} catch (e) {
alert(`리뷰 삭제 실패: ${e instanceof Error ? e.message : String(e)}`);
}
};
return (
<div>
<h3 className="font-semibold text-sm mb-2"></h3>
{loading ? (
<div className="space-y-3 animate-pulse">
<div className="flex items-center gap-2">
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-8 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
{[1, 2].map((i) => (
<div key={i} className="space-y-1">
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-full bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
) : (
<>
{reviewCount > 0 && avgRating !== null && (
<div className="flex items-center gap-2 mb-3 text-sm">
<Stars rating={Math.round(avgRating * 2) / 2} />
<span className="font-medium">{avgRating.toFixed(1)}</span>
<span className="text-gray-500">({reviewCount})</span>
</div>
)}
{user && !myReview && !showForm && (
<button
onClick={() => setShowForm(true)}
className="mb-3 px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500"
>
</button>
)}
{showForm && (
<div className="mb-3">
<ReviewForm
initialDate={new Date().toISOString().slice(0, 10)}
onSubmit={handleCreate}
onCancel={() => setShowForm(false)}
submitLabel="작성"
/>
</div>
)}
{reviews.length === 0 ? (
<p className="text-sm text-gray-500"> </p>
) : (
<div className="space-y-3">
{reviews.map((review) => (
<div key={review.id} className="border rounded-lg p-3">
{editingId === review.id ? (
<ReviewForm
initialRating={review.rating}
initialText={review.review_text || ""}
initialDate={review.visited_at || ""}
onSubmit={(data) => handleUpdate(review.id, data)}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<>
<div className="flex items-center gap-2 mb-1">
{review.user_avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
// #343 — Google avatar URL은 remotePatterns에 추가됨.
// next/image 전환은 SSR/lazy 효과 미미한 5x5 아바타라 후속에서 일괄 적용.
<img
src={review.user_avatar_url}
alt=""
className="w-5 h-5 rounded-full"
/>
)}
<span className="text-sm font-medium">
{review.user_nickname || "익명"}
</span>
<Stars rating={review.rating} />
<span className="text-xs text-gray-400">
{new Date(review.created_at).toLocaleDateString(
"ko-KR"
)}
</span>
</div>
{review.review_text && (
<p className="text-sm text-gray-700 mt-1">
{review.review_text}
</p>
)}
{review.visited_at && (
<p className="text-xs text-gray-400 mt-1">
: {review.visited_at}
</p>
)}
{user && review.user_id === user.id && (
<div className="flex gap-2 mt-2">
<button
onClick={() => setEditingId(review.id)}
className="text-xs text-blue-600 hover:underline"
>
</button>
<button
onClick={() => handleDelete(review.id)}
className="text-xs text-red-600 hover:underline"
>
</button>
</div>
)}
</>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}