UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동

- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가
- 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능)
- 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가
- 채널 필터 시 해당 채널만 마커/범례 표시
- 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴
- 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용)
- 테이블링/캐치테이블 버튼 UI 차별화
- Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-11 00:49:16 +09:00
parent 58c0f972e2
commit cdee37e341
23 changed files with 1465 additions and 325 deletions

View File

@@ -1495,6 +1495,12 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
const [editForm, setEditForm] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [videos, setVideos] = useState<VideoLink[]>([]);
const [tablingSearching, setTablingSearching] = useState(false);
const [bulkTabling, setBulkTabling] = useState(false);
const [bulkTablingProgress, setBulkTablingProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
const [catchtableSearching, setCatchtableSearching] = useState(false);
const [bulkCatchtable, setBulkCatchtable] = useState(false);
const [bulkCatchtableProgress, setBulkCatchtableProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status";
const [sortKey, setSortKey] = useState<RestSortKey>("name");
const [sortAsc, setSortAsc] = useState(true);
@@ -1617,10 +1623,126 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
</button>
)}
</div>
{isAdmin && (<>
<button
onClick={async () => {
const pending = await fetch(`/api/restaurants/tabling-pending`, {
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
}).then(r => r.json());
if (pending.count === 0) { alert("테이블링 미연결 식당이 없습니다"); return; }
if (!confirm(`테이블링 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
setBulkTabling(true);
setBulkTablingProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
try {
const res = await fetch("/api/restaurants/bulk-tabling", {
method: "POST",
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
const m = line.match(/^data:(.+)$/);
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))); }
finally { setBulkTabling(false); load(); }
}}
disabled={bulkTabling}
className="px-3 py-1.5 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50"
>
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
</button>
<button
onClick={async () => {
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
}).then(r => r.json());
if (pending.count === 0) { alert("캐치테이블 미연결 식당이 없습니다"); return; }
if (!confirm(`캐치테이블 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
setBulkCatchtable(true);
setBulkCatchtableProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
try {
const res = await fetch("/api/restaurants/bulk-catchtable", {
method: "POST",
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
const m = line.match(/^data:(.+)$/);
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))); }
finally { setBulkCatchtable(false); load(); }
}}
disabled={bulkCatchtable}
className="px-3 py-1.5 text-xs bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
>
{bulkCatchtable ? `캐치테이블 검색 중 (${bulkCatchtableProgress.current}/${bulkCatchtableProgress.total})` : "벌크 캐치테이블 연결"}
</button>
</>)}
<span className="text-sm text-gray-400 ml-auto">
{nameSearch ? `${sorted.length} / ` : ""} {restaurants.length}
</span>
</div>
{bulkTabling && bulkTablingProgress.name && (
<div className="bg-orange-50 rounded p-3 mb-4 text-sm">
<div className="flex justify-between mb-1">
<span>{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name}</span>
<span className="text-xs text-gray-500">: {bulkTablingProgress.linked} / : {bulkTablingProgress.notFound}</span>
</div>
<div className="w-full bg-orange-200 rounded-full h-1.5">
<div className="bg-orange-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkTablingProgress.current / bulkTablingProgress.total) * 100}%` }} />
</div>
</div>
)}
{bulkCatchtable && bulkCatchtableProgress.name && (
<div className="bg-violet-50 rounded p-3 mb-4 text-sm">
<div className="flex justify-between mb-1">
<span>{bulkCatchtableProgress.current}/{bulkCatchtableProgress.total} - {bulkCatchtableProgress.name}</span>
<span className="text-xs text-gray-500">: {bulkCatchtableProgress.linked} / : {bulkCatchtableProgress.notFound}</span>
</div>
<div className="w-full bg-violet-200 rounded-full h-1.5">
<div className="bg-violet-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkCatchtableProgress.current / bulkCatchtableProgress.total) * 100}%` }} />
</div>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-auto">
<table className="w-full text-sm">
@@ -1730,6 +1852,109 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
</a>
</p>
)}
{/* 테이블링 연결 */}
{isAdmin && (
<div className="mt-4 border-t pt-3">
<div className="flex items-center gap-2 mb-2">
<h4 className="text-xs font-semibold text-gray-500"></h4>
{selected.tabling_url === "NONE" ? (
<span className="text-xs text-gray-400">-</span>
) : selected.tabling_url ? (
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs">{selected.tabling_url}</a>
) : (
<span className="text-xs text-gray-400"></span>
)}
<button
onClick={async () => {
setTablingSearching(true);
try {
const results = await api.searchTabling(selected.id);
if (results.length === 0) {
alert("테이블링에서 검색 결과가 없습니다");
} else {
const best = results[0];
if (confirm(`"${best.title}"\n${best.url}\n\n이 테이블링 페이지를 연결할까요?`)) {
await api.setTablingUrl(selected.id, best.url);
setSelected({ ...selected, tabling_url: best.url });
load();
}
}
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setTablingSearching(false); }
}}
disabled={tablingSearching}
className="px-2 py-0.5 text-[11px] bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50"
>
{tablingSearching ? "검색 중..." : "테이블링 검색"}
</button>
{selected.tabling_url && (
<button
onClick={async () => {
await api.setTablingUrl(selected.id, "");
setSelected({ ...selected, tabling_url: null });
load();
}}
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
>
</button>
)}
</div>
</div>
)}
{/* 캐치테이블 연결 */}
{isAdmin && (
<div className="mt-4 border-t pt-3">
<div className="flex items-center gap-2 mb-2">
<h4 className="text-xs font-semibold text-gray-500"></h4>
{selected.catchtable_url === "NONE" ? (
<span className="text-xs text-gray-400">-</span>
) : selected.catchtable_url ? (
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs">{selected.catchtable_url}</a>
) : (
<span className="text-xs text-gray-400"></span>
)}
<button
onClick={async () => {
setCatchtableSearching(true);
try {
const results = await api.searchCatchtable(selected.id);
if (results.length === 0) {
alert("캐치테이블에서 검색 결과가 없습니다");
} else {
const best = results[0];
if (confirm(`"${best.title}"\n${best.url}\n\n이 캐치테이블 페이지를 연결할까요?`)) {
await api.setCatchtableUrl(selected.id, best.url);
setSelected({ ...selected, catchtable_url: best.url });
load();
}
}
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setCatchtableSearching(false); }
}}
disabled={catchtableSearching}
className="px-2 py-0.5 text-[11px] bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
>
{catchtableSearching ? "검색 중..." : "캐치테이블 검색"}
</button>
{selected.catchtable_url && (
<button
onClick={async () => {
await api.setCatchtableUrl(selected.id, "");
setSelected({ ...selected, catchtable_url: null });
load();
}}
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
>
</button>
)}
</div>
</div>
)}
{videos.length > 0 && (
<div className="mt-4 border-t pt-3">
<h4 className="text-xs font-semibold text-gray-500 mb-2"> ({videos.length})</h4>