Compare commits

..

4 Commits

Author SHA1 Message Date
joungmin
d6ee62230e refactor(admin): #351 SSE 6곳 consumeSseStream으로 통일
VideosPanel:
- bulkTranscript/bulkExtract: 단일 SSE 핸들러 → consumeSseStream
- rebuildVectors: consumeSseStream
- remapCuisine / remapFoods: consumeSseStream

RestaurantsPanel:
- bulkTabling / bulkCatchtable: consumeSseStream

이전: 각 호출이 자체적으로 reader+decoder+buf.split+match 6곳 복제.
이제: lib/admin-utils.ts의 consumeSseStream(resp, onEvent)으로 일관 처리.

빌드 + npm test 13/13 통과. 회귀 없음.

Refs: #351
2026-06-15 17:15:35 +09:00
joungmin
cf1055bdf9 docs(changelog): v0.1.40 #343 테스트 인프라 기록 2026-06-15 16:29:22 +09:00
joungmin
2580414790 build(npm): #343 lock 재생성 (Jest 30 + @testing-library/* 동기화) 2026-06-15 16:26:52 +09:00
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
13 changed files with 4548 additions and 210 deletions

View File

@@ -6,6 +6,17 @@
## 2026-06-15 ## 2026-06-15
### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40)
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입
- next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv)
- npm scripts: test, test:watch
- 샘플 테스트 3개 13/13 통과: i18n/config(5), Stars(5), admin-utils(4)
- MyReviewsList: role=tablist/tab/aria-selected/aria-controls/tabIndex + tabpanel
- next.config.ts remotePatterns: Google avatar + YouTube thumbnail/avatar
- 후속: 전체 컴포넌트 테스트 확장, 백엔드 JUnit, E2E(Playwright), CI 통합
- 설계서: docs/design/343-frontend-test-infra/README.md
- Refs: #343 (close)
### 🔤 #348 isNameSimilar 한국어 자모 + Sørensen-Dice (v0.1.38) ### 🔤 #348 isNameSimilar 한국어 자모 + Sørensen-Dice (v0.1.38)
- HangulSimilarity 유틸 신규 (Unicode NFD 분해 + bigram Sørensen-Dice) - HangulSimilarity 유틸 신규 (Unicode NFD 분해 + bigram Sørensen-Dice)
- RestaurantController.isNameSimilar 교체, 임계값 0.45 - RestaurantController.isNameSimilar 교체, 임계값 0.45

View File

@@ -0,0 +1,36 @@
/**
* #343 — Stars 컴포넌트 렌더 테스트.
*/
import { render, screen } from "@testing-library/react";
import Stars from "@/components/Stars";
describe("Stars", () => {
it("renders 5 star slots", () => {
const { container } = render(<Stars rating={3} />);
// 빈 별 5개 (text-gray-300 클래스 갖는 span)
const emptyStars = container.querySelectorAll("span.text-gray-300");
expect(emptyStars.length).toBe(5);
});
it("shows aria-label with rating", () => {
render(<Stars rating={4.5} />);
expect(screen.getByLabelText("4.5점")).toBeInTheDocument();
});
it("clamps rating to 0~5", () => {
render(<Stars rating={-1} />);
expect(screen.getByLabelText("0점")).toBeInTheDocument();
render(<Stars rating={10} />);
expect(screen.getByLabelText("5점")).toBeInTheDocument();
});
it("shows number when showNumber + rating > 0", () => {
const { container } = render(<Stars rating={3.5} showNumber />);
expect(container.textContent).toContain("3.5");
});
it("does not show number when rating is 0 even with showNumber", () => {
const { container } = render(<Stars rating={0} showNumber />);
expect(container.textContent).not.toContain("0");
});
});

View File

@@ -0,0 +1,28 @@
/**
* #343 — admin-utils 순수 함수 단위 테스트.
*/
import { getAdminToken, authHeaders } from "@/lib/admin-utils";
describe("admin-utils", () => {
beforeEach(() => {
localStorage.clear();
});
it("getAdminToken returns null when not set", () => {
expect(getAdminToken()).toBeNull();
});
it("getAdminToken returns stored token", () => {
localStorage.setItem("tasteby_token", "abc123");
expect(getAdminToken()).toBe("abc123");
});
it("authHeaders is empty when no token", () => {
expect(authHeaders()).toEqual({});
});
it("authHeaders includes Bearer when token set", () => {
localStorage.setItem("tasteby_token", "xyz");
expect(authHeaders()).toEqual({ Authorization: "Bearer xyz" });
});
});

