From bff3dcc200a11cc2da46d0559ea86c3edbc4cee5 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 14:33:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20P4-4=20=EB=B3=84=EC=A0=90=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20+=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EC=A0=91=EA=B7=BC=EC=84=B1=20(#281+#28?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- frontend/src/components/LoginMenu.tsx | 47 ++++++++--- frontend/src/components/MemoSection.tsx | 63 ++++++++------- frontend/src/components/MyReviewsList.tsx | 13 +-- frontend/src/components/ReviewSection.tsx | 98 +++++++++++------------ frontend/src/components/Stars.tsx | 37 +++++++++ 5 files changed, 160 insertions(+), 98 deletions(-) create mode 100644 frontend/src/components/Stars.tsx 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}} + + ); +}