diff --git a/frontend/src/components/LoginMenu.tsx b/frontend/src/components/LoginMenu.tsx index acef55e..de22701 100644 --- a/frontend/src/components/LoginMenu.tsx +++ b/frontend/src/components/LoginMenu.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { createPortal } from "react-dom"; import { GoogleLogin } from "@react-oauth/google"; +import { useEscapeKey, useFocusTrap, useBodyScrollLock } from "@/lib/hooks/useModalA11y"; interface LoginMenuProps { onGoogleSuccess: (credential: string) => void; @@ -10,6 +11,22 @@ interface LoginMenuProps { export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) { const [open, setOpen] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + const dialogRef = useRef(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 ( <> @@ -26,26 +43,34 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) { style={{ zIndex: 99999 }} onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }} > -
+
-

로그인

+

로그인

소셜 계정으로 간편 로그인

+ {errorMsg && ( +

+ {errorMsg} +

+ )}
{ - if (res.credential) { - onGoogleSuccess(res.credential); - setOpen(false); - } - }} - onError={() => console.error("Google login failed")} + onSuccess={handleSuccess} + onError={() => setErrorMsg("Google 로그인에 실패했습니다. 팝업 차단 또는 네트워크 상태를 확인해주세요.")} size="large" width="260" text="signin_with" diff --git a/frontend/src/components/MemoSection.tsx b/frontend/src/components/MemoSection.tsx index 1aa1cb8..6b45f81 100644 --- a/frontend/src/components/MemoSection.tsx +++ b/frontend/src/components/MemoSection.tsx @@ -5,11 +5,13 @@ import { api } from "@/lib/api"; import type { Memo } from "@/lib/api"; import { useAuth } from "@/lib/auth-context"; import Icon from "@/components/Icon"; +import Stars from "@/components/Stars"; interface MemoSectionProps { restaurantId: string; } +// #281 — ReviewSection의 StarSelector와 동일 UX (0.5 단위 + 44px 터치 + ARIA radiogroup) function StarSelector({ value, onChange, @@ -18,38 +20,32 @@ function StarSelector({ onChange: (v: number) => void; }) { return ( -
+
별점: - {[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => ( - - ))} + {[1, 2, 3, 4, 5].map((v) => { + const nextVal = value === v ? v - 0.5 : v; + return ( + + ); + })} + {value > 0 && {value}}
); } -function StarDisplay({ rating }: { rating: number }) { - const stars = []; - for (let i = 1; i <= 5; i++) { - stars.push( - = i - 0.5 ? "text-yellow-500" : "text-gray-300"}> - ★ - - ); - } - return {stars}; -} - export default function MemoSection({ restaurantId }: MemoSectionProps) { const { user } = useAuth(); const [memo, setMemo] = useState(null); @@ -104,6 +100,9 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) { setMemo(saved); setShowForm(false); setEditing(false); + } catch (err) { + // #281 — 사용자 피드백 + alert(`메모 저장 실패: ${err instanceof Error ? err.message : String(err)}`); } finally { setSubmitting(false); } @@ -111,8 +110,12 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) { const handleDelete = async () => { if (!confirm("메모를 삭제하시겠습니까?")) return; - await api.deleteMemo(restaurantId); - setMemo(null); + try { + await api.deleteMemo(restaurantId); + setMemo(null); + } catch (err) { + alert(`메모 삭제 실패: ${err instanceof Error ? err.message : String(err)}`); + } }; return ( @@ -167,7 +170,7 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) { ) : memo ? (
- {memo.rating && } + {memo.rating && } {memo.visited_at && ( 방문일: {memo.visited_at} )} diff --git a/frontend/src/components/MyReviewsList.tsx b/frontend/src/components/MyReviewsList.tsx index 49d3d65..576a29e 100644 --- a/frontend/src/components/MyReviewsList.tsx +++ b/frontend/src/components/MyReviewsList.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import type { Review, Memo } from "@/lib/api"; import Icon from "@/components/Icon"; +import Stars from "@/components/Stars"; interface MyReview extends Review { restaurant_id: string; @@ -82,9 +83,9 @@ export default function MyReviewsList({ {r.restaurant_name || "알 수 없는 식당"} - - {"★".repeat(Math.round(r.rating))} - {r.rating} + + + {r.rating}
{r.review_text && ( @@ -118,9 +119,9 @@ export default function MyReviewsList({ {m.restaurant_name || "알 수 없는 식당"} {m.rating && ( - - {"★".repeat(Math.round(m.rating))} - {m.rating} + + + {m.rating} )}
diff --git a/frontend/src/components/ReviewSection.tsx b/frontend/src/components/ReviewSection.tsx index 581210a..42cd2f3 100644 --- a/frontend/src/components/ReviewSection.tsx +++ b/frontend/src/components/ReviewSection.tsx @@ -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( - - ★ - - ); - } else if (rating >= i - 0.5) { - stars.push( - - ★ - - ); - } else { - stars.push( - - ★ - - ); - } - } - return {stars}; -} - function StarSelector({ value, onChange, @@ -43,22 +18,30 @@ function StarSelector({ onChange: (v: number) => void; }) { return ( -
+
별점: - {[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => ( - - ))} + {[1, 2, 3, 4, 5].map((v) => { + const isCurrent = value === v || value === v - 0.5; + const nextVal = value === v ? v - 0.5 : v; + return ( + + ); + })} + {value > 0 && {value}}
); } @@ -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 && (
- + {avgRating.toFixed(1)} ({reviewCount}개)
@@ -270,7 +266,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) { {review.user_nickname || "익명"} - + {new Date(review.created_at).toLocaleDateString( "ko-KR" diff --git a/frontend/src/components/Stars.tsx b/frontend/src/components/Stars.tsx new file mode 100644 index 0000000..2210744 --- /dev/null +++ b/frontend/src/components/Stars.tsx @@ -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 ( + + {[1, 2, 3, 4, 5].map((i) => { + const full = r >= i; + const half = !full && r >= i - 0.5; + return ( + + + {(full || half) && ( + + )} + + ); + })} + {showNumber && r > 0 && {r}} + + ); +}