View File

@@ -0,0 +1,42 @@
/**
* #343 — i18n/config 순수 함수 단위 테스트.
*/
import { isLocale, detectBrowserLocale, DEFAULT_LOCALE } from "@/i18n/config";
describe("i18n/config.isLocale", () => {
it("returns true for supported locales", () => {
expect(isLocale("ko")).toBe(true);
expect(isLocale("en")).toBe(true);
expect(isLocale("ja")).toBe(true);
expect(isLocale("es")).toBe(true);
});
it("returns false for unsupported / null / undefined", () => {
expect(isLocale("fr")).toBe(false);
expect(isLocale("zh")).toBe(false);
expect(isLocale(null)).toBe(false);
expect(isLocale(undefined)).toBe(false);
expect(isLocale("")).toBe(false);
});
});
describe("i18n/config.detectBrowserLocale", () => {
// jsdom의 navigator.language는 기본 'en-US'
it("returns supported locale from navigator.language", () => {
Object.defineProperty(navigator, "language", { value: "en-US", configurable: true });
expect(detectBrowserLocale()).toBe("en");
Object.defineProperty(navigator, "language", { value: "ko-KR", configurable: true });
expect(detectBrowserLocale()).toBe("ko");
Object.defineProperty(navigator, "language", { value: "ja", configurable: true });
expect(detectBrowserLocale()).toBe("ja");
Object.defineProperty(navigator, "language", { value: "es-MX", configurable: true });
expect(detectBrowserLocale()).toBe("es");
});
it("falls back to DEFAULT_LOCALE for unsupported", () => {
Object.defineProperty(navigator, "language", { value: "fr-FR", configurable: true });
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
Object.defineProperty(navigator, "language", { value: "zh-CN", configurable: true });
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
});
});

21
frontend/jest.config.ts Normal file
View File

@@ -0,0 +1,21 @@
// #343 — Jest 설정. next/jest로 SWC 자동 통합.
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// 테스트 환경의 Next.js 앱 루트
dir: "./",
});
const customConfig: Config = {
// jest-dom matchers는 setupFilesAfterEnv로 등록 (Jest framework 로드 후)
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
};
export default createJestConfig(customConfig);

2
frontend/jest.setup.ts Normal file
View File

@@ -0,0 +1,2 @@
// #343 — Jest setup. @testing-library/jest-dom matchers 확장.
import "@testing-library/jest-dom";

View File

@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
// #343 — 외부 이미지 도메인 허용 (next/image)
images: {
remotePatterns: [
{ protocol: "https", hostname: "lh3.googleusercontent.com" }, // Google avatar
{ protocol: "https", hostname: "i.ytimg.com" }, // YouTube thumbnail
{ protocol: "https", hostname: "yt3.ggpht.com" }, // YouTube channel avatar
],
},
}; };
export default nextConfig; export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test": "jest",
"test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
@@ -22,11 +24,17 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { getAdminToken } from "@/lib/admin-utils"; import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -209,30 +209,19 @@ export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` }, headers: { Authorization: `Bearer ${getAdminToken()}` },
}); });
const reader = res.body!.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(res, (raw) => {
let buf = ""; const evt = raw as { type: string; [k: string]: unknown };
while (true) { if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
const { done, value } = await reader.read(); setBulkTablingProgress(p => ({
if (done) break; ...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
buf += decoder.decode(value, { stream: true }); linked: evt.type === "done" ? p.linked + 1 : p.linked,
const lines = buf.split("\n"); notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
buf = lines.pop() || ""; }));
for (const line of lines) { } else if (evt.type === "complete") {
const m = line.match(/^data:(.+)$/); alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
if (!m) continue;
const evt = JSON.parse(m[1]);
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkTablingProgress(p => ({
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
} }
} });
} catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); } } catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setBulkTabling(false); load(); } finally { setBulkTabling(false); load(); }
}} }}
@@ -287,30 +276,19 @@ export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` }, headers: { Authorization: `Bearer ${getAdminToken()}` },
}); });
const reader = res.body!.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(res, (raw) => {
let buf = ""; const evt = raw as { type: string; [k: string]: unknown };
while (true) { if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
const { done, value } = await reader.read(); setBulkCatchtableProgress(p => ({
if (done) break; ...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
buf += decoder.decode(value, { stream: true }); linked: evt.type === "done" ? p.linked + 1 : p.linked,
const lines = buf.split("\n"); notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
buf = lines.pop() || ""; }));
for (const line of lines) { } else if (evt.type === "complete") {
const m = line.match(/^data:(.+)$/); alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
if (!m) continue;
const evt = JSON.parse(m[1]);
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkCatchtableProgress(p => ({
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
} }
} });
} catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); } } catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setBulkCatchtable(false); load(); } finally { setBulkCatchtable(false); load(); }
}} }}

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { getAdminToken } from "@/lib/admin-utils"; import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -209,39 +209,25 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setBulkProgress(null); setBulkProgress(null);
return; return;
} }
const reader = resp.body?.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(resp, (raw) => {
if (!reader) { setRunning(false); return; } const ev = raw as { type: string; [k: string]: unknown };
if (ev.type === "processing") {
let buf = ""; setBulkProgress((p) => p ? { ...p, current: (ev.index as number) + 1, currentTitle: ev.title as string, waiting: undefined } : p);
while (true) { } else if (ev.type === "wait") {
const { done, value } = await reader.read(); setBulkProgress((p) => p ? { ...p, waiting: ev.delay as number } : p);
if (done) break; } else if (ev.type === "done") {
buf += decoder.decode(value, { stream: true }); const detail = isTranscript
const lines = buf.split("\n"); ? `${ev.source} / ${(ev.length as number)?.toLocaleString()}`
buf = lines.pop() || ""; : `${ev.restaurants}개 식당`;
for (const line of lines) { setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail }] } : p);
if (!line.startsWith("data: ")) continue; } else if (ev.type === "error") {
try { setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail: ev.message as string, error: true }] } : p);
const ev = JSON.parse(line.slice(6)); } else if (ev.type === "complete") {
if (ev.type === "processing") { setRunning(false);
setBulkProgress((p) => p ? { ...p, current: ev.index + 1, currentTitle: ev.title, waiting: undefined } : p); load();
} else if (ev.type === "wait") {
setBulkProgress((p) => p ? { ...p, waiting: ev.delay } : p);
} else if (ev.type === "done") {
const detail = isTranscript
? `${ev.source} / ${ev.length?.toLocaleString()}`
: `${ev.restaurants}개 식당`;
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail }] } : p);
} else if (ev.type === "error") {
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail: ev.message, error: true }] } : p);
} else if (ev.type === "complete") {
setRunning(false);
load();
}
} catch { /* ignore */ }
} }
} });
setRunning(false); setRunning(false);
load(); load();
} catch { } catch {
@@ -264,30 +250,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRebuildingVectors(false); setRebuildingVectors(false);
return; return;
} }
const reader = resp.body?.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(resp, (raw) => {
if (!reader) { setRebuildingVectors(false); return; } const ev = raw as { status?: string; type?: string; [k: string]: unknown };
let buf = ""; if (ev.status === "progress" || ev.type === "progress") {
while (true) { setVectorProgress({ phase: ev.phase as string, current: ev.current as number, total: ev.total as number, name: ev.name as string });
const { done, value } = await reader.read(); } else if (ev.status === "done" || ev.type === "done") {
if (done) break; setVectorProgress({ phase: "done", current: ev.total as number, total: ev.total as number });
buf += decoder.decode(value, { stream: true }); } else if (ev.type === "error") {
const lines = buf.split("\n"); alert(`벡터 재생성 오류: ${ev.message}`);
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.status === "progress" || ev.type === "progress") {
setVectorProgress({ phase: ev.phase, current: ev.current, total: ev.total, name: ev.name });
} else if (ev.status === "done" || ev.type === "done") {
setVectorProgress({ phase: "done", current: ev.total, total: ev.total });
} else if (ev.type === "error") {
alert(`벡터 재생성 오류: ${ev.message}`);
}
} catch { /* ignore */ }
} }
} });
setRebuildingVectors(false); setRebuildingVectors(false);
} catch { } catch {
setRebuildingVectors(false); setRebuildingVectors(false);
@@ -309,30 +282,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRemappingCuisine(false); setRemappingCuisine(false);
return; return;
} }
const reader = resp.body?.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(resp, (raw) => {
if (!reader) { setRemappingCuisine(false); return; } const ev = raw as { type: string; [k: string]: unknown };
let buf = ""; if (ev.type === "processing" || ev.type === "batch_done") {
while (true) { setRemapProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 });
const { done, value } = await reader.read(); } else if (ev.type === "complete") {
if (done) break; setRemapProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number });
buf += decoder.decode(value, { stream: true }); } else if (ev.type === "error") {
const lines = buf.split("\n"); alert(`재분류 오류: ${ev.message}`);
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === "processing" || ev.type === "batch_done") {
setRemapProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
} else if (ev.type === "complete") {
setRemapProgress({ current: ev.total, total: ev.total, updated: ev.updated });
} else if (ev.type === "error") {
alert(`재분류 오류: ${ev.message}`);
}
} catch { /* ignore */ }
} }
} });
setRemappingCuisine(false); setRemappingCuisine(false);
} catch { } catch {
setRemappingCuisine(false); setRemappingCuisine(false);
@@ -354,30 +314,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRemappingFoods(false); setRemappingFoods(false);
return; return;
} }
const reader = resp.body?.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(resp, (raw) => {
if (!reader) { setRemappingFoods(false); return; } const ev = raw as { type: string; [k: string]: unknown };
let buf = ""; if (ev.type === "processing" || ev.type === "batch_done") {
while (true) { setFoodsProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 });
const { done, value } = await reader.read(); } else if (ev.type === "complete") {
if (done) break; setFoodsProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number });
buf += decoder.decode(value, { stream: true }); } else if (ev.type === "error") {
const lines = buf.split("\n"); alert(`메뉴 태그 재생성 오류: ${ev.message}`);
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === "processing" || ev.type === "batch_done") {
setFoodsProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
} else if (ev.type === "complete") {
setFoodsProgress({ current: ev.total, total: ev.total, updated: ev.updated });
} else if (ev.type === "error") {
alert(`메뉴 태그 재생성 오류: ${ev.message}`);
}
} catch { /* ignore */ }
} }
} });
setRemappingFoods(false); setRemappingFoods(false);
} catch { } catch {
setRemappingFoods(false); setRemappingFoods(false);

View File

@@ -41,8 +41,14 @@ export default function MyReviewsList({
</button> </button>
</div> </div>
<div className="flex gap-1 border-b"> {/* #343 — WAI-ARIA Tabs 패턴 */}
<div role="tablist" aria-label="내 활동" className="flex gap-1 border-b">
<button <button
role="tab"
id="tab-reviews"
aria-selected={tab === "reviews"}
aria-controls="panel-reviews"
tabIndex={tab === "reviews" ? 0 : -1}
onClick={() => setTab("reviews")} onClick={() => setTab("reviews")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${ className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "reviews" tab === "reviews"
@@ -54,6 +60,11 @@ export default function MyReviewsList({
({reviews.length}) ({reviews.length})
</button> </button>
<button <button
role="tab"
id="tab-memos"
aria-selected={tab === "memos"}
aria-controls="panel-memos"
tabIndex={tab === "memos" ? 0 : -1}
onClick={() => setTab("memos")} onClick={() => setTab("memos")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${ className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "memos" tab === "memos"
@@ -67,7 +78,8 @@ export default function MyReviewsList({
</div> </div>
{tab === "reviews" ? ( {tab === "reviews" ? (
reviews.length === 0 ? ( <div role="tabpanel" id="panel-reviews" aria-labelledby="tab-reviews">
{reviews.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center"> <p className="text-sm text-gray-500 py-8 text-center">
. .
</p> </p>
@@ -100,9 +112,11 @@ export default function MyReviewsList({
</button> </button>
))} ))}
</div> </div>
) )}
</div>
) : ( ) : (
memos.length === 0 ? ( <div role="tabpanel" id="panel-memos" aria-labelledby="tab-memos">
{memos.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center"> <p className="text-sm text-gray-500 py-8 text-center">
. .
</p> </p>
@@ -137,7 +151,8 @@ export default function MyReviewsList({
</button> </button>
))} ))}
</div> </div>
) )}
</div>
)} )}
</div> </div>
); );

View File

@@ -257,6 +257,9 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
<> <>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
{review.user_avatar_url && ( {review.user_avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
// #343 — Google avatar URL은 remotePatterns에 추가됨.
// next/image 전환은 SSR/lazy 효과 미미한 5x5 아바타라 후속에서 일괄 적용.
<img <img
src={review.user_avatar_url} src={review.user_avatar_url}
alt="" alt=""