9 Commits

Author SHA1 Message Date
joungmin
f2861b6b79 홈 탭 장르 카드 UI + Tabler Icons 적용 + 지역 필터 추가
- 홈 탭: 장르 가로 스크롤 카드 (Tabler Icons 픽토그램)
- 홈 탭: 가격/지역/내위치 필터 2줄 배치
- 리스트 탭: 기존 바텀시트 필터 UI 유지
- cuisine-icons: Tabler 아이콘 매핑 추가 (getTablerCuisineIcon)
- 드래그 스크롤 장르 카드에 적용
- 배포 가이드 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:52:42 +09:00
joungmin
dda0da52c4 내위치 필터 모바일 리스트 적용 + 반경 4km
- mapBounds 없을 때 userLoc 기준 ~4km 반경 필터링
- 내위치 ON 시 setUserLoc도 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:25:36 +09:00
joungmin
18776b9b4b 바텀시트 필터 글씨 크기 미세 조정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:15:59 +09:00
joungmin
177532e6e7 모바일 필터 바텀시트 UI 적용
- FilterSheet 컴포넌트 신규: 바텀시트로 올라오는 필터 선택 UI
- 장르/가격/지역 필터 모두 네이티브 select 대신 바텀시트 사용
- 카테고리별 그룹핑 + sticky 헤더 + 선택 체크 표시
- slide-up 애니메이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:13:46 +09:00
joungmin
64d58cb553 모바일 필터 UI pill 스타일로 개선
- select를 둥근 칩(pill) 형태로 변경 (아이콘 + 드롭다운 화살표)
- 선택 시 브랜드 컬러 배경 + 링 하이라이트
- 장르/가격/지역 필터 모두 동일 스타일 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:08:42 +09:00
joungmin
a766a74f20 모바일 리스트 레이아웃 개선 + 내위치 줌 조정
- 식당명/지역/별점 1줄, 종류+가격(왼)+유튜브채널(우) 2줄, 태그 3줄 배치
- 가격대: 종류가 공간 우선 차지, 나머지에서 truncate
- 내위치 줌 16→17로 조정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:04:32 +09:00
joungmin
4b1f7c13b7 Playwright 제거 → DuckDuckGo HTML 검색 전환 + UI 미세 조정
- 테이블링/캐치테이블 검색: Google+Playwright → DuckDuckGo HTML 파싱 (브라우저 불필요)
- 검색 딜레이 5~15초 → 2~5초로 단축
- 프론트엔드: 정보 텍스트 계층 개선 (폰트 크기/색상 조정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:28:49 +09:00
joungmin
75e0066dbe 지도 마커 클러스터링 적용 (supercluster)
- 줌 16 이하에서 근접 마커를 숫자 원형 클러스터로 묶음
- 클러스터 클릭 시 해당 영역으로 자동 줌인
- 개별 마커 스타일/채널 색상 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:16:31 +09:00
joungmin
3134994817 비공개 메모 기능 추가 + 아이콘 개선
- 식당별 1:1 비공개 메모 CRUD (user_memos 테이블)
- 내 기록에 리뷰/메모 탭 분리
- 백오피스 유저 관리에 메모 수/상세 표시
- 리뷰/메모 작성 시 현재 날짜 기본값
- 지도우선/목록우선 버튼 Material Symbols 아이콘 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:10:06 +09:00
24 changed files with 1907 additions and 418 deletions

View File

@@ -1,7 +1,9 @@
package com.tasteby.controller;
import com.tasteby.domain.Memo;
import com.tasteby.domain.Restaurant;
import com.tasteby.domain.Review;
import com.tasteby.service.MemoService;
import com.tasteby.service.ReviewService;
import com.tasteby.service.UserService;
import org.springframework.web.bind.annotation.*;
@@ -15,10 +17,12 @@ public class AdminUserController {
private final UserService userService;
private final ReviewService reviewService;
private final MemoService memoService;
public AdminUserController(UserService userService, ReviewService reviewService) {
public AdminUserController(UserService userService, ReviewService reviewService, MemoService memoService) {
this.userService = userService;
this.reviewService = reviewService;
this.memoService = memoService;
}
@GetMapping
@@ -39,4 +43,9 @@ public class AdminUserController {
public List<Review> userReviews(@PathVariable String userId) {
return reviewService.findByUser(userId, 100, 0);
}
@GetMapping("/{userId}/memos")
public List<Memo> userMemos(@PathVariable String userId) {
return memoService.findByUser(userId);
}
}

View File

@@ -0,0 +1,59 @@
package com.tasteby.controller;
import com.tasteby.domain.Memo;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.MemoService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class MemoController {
private final MemoService memoService;
public MemoController(MemoService memoService) {
this.memoService = memoService;
}
@GetMapping("/restaurants/{restaurantId}/memo")
public Memo getMemo(@PathVariable String restaurantId) {
String userId = AuthUtil.getUserId();
Memo memo = memoService.findByUserAndRestaurant(userId, restaurantId);
if (memo == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
}
return memo;
}
@PostMapping("/restaurants/{restaurantId}/memo")
public Memo upsertMemo(@PathVariable String restaurantId,
@RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId();
Double rating = body.get("rating") != null
? ((Number) body.get("rating")).doubleValue() : null;
String text = (String) body.get("memo_text");
LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null;
return memoService.upsert(userId, restaurantId, rating, text, visitedAt);
}
@GetMapping("/users/me/memos")
public List<Memo> myMemos() {
return memoService.findByUser(AuthUtil.getUserId());
}
@DeleteMapping("/restaurants/{restaurantId}/memo")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteMemo(@PathVariable String restaurantId) {
String userId = AuthUtil.getUserId();
if (!memoService.delete(userId, restaurantId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
}
}
}

View File

@@ -2,7 +2,6 @@ package com.tasteby.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.microsoft.playwright.*;
import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
@@ -15,15 +14,19 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/restaurants")
@@ -139,12 +142,8 @@ public class RestaurantController {
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchTabling(page, r.getName());
}
try {
return searchTabling(r.getName());
} catch (Exception e) {
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
@@ -183,18 +182,13 @@ public class RestaurantController {
int linked = 0;
int notFound = 0;
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
try {
var results = searchTabling(page, r.getName());
var results = searchTabling(r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
@@ -222,12 +216,10 @@ public class RestaurantController {
"name", r.getName(), "message", e.getMessage()));
}
// Google 봇 판정 방지 랜덤 딜레이 (5~15초)
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
// 랜덤 딜레이 (2~5초)
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[TABLING] Waiting {}ms before next search...", delay);
page.waitForTimeout(delay);
}
}
Thread.sleep(delay);
}
cache.flush();
@@ -277,12 +269,8 @@ public class RestaurantController {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchCatchtable(page, r.getName());
}
try {
return searchCatchtable(r.getName());
} catch (Exception e) {
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
@@ -321,18 +309,13 @@ public class RestaurantController {
int linked = 0;
int notFound = 0;
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
try {
var results = searchCatchtable(page, r.getName());
var results = searchCatchtable(r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
@@ -360,11 +343,9 @@ public class RestaurantController {
"name", r.getName(), "message", e.getMessage()));
}
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
page.waitForTimeout(delay);
}
}
Thread.sleep(delay);
}
cache.flush();
@@ -407,119 +388,96 @@ public class RestaurantController {
return result;
}
// ─── Playwright helpers ──────────────────────────────────────────────
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
private Browser launchBrowser(Playwright pw) {
return pw.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
}
private static final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private BrowserContext newContext(Browser browser) {
return browser.newContext(new Browser.NewContextOptions()
.setLocale("ko-KR").setViewportSize(1280, 900));
}
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
Pattern.DOTALL
);
private Page newPage(BrowserContext ctx) {
Page page = ctx.newPage();
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
return page;
}
/**
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
*/
private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
log.info("[DDG] Searching: {}", query);
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) {
String query = "site:tabling.co.kr " + restaurantName;
log.info("[TABLING] Searching: {}", query);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(searchUrl))
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9")
.GET()
.build();
String searchUrl = "https://www.google.com/search?q=" +
URLEncoder.encode(query, StandardCharsets.UTF_8);
page.navigate(searchUrl);
page.waitForTimeout(3000);
Object linksObj = page.evaluate("""
() => {
const results = [];
const links = document.querySelectorAll('a[href]');
for (const a of links) {
const href = a.href;
if (href.includes('tabling.co.kr/restaurant/') || href.includes('tabling.co.kr/place/')) {
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|| a.querySelector('h3')?.textContent
|| a.textContent?.trim()?.substring(0, 80)
|| '';
results.push({ title, url: href });
}
}
const seen = new Set();
return results.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
}).slice(0, 5);
}
""");
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
String html = response.body();
List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) {
for (var item : list) {
if (item instanceof Map<?, ?> map) {
results.add(Map.of(
"title", String.valueOf(map.get("title")),
"url", String.valueOf(map.get("url"))
));
Set<String> seen = new HashSet<>();
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
while (matcher.find() && results.size() < 5) {
String href = matcher.group(1);
String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
String actualUrl = extractDdgUrl(href);
if (actualUrl == null) continue;
boolean matches = false;
for (String pattern : urlPatterns) {
if (actualUrl.contains(pattern)) {
matches = true;
break;
}
}
if (matches && !seen.contains(actualUrl)) {
seen.add(actualUrl);
results.add(Map.of("title", title, "url", actualUrl));
}
log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName);
}
log.info("[DDG] Found {} results for '{}'", results.size(), query);
return results;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchCatchtable(Page page, String restaurantName) {
String query = "site:app.catchtable.co.kr " + restaurantName;
log.info("[CATCHTABLE] Searching: {}", query);
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
private String extractDdgUrl(String ddgHref) {
try {
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
if (ddgHref.contains("uddg=")) {
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
int ampIdx = uddgParam.indexOf('&');
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
}
// 직접 URL인 경우
if (ddgHref.startsWith("http")) return ddgHref;
} catch (Exception e) {
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
}
return null;
}
String searchUrl = "https://www.google.com/search?q=" +
URLEncoder.encode(query, StandardCharsets.UTF_8);
page.navigate(searchUrl);
page.waitForTimeout(3000);
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
return searchDuckDuckGo(
"site:tabling.co.kr " + restaurantName,
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
);
}
Object linksObj = page.evaluate("""
() => {
const results = [];
const links = document.querySelectorAll('a[href]');
for (const a of links) {
const href = a.href;
if (href.includes('catchtable.co.kr/') && (href.includes('/dining/') || href.includes('/shop/'))) {
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|| a.querySelector('h3')?.textContent
|| a.textContent?.trim()?.substring(0, 80)
|| '';
results.push({ title, url: href });
}
}
const seen = new Set();
return results.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
}).slice(0, 5);
}
""");
List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) {
for (var item : list) {
if (item instanceof Map<?, ?> map) {
results.add(Map.of(
"title", String.valueOf(map.get("title")),
"url", String.valueOf(map.get("url"))
));
}
}
}
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName);
return results;
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
return searchDuckDuckGo(
"site:app.catchtable.co.kr " + restaurantName,
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
);
}
/**

View File

@@ -0,0 +1,22 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Memo {
private String id;
private String userId;
private String restaurantId;
private Double rating;
private String memoText;
private String visitedAt;
private String createdAt;
private String updatedAt;
private String restaurantName;
}

View File

@@ -22,4 +22,5 @@ public class UserInfo {
private String createdAt;
private int favoriteCount;
private int reviewCount;
private int memoCount;
}

View File

@@ -0,0 +1,32 @@
package com.tasteby.mapper;
import com.tasteby.domain.Memo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MemoMapper {
Memo findByUserAndRestaurant(@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
void insertMemo(@Param("id") String id,
@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") Double rating,
@Param("memoText") String memoText,
@Param("visitedAt") String visitedAt);
int updateMemo(@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") Double rating,
@Param("memoText") String memoText,
@Param("visitedAt") String visitedAt);
int deleteMemo(@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
List<Memo> findByUser(@Param("userId") String userId);
}

View File

@@ -0,0 +1,44 @@
package com.tasteby.service;
import com.tasteby.domain.Memo;
import com.tasteby.mapper.MemoMapper;
import com.tasteby.util.IdGenerator;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Service
public class MemoService {
private final MemoMapper mapper;
public MemoService(MemoMapper mapper) {
this.mapper = mapper;
}
public Memo findByUserAndRestaurant(String userId, String restaurantId) {
return mapper.findByUserAndRestaurant(userId, restaurantId);
}
@Transactional
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
if (existing != null) {
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
} else {
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
}
return mapper.findByUserAndRestaurant(userId, restaurantId);
}
public boolean delete(String userId, String restaurantId) {
return mapper.deleteMemo(userId, restaurantId) > 0;
}
public List<Memo> findByUser(String userId) {
return mapper.findByUser(userId);
}
}

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.MemoMapper">
<resultMap id="memoResultMap" type="com.tasteby.domain.Memo">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="restaurantId" column="restaurant_id"/>
<result property="rating" column="rating"/>
<result property="memoText" column="memo_text" typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="visitedAt" column="visited_at"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<result property="restaurantName" column="restaurant_name"/>
</resultMap>
<select id="findByUserAndRestaurant" resultMap="memoResultMap">
SELECT id, user_id, restaurant_id, rating, memo_text,
visited_at, created_at, updated_at
FROM user_memos
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</select>
<insert id="insertMemo">
INSERT INTO user_memos (id, user_id, restaurant_id, rating, memo_text, visited_at)
VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{memoText},
<choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>)
</insert>
<update id="updateMemo">
UPDATE user_memos SET
rating = #{rating},
memo_text = #{memoText},
visited_at = <choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>,
updated_at = SYSTIMESTAMP
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</update>
<delete id="deleteMemo">
DELETE FROM user_memos WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</delete>
<select id="findByUser" resultMap="memoResultMap">
SELECT m.id, m.user_id, m.restaurant_id, m.rating, m.memo_text,
m.visited_at, m.created_at, m.updated_at,
r.name AS restaurant_name
FROM user_memos m
LEFT JOIN restaurants r ON r.id = m.restaurant_id
WHERE m.user_id = #{userId}
ORDER BY m.updated_at DESC
</select>
</mapper>

View File

@@ -12,6 +12,7 @@
<result property="createdAt" column="created_at"/>
<result property="favoriteCount" column="favorite_count"/>
<result property="reviewCount" column="review_count"/>
<result property="memoCount" column="memo_count"/>
</resultMap>
<select id="findByProviderAndProviderId" resultMap="userResultMap">
@@ -38,10 +39,12 @@
<select id="findAllWithCounts" resultMap="userResultMap">
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
NVL(fav.cnt, 0) AS favorite_count,
NVL(rev.cnt, 0) AS review_count
NVL(rev.cnt, 0) AS review_count,
NVL(memo.cnt, 0) AS memo_count
FROM tasteby_users u
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_memos GROUP BY user_id) memo ON memo.user_id = u.id
ORDER BY u.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>

262
docs/deployment-guide.md Normal file
View File

@@ -0,0 +1,262 @@
# Tasteby 배포 가이드
## 환경 요약
| 항목 | Dev (개발) | Prod (운영) |
|------|-----------|-------------|
| URL | dev.tasteby.net | www.tasteby.net |
| 호스트 | 로컬 Mac mini | OKE (Oracle Kubernetes Engine) |
| 프로세스 관리 | PM2 | Kubernetes Deployment |
| 프론트엔드 실행 | `npm run dev` (Next.js dev server) | `node server.js` (standalone 빌드) |
| 백엔드 실행 | `./gradlew bootRun` | `java -jar app.jar` (bootJar 빌드) |
| Redis | 로컬 Redis 서버 | K8s Pod (redis:7-alpine) |
| TLS | Nginx(192.168.0.147) + Certbot | cert-manager + Let's Encrypt |
| 리버스 프록시 | Nginx (192.168.0.147 → 192.168.0.208) | Nginx Ingress Controller (K8s) |
| 도메인 DNS | dev.tasteby.net → Mac mini IP | www.tasteby.net → OCI NLB 217.142.131.194 |
---
## 1. Dev 환경 (dev.tasteby.net)
### 구조
```
브라우저 → dev.tasteby.net (HTTPS)
Nginx (192.168.0.147) — Certbot Let's Encrypt TLS
├── /api/* → proxy_pass http://192.168.0.208:8000 (tasteby-api)
└── /* → proxy_pass http://192.168.0.208:3001 (tasteby-web)
Mac mini (192.168.0.208) — PM2 프로세스 매니저
├── tasteby-api → ./gradlew bootRun (:8000)
└── tasteby-web → npm run dev (:3001)
```
- **192.168.0.147**: Nginx 리버스 프록시 서버 (TLS 종료, Certbot 자동 갱신)
- **192.168.0.208**: Mac mini (실제 앱 서버, PM2 관리)
### PM2 프로세스 구성 (ecosystem.config.js)
```javascript
module.exports = {
apps: [
{
name: "tasteby-api",
cwd: "/Users/joungmin/workspaces/tasteby/backend-java",
script: "./start.sh", // gradlew bootRun 실행
interpreter: "/bin/bash",
},
{
name: "tasteby-web",
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
script: "npm",
args: "run dev", // ⚠️ 절대 standalone으로 바꾸지 말 것!
},
],
};
```
### 백엔드 start.sh
```bash
#!/bin/bash
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
set -a
source /Users/joungmin/workspaces/tasteby/backend/.env # 환경변수 로드
set +a
exec ./gradlew bootRun
```
### 코드 수정 후 반영 방법
```bash
# 프론트엔드: npm run dev라서 코드 수정 시 자동 Hot Reload (재시작 불필요)
# 백엔드: 코드 수정 후 재시작 필요
pm2 restart tasteby-api
# 전체 재시작
pm2 restart tasteby-api tasteby-web
# PM2 상태 확인
pm2 status
# 로그 확인
pm2 logs tasteby-api --lines 50
pm2 logs tasteby-web --lines 50
```
### 주의사항
- `tasteby-web`은 반드시 `npm run dev`로 실행 (dev server)
- standalone 모드(`node .next/standalone/server.js`)로 바꾸면 static/public 파일을 못 찾아서 404 발생
- standalone은 prod(Docker/K8s) 전용
- dev 포트: 프론트 3001, 백엔드 8000 (3000은 Gitea가 사용 중)
- 환경변수는 `backend/.env`에서 로드
---
## 2. Prod 환경 (www.tasteby.net)
### 구조
```
브라우저 → www.tasteby.net (HTTPS)
OCI Network Load Balancer (217.142.131.194)
↓ 80→NodePort:32530, 443→NodePort:31437
Nginx Ingress Controller (K8s)
├── /api/* → backend Service (:8000)
└── /* → frontend Service (:3001)
```
### 클러스터 정보
- **OKE 클러스터**: tasteby-cluster-prod
- **노드**: ARM64 × 2 (2 CPU / 8GB)
- **네임스페이스**: tasteby
- **K8s context**: `context-c6ap7ecrdeq`
### Pod 구성
| Pod | Image | Port | 리소스 |
|-----|-------|------|--------|
| backend | `icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG` | 8000 | 500m~1 CPU, 768Mi~1536Mi |
| frontend | `icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG` | 3001 | 200m~500m CPU, 256Mi~512Mi |
| redis | `docker.io/library/redis:7-alpine` | 6379 | 100m~200m CPU, 128Mi~256Mi |
### 배포 명령어 (deploy.sh)
```bash
# 전체 배포 (백엔드 + 프론트엔드)
./deploy.sh "배포 메시지"
# 백엔드만 배포
./deploy.sh --backend-only "백엔드 수정 사항"
# 프론트엔드만 배포
./deploy.sh --frontend-only "프론트 수정 사항"
# 드라이런 (실제 배포 없이 확인)
./deploy.sh --dry-run "테스트"
```
### deploy.sh 동작 순서
1. **버전 계산**: 최신 git tag에서 patch +1 (v0.1.9 → v0.1.10)
2. **Docker 빌드**: Colima로 `linux/arm64` 이미지 빌드 (로컬 Mac에서)
- 백엔드: `backend-java/Dockerfile` → multi-stage (JDK build → JRE runtime)
- 프론트: `frontend/Dockerfile` → multi-stage (node build → standalone runtime)
3. **OCIR Push**: `icn.ocir.io/idyhsdamac8c/tasteby/{backend,frontend}:TAG` + `:latest`
4. **K8s 배포**: `kubectl set image``kubectl rollout status` (롤링 업데이트)
5. **Git tag**: `vX.Y.Z` 태그 생성 후 origin push
### Docker 빌드 상세
**백엔드 Dockerfile** (multi-stage):
```dockerfile
# Build: eclipse-temurin:21-jdk에서 gradlew bootJar
# Runtime: eclipse-temurin:21-jre에서 java -jar app.jar
# JVM 옵션: -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC
```
**프론트엔드 Dockerfile** (multi-stage):
```dockerfile
# Build: node:22-alpine에서 npm ci + npm run build
# Runtime: node:22-alpine에서 standalone 출력물 복사 + node server.js
# ⚠️ standalone 모드는 Docker(prod) 전용. .next/static과 public을 직접 복사해야 함
```
### Ingress 설정
```yaml
# 주요 annotation
cert-manager.io/cluster-issuer: letsencrypt-prod # 자동 TLS 인증서
nginx.ingress.kubernetes.io/ssl-redirect: "true" # HTTP → HTTPS 리다이렉트
nginx.ingress.kubernetes.io/from-to-www-redirect: "true" # tasteby.net → www 리다이렉트
# 라우팅
www.tasteby.net/api/* → backend:8000
www.tasteby.net/* → frontend:3001
```
### TLS 인증서 (cert-manager)
- ClusterIssuer: `letsencrypt-prod`
- HTTP-01 challenge 방식 (포트 80 필수)
- Secret: `tasteby-tls`
- 인증서 상태 확인: `kubectl get certificate -n tasteby`
### 운영 확인 명령어
```bash
# Pod 상태
kubectl get pods -n tasteby
# 로그 확인
kubectl logs -f deployment/backend -n tasteby
kubectl logs -f deployment/frontend -n tasteby
# 인증서 상태
kubectl get certificate -n tasteby
# Ingress 상태
kubectl get ingress -n tasteby
# 롤백 (이전 이미지로)
kubectl rollout undo deployment/backend -n tasteby
kubectl rollout undo deployment/frontend -n tasteby
```
---
## 3. OCI 네트워크 구성
### VCN 서브넷
| 서브넷 | CIDR | 용도 |
|--------|------|------|
| oke-k8sApiEndpoint-subnet | 10.0.0.0/28 | K8s API 서버 |
| oke-nodesubnet | 10.0.10.0/24 | 워커 노드 |
| oke-svclbsubnet | 10.0.20.0/24 | NLB (로드밸런서) |
### 보안 리스트 (Security List)
**LB 서브넷** (oke-svclbsubnet):
- Ingress: `0.0.0.0/0` → TCP 80, 443
- Egress: `10.0.10.0/24` → all (노드 서브넷 전체 허용)
**노드 서브넷** (oke-nodesubnet):
- Ingress: `10.0.10.0/24` → all (노드 간 통신)
- Ingress: `10.0.0.0/28` → TCP all (API 서버)
- Ingress: `0.0.0.0/0` → TCP 22 (SSH)
- Ingress: `10.0.20.0/24` → TCP 30000-32767 (LB → NodePort)
- Ingress: `0.0.0.0/0` → TCP 30000-32767 (NLB preserve-source 대응)
> ⚠️ NLB `is-preserve-source: true` 설정으로 클라이언트 원본 IP가 보존됨.
> 따라서 노드 서브넷에 `0.0.0.0/0` → NodePort 인바운드가 반드시 필요.
---
## 4. OCIR (컨테이너 레지스트리) 인증
```bash
# 로그인
docker login icn.ocir.io -u idyhsdamac8c/oracleidentitycloudservice/<email> -p <auth-token>
```
- Registry: `icn.ocir.io/idyhsdamac8c/tasteby/`
- K8s imagePullSecret: `ocir-secret` (namespace: tasteby)
---
## 5. 자주 하는 실수 / 주의사항
| 실수 | 원인 | 해결 |
|------|------|------|
| dev에서 static 404 | PM2를 standalone 모드로 바꿈 | `npm run dev`로 원복 |
| prod HTTPS 타임아웃 | NLB 보안 리스트 NodePort 불일치 | egress를 노드 서브넷 all 허용 |
| 인증서 발급 실패 | 포트 80 방화벽 차단 | LB 서브넷 ingress 80 + 노드 서브넷 NodePort 허용 |
| OKE에서 이미지 pull 실패 | CRI-O short name 불가 | `docker.io/library/` 풀네임 사용 |
| NLB 헬스체크 실패 | preserve-source + 노드 보안 리스트 | 0.0.0.0/0 → NodePort 인바운드 추가 |

View File

@@ -9,10 +9,13 @@
"version": "0.1.0",
"dependencies": {
"@react-oauth/google": "^0.13.4",
"@tabler/icons-react": "^3.40.0",
"@types/supercluster": "^7.1.3",
"@vis.gl/react-google-maps": "^1.7.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"supercluster": "^8.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1255,6 +1258,32 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tabler/icons": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.40.0.tgz",
"integrity": "sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.40.0.tgz",
"integrity": "sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.40.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -1544,6 +1573,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/google.maps": {
"version": "3.58.1",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
@@ -1595,6 +1630,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -4555,6 +4599,12 @@
"node": ">=4.0"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6086,6 +6136,15 @@
}
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -10,10 +10,13 @@
},
"dependencies": {
"@react-oauth/google": "^0.13.4",
"@tabler/icons-react": "^3.40.0",
"@types/supercluster": "^7.1.3",
"@vis.gl/react-google-maps": "^1.7.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"supercluster": "^8.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -2148,6 +2148,7 @@ interface AdminUser {
created_at: string | null;
favorite_count: number;
review_count: number;
memo_count: number;
}
interface UserFavorite {
@@ -2171,6 +2172,16 @@ interface UserReview {
restaurant_name: string | null;
}
interface UserMemo {
id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
restaurant_name: string | null;
}
function UsersPanel() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
@@ -2178,6 +2189,7 @@ function UsersPanel() {
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [reviews, setReviews] = useState<UserReview[]>([]);
const [memos, setMemos] = useState<UserMemo[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const perPage = 20;
@@ -2200,17 +2212,20 @@ function UsersPanel() {
setSelectedUser(null);
setFavorites([]);
setReviews([]);
setMemos([]);
return;
}
setSelectedUser(u);
setDetailLoading(true);
try {
const [favs, revs] = await Promise.all([
const [favs, revs, mems] = await Promise.all([
api.getAdminUserFavorites(u.id),
api.getAdminUserReviews(u.id),
api.getAdminUserMemos(u.id),
]);
setFavorites(favs);
setReviews(revs);
setMemos(mems);
} catch (e) {
console.error(e);
} finally {
@@ -2233,6 +2248,7 @@ function UsersPanel() {
<th className="text-left px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-left px-4 py-2"></th>
</tr>
</thead>
@@ -2284,6 +2300,15 @@ function UsersPanel() {
<span className="text-gray-300">0</span>
)}
</td>
<td className="px-4 py-2 text-center">
{u.memo_count > 0 ? (
<span className="inline-block px-2 py-0.5 bg-purple-50 text-purple-600 rounded-full text-xs font-medium">
{u.memo_count}
</span>
) : (
<span className="text-gray-300">0</span>
)}
</td>
<td className="px-4 py-2 text-gray-400 text-xs">
{u.created_at?.slice(0, 10) || "-"}
</td>
@@ -2343,7 +2368,7 @@ function UsersPanel() {
{detailLoading ? (
<p className="text-sm text-gray-500"> ...</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Favorites */}
<div>
<h3 className="font-semibold text-sm mb-2 text-red-600">
@@ -2419,6 +2444,46 @@ function UsersPanel() {
</div>
)}
</div>
{/* Memos */}
<div>
<h3 className="font-semibold text-sm mb-2 text-purple-600">
({memos.length})
</h3>
{memos.length === 0 ? (
<p className="text-xs text-gray-400"> .</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{memos.map((m) => (
<div
key={m.id}
className="border border-purple-200 rounded px-3 py-2 text-xs space-y-0.5 bg-purple-50/30"
>
<div className="flex items-center justify-between">
<span className="font-medium">
{m.restaurant_name || "알 수 없음"}
</span>
{m.rating && (
<span className="text-yellow-500 shrink-0">
{"★".repeat(Math.round(m.rating))} {m.rating}
</span>
)}
</div>
{m.memo_text && (
<p className="text-gray-600 line-clamp-2">
{m.memo_text}
</p>
)}
<div className="text-gray-400 text-[10px]">
{m.visited_at && `방문: ${m.visited_at} · `}
{m.created_at?.slice(0, 10)}
<span className="ml-1 text-purple-400"></span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>

View File

@@ -101,3 +101,12 @@ html, body, #__next {
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Filter sheet slide-up animation */
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.25s ease-out;
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GoogleLogin } from "@react-oauth/google";
import LoginMenu from "@/components/LoginMenu";
import { api } from "@/lib/api";
import type { Restaurant, Channel, Review } from "@/lib/api";
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import MapView, { MapBounds, FlyTo } from "@/components/MapView";
import SearchBar from "@/components/SearchBar";
@@ -12,8 +12,10 @@ import RestaurantList from "@/components/RestaurantList";
import RestaurantDetail from "@/components/RestaurantDetail";
import MyReviewsList from "@/components/MyReviewsList";
import BottomSheet from "@/components/BottomSheet";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
import { getCuisineIcon, getTablerCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
import * as TablerIcons from "@tabler/icons-react";
function useDragScroll() {
const ref = useRef<HTMLDivElement>(null);
@@ -187,10 +189,12 @@ export default function Home() {
const [countryFilter, setCountryFilter] = useState("");
const [cityFilter, setCityFilter] = useState("");
const [districtFilter, setDistrictFilter] = useState("");
const [openSheet, setOpenSheet] = useState<"cuisine" | "price" | "country" | "city" | "district" | null>(null);
const [regionFlyTo, setRegionFlyTo] = useState<FlyTo | null>(null);
const [showFavorites, setShowFavorites] = useState(false);
const [showMyReviews, setShowMyReviews] = useState(false);
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
const [myMemos, setMyMemos] = useState<(Memo & { restaurant_name: string | null })[]>([]);
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
const [isSearchResult, setIsSearchResult] = useState(false);
@@ -198,6 +202,7 @@ export default function Home() {
const geoApplied = useRef(false);
const dd = useDragScroll();
const dm = useDragScroll();
const dg = useDragScroll(); // genre card drag scroll
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]);
@@ -234,9 +239,16 @@ export default function Home() {
if (cityFilter && parsed.city !== cityFilter) return false;
if (districtFilter && parsed.district !== districtFilter) return false;
}
if (boundsFilterOn && mapBounds) {
if (boundsFilterOn) {
if (mapBounds) {
if (r.latitude < mapBounds.south || r.latitude > mapBounds.north) return false;
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
} else {
// 지도가 없으면 (모바일 리스트 탭 등) userLoc 기준 ~2km 반경
const dlat = r.latitude - userLoc.lat;
const dlng = r.longitude - userLoc.lng;
if (dlat * dlat + dlng * dlng > 0.0013) return false; // ~4km
}
}
return true;
}).sort((a, b) => {
@@ -246,6 +258,34 @@ export default function Home() {
});
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
// FilterSheet option builders
const cuisineOptions = useMemo<FilterOption[]>(() => {
const opts: FilterOption[] = [];
for (const g of CUISINE_TAXONOMY) {
opts.push({ label: `${g.category} 전체`, value: g.category, group: g.category });
for (const item of g.items) {
opts.push({ label: item, value: `${g.category}|${item}`, group: g.category });
}
}
return opts;
}, []);
const priceOptions = useMemo<FilterOption[]>(() =>
PRICE_GROUPS.map((g) => ({ label: g.label, value: g.label })),
[]);
const countryOptions = useMemo<FilterOption[]>(() =>
countries.map((c) => ({ label: c, value: c })),
[countries]);
const cityOptions = useMemo<FilterOption[]>(() =>
cities.map((c) => ({ label: c, value: c })),
[cities]);
const districtOptions = useMemo<FilterOption[]>(() =>
districts.map((d) => ({ label: d, value: d })),
[districts]);
// Set desktop default to map mode on mount + get user location
useEffect(() => {
if (window.innerWidth >= 768) setViewMode("map");
@@ -343,7 +383,7 @@ export default function Home() {
navigator.geolocation.getCurrentPosition(
(pos) => {
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 16 });
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 17 });
},
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
{ timeout: 5000 },
@@ -518,10 +558,15 @@ export default function Home() {
if (showMyReviews) {
setShowMyReviews(false);
setMyReviews([]);
setMyMemos([]);
} else {
try {
const reviews = await api.getMyReviews();
const [reviews, memos] = await Promise.all([
api.getMyReviews(),
api.getMyMemos(),
]);
setMyReviews(reviews);
setMyMemos(memos);
setShowMyReviews(true);
setShowFavorites(false);
setSelected(null);
@@ -534,7 +579,8 @@ export default function Home() {
const sidebarContent = showMyReviews ? (
<MyReviewsList
reviews={myReviews}
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
memos={myMemos}
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
onSelectRestaurant={async (restaurantId) => {
try {
const r = await api.getRestaurant(restaurantId);
@@ -563,7 +609,8 @@ export default function Home() {
const mobileListContent = showMyReviews ? (
<MyReviewsList
reviews={myReviews}
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
memos={myMemos}
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
onSelectRestaurant={async (restaurantId) => {
try {
const r = await api.getRestaurant(restaurantId);
@@ -618,7 +665,7 @@ export default function Home() {
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500"
}`}
>
{viewMode === "map" ? "🗺 지도우선" : "목록우선"}
<Icon name={viewMode === "map" ? "map" : "list"} size={14} className="mr-0.5" />{viewMode === "map" ? "지도우선" : "목록우선"}
</button>
{user && (
<>
@@ -840,7 +887,7 @@ export default function Home() {
setDistrictFilter("");
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
{ timeout: 5000 },
);
@@ -945,41 +992,170 @@ export default function Home() {
</div>
)}
{/* Row 2: Filters - always visible, 2 lines */}
{/* Row 2: Filters */}
<div className="space-y-1.5">
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
{/* Home tab: 장르 가로 스크롤 */}
{mobileTab === "home" && (
<div ref={dg.ref} onMouseDown={dg.onMouseDown} onMouseMove={dg.onMouseMove} onMouseUp={dg.onMouseUp} onMouseLeave={dg.onMouseLeave} onClickCapture={dg.onClickCapture} style={dg.style} className="flex gap-2 overflow-x-auto scrollbar-hide -mx-1 px-1 pb-1 select-none">
{(() => {
const allCards = [
{ label: "전체", value: "", icon: "Bowl" },
...CUISINE_TAXONOMY.flatMap((g) => [
{ label: g.category, value: g.category, icon: getTablerCuisineIcon(g.category) },
...g.items.map((item) => ({ label: item, value: `${g.category}|${item}`, icon: getTablerCuisineIcon(`${g.category}|${item}`) })),
]),
];
return allCards.map((card) => {
const isCategory = card.value === "" || !card.value.includes("|");
const selected = card.value === ""
? !cuisineFilter
: isCategory
? cuisineFilter === card.value || cuisineFilter.startsWith(card.value + "|")
: cuisineFilter === card.value;
const TablerIcon = (TablerIcons as unknown as Record<string, React.ComponentType<{ size?: number; stroke?: number; className?: string }>>)[`Icon${card.icon}`] || TablerIcons.IconBowl;
return (
<button
key={card.value || "__all__"}
onClick={() => {
if (card.value === "") { setCuisineFilter(""); }
else if (cuisineFilter === card.value) { setCuisineFilter(""); }
else { setCuisineFilter(card.value); setBoundsFilterOn(false); }
}}
className={`shrink-0 flex flex-col items-center gap-1 rounded-xl px-2.5 py-2 min-w-[56px] transition-all ${
selected
? "bg-brand-500 text-white shadow-sm"
: isCategory
? "bg-brand-50 border border-brand-200 text-brand-700"
: "bg-white border border-gray-100 text-gray-500"
}`}
>
<TablerIcon size={22} stroke={1.5} className={selected ? "text-white" : isCategory ? "text-brand-500" : "text-gray-400"} />
<span className={`text-[11px] whitespace-nowrap ${isCategory ? "font-semibold" : "font-medium"}`}>{card.label}</span>
</button>
);
});
})()}
</div>
)}
{/* Home tab: 가격 + 지역 + 내위치 + 개수 */}
{mobileTab === "home" && (
<div className="flex items-center gap-1.5 text-xs flex-wrap">
<button
onClick={() => setOpenSheet("price")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
priceFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<Icon name="payments" size={14} className={`mr-1 ${priceFilter ? "text-brand-500" : "text-gray-400"}`} />
<span className={priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{priceFilter || "가격"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
<button
onClick={() => setOpenSheet("country")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
countryFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<Icon name="public" size={14} className={`mr-1 ${countryFilter ? "text-brand-500" : "text-gray-400"}`} />
<span className={countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{countryFilter || "나라"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
{countryFilter && cities.length > 0 && (
<button
onClick={() => setOpenSheet("city")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
cityFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<span className={cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{cityFilter || "시/도"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
)}
{cityFilter && districts.length > 0 && (
<button
onClick={() => setOpenSheet("district")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
districtFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<span className={districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{districtFilter || "구/군"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
)}
{(cuisineFilter || priceFilter || countryFilter) && (
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); }} className="text-gray-400 hover:text-brand-500">
<Icon name="close" size={14} />
</button>
)}
<button
onClick={() => {
const next = !boundsFilterOn;
setBoundsFilterOn(next);
if (next) {
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
{ timeout: 5000 },
);
} else {
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
}
}
}}
className={`inline-flex items-center gap-0.5 rounded-full px-3 py-1.5 transition-colors ${
boundsFilterOn
? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700 text-brand-600 dark:text-brand-400"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
}`}
>
<Icon name="location_on" size={14} />
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
</button>
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}</span>
</div>
)}
{/* List tab: 기존 필터 UI */}
{mobileTab === "list" && (
<>
<div className="flex items-center gap-1.5 text-xs">
<select
value={cuisineFilter}
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
cuisineFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<button
onClick={() => setOpenSheet("cuisine")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
cuisineFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<option value="">🍽 </option>
{CUISINE_TAXONOMY.map((g) => (
<optgroup key={g.category} label={`── ${g.category} ──`}>
<option value={g.category}>{g.category} </option>
{g.items.map((item) => (
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
&nbsp;&nbsp;{item}
</option>
))}
</optgroup>
))}
</select>
<select
value={priceFilter}
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
priceFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<Icon name="restaurant" size={14} className={`mr-1 ${cuisineFilter ? "text-brand-500" : "text-gray-400"}`} />
<span className={cuisineFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{cuisineFilter ? (cuisineFilter.includes("|") ? cuisineFilter.split("|")[1] : cuisineFilter) : "장르"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
<button
onClick={() => setOpenSheet("price")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
priceFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<option value="">💰 </option>
{PRICE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>{g.label}</option>
))}
</select>
<Icon name="payments" size={14} className={`mr-1 ${priceFilter ? "text-brand-500" : "text-gray-400"}`} />
<span className={priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{priceFilter || "가격"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
{(cuisineFilter || priceFilter) && (
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
<Icon name="close" size={14} />
@@ -987,47 +1163,44 @@ export default function Home() {
)}
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}</span>
</div>
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
<div className="flex items-center gap-1.5 text-xs">
<select
value={countryFilter}
onChange={(e) => handleCountryChange(e.target.value)}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
countryFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<button
onClick={() => setOpenSheet("country")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
countryFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<option value="">🌍 </option>
{countries.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Icon name="public" size={14} className={`mr-1 ${countryFilter ? "text-brand-500" : "text-gray-400"}`} />
<span className={countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{countryFilter || "나라"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
{countryFilter && cities.length > 0 && (
<select
value={cityFilter}
onChange={(e) => handleCityChange(e.target.value)}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
cityFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<button
onClick={() => setOpenSheet("city")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
cityFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<option value="">/</option>
{cities.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<span className={cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{cityFilter || "시/도"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
)}
{cityFilter && districts.length > 0 && (
<select
value={districtFilter}
onChange={(e) => handleDistrictChange(e.target.value)}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
districtFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<button
onClick={() => setOpenSheet("district")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
districtFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<option value="">/</option>
{districts.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<span className={districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{districtFilter || "구/군"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
)}
{countryFilter && (
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
@@ -1046,7 +1219,7 @@ export default function Home() {
setDistrictFilter("");
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
{ timeout: 5000 },
);
@@ -1055,16 +1228,18 @@ export default function Home() {
}
}
}}
className={`flex items-center gap-0.5 rounded-lg px-2 py-1 border transition-colors ${
className={`inline-flex items-center gap-0.5 rounded-full px-3 py-1.5 transition-colors ${
boundsFilterOn
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700 text-brand-600 dark:text-brand-400"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
}`}
>
<Icon name="location_on" size={12} />
<Icon name="location_on" size={14} />
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
</button>
</div>
</>
)}
</div>
</div>
</header>
@@ -1193,6 +1368,7 @@ export default function Home() {
) : (
<MyReviewsList
reviews={myReviews}
memos={myMemos}
onClose={() => {}}
onSelectRestaurant={async (restaurantId) => {
try {
@@ -1276,6 +1452,47 @@ export default function Home() {
SDJ Labs Co., Ltd.
</span>
</footer>
{/* Mobile Filter Sheets */}
<FilterSheet
open={openSheet === "cuisine"}
onClose={() => setOpenSheet(null)}
title="음식 장르"
options={cuisineOptions}
value={cuisineFilter}
onChange={(v) => { setCuisineFilter(v); if (v) setBoundsFilterOn(false); }}
/>
<FilterSheet
open={openSheet === "price"}
onClose={() => setOpenSheet(null)}
title="가격대"
options={priceOptions}
value={priceFilter}
onChange={(v) => { setPriceFilter(v); if (v) setBoundsFilterOn(false); }}
/>
<FilterSheet
open={openSheet === "country"}
onClose={() => setOpenSheet(null)}
title="나라"
options={countryOptions}
value={countryFilter}
onChange={handleCountryChange}
/>
<FilterSheet
open={openSheet === "city"}
onClose={() => setOpenSheet(null)}
title="시/도"
options={cityOptions}
value={cityFilter}
onChange={handleCityChange}
/>
<FilterSheet
open={openSheet === "district"}
onClose={() => setOpenSheet(null)}
title="구/군"
options={districtOptions}
value={districtFilter}
onChange={handleDistrictChange}
/>
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { useEffect, useRef } from "react";
import Icon from "@/components/Icon";
export interface FilterOption {
label: string;
value: string;
group?: string;
}
interface FilterSheetProps {
open: boolean;
onClose: () => void;
title: string;
options: FilterOption[];
value: string;
onChange: (value: string) => void;
}
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, [open]);
// Group options by group field
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
const key = opt.group || "";
if (!acc[key]) acc[key] = [];
acc[key].push(opt);
return acc;
}, {});
const groups = Object.keys(grouped);
const handleSelect = (v: string) => {
onChange(v);
onClose();
};
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[60] bg-black/30 md:hidden"
onClick={onClose}
/>
{/* Sheet */}
<div
ref={sheetRef}
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
>
{/* Handle */}
<div className="flex justify-center pt-2 pb-1 shrink-0">
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
<Icon name="close" size={20} />
</button>
</div>
{/* Options */}
<div className="flex-1 overflow-y-auto overscroll-contain pb-safe">
{/* 전체(초기화) */}
<button
onClick={() => handleSelect("")}
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 ${
!value ? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20" : "text-gray-700 dark:text-gray-300"
}`}
>
<span className="text-[15px]"></span>
{!value && <Icon name="check" size={18} className="text-brand-500" />}
</button>
{groups.map((group) => (
<div key={group}>
{group && (
<div className="px-4 py-2.5 text-xs font-semibold text-gray-400 dark:text-gray-500 tracking-wider bg-gray-50 dark:bg-gray-800/50 sticky top-0">
{group}
</div>
)}
{grouped[group].map((opt) => (
<button
key={opt.value}
onClick={() => handleSelect(opt.value)}
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 active:bg-gray-100 dark:active:bg-gray-800 ${
value === opt.value
? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20"
: "text-gray-700 dark:text-gray-300"
}`}
>
<span className="text-[15px]">{opt.label}</span>
{value === opt.value && <Icon name="check" size={18} className="text-brand-500" />}
</button>
))}
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -8,6 +8,7 @@ import {
InfoWindow,
useMap,
} from "@vis.gl/react-google-maps";
import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
@@ -62,10 +63,83 @@ interface MapViewProps {
activeChannel?: string;
}
type RestaurantProps = { restaurant: Restaurant };
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
function useSupercluster(restaurants: Restaurant[]) {
const indexRef = useRef<Supercluster<{ restaurant: Restaurant }> | null>(null);
const points: RestaurantFeature[] = useMemo(
() =>
restaurants.map((r) => ({
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
properties: { restaurant: r },
})),
[restaurants]
);
const index = useMemo(() => {
const sc = new Supercluster<{ restaurant: Restaurant }>({
radius: 60,
maxZoom: 16,
minPoints: 2,
});
sc.load(points);
indexRef.current = sc;
return sc;
}, [points]);
const getClusters = useCallback(
(bounds: MapBounds, zoom: number) => {
return index.getClusters(
[bounds.west, bounds.south, bounds.east, bounds.north],
Math.floor(zoom)
);
},
[index]
);
const getExpansionZoom = useCallback(
(clusterId: number): number => {
try {
return index.getClusterExpansionZoom(clusterId);
} catch {
return 17;
}
},
[index]
);
return { getClusters, getExpansionZoom, index };
}
function getClusterSize(count: number): number {
if (count < 10) return 36;
if (count < 50) return 42;
if (count < 100) return 48;
return 54;
}
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
const map = useMap();
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
const [zoom, setZoom] = useState(13);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
// Build a lookup for restaurants by id
const restaurantMap = useMemo(() => {
const m: Record<string, Restaurant> = {};
restaurants.forEach((r) => { m[r.id] = r; });
return m;
}, [restaurants]);
const clusters = useMemo(() => {
if (!bounds) return [];
return getClusters(bounds, zoom);
}, [bounds, zoom, getClusters]);
const handleMarkerClick = useCallback(
(r: Restaurant) => {
@@ -75,6 +149,41 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
[onSelectRestaurant]
);
const handleClusterClick = useCallback(
(clusterId: number, lng: number, lat: number) => {
if (!map) return;
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
map.panTo({ lat, lng });
map.setZoom(expansionZoom);
},
[map, getExpansionZoom]
);
// Track camera changes for clustering
useEffect(() => {
if (!map) return;
const listener = map.addListener("idle", () => {
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
});
// Trigger initial bounds
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
return () => google.maps.event.removeListener(listener);
}, [map]);
// Fly to a specific location (region filter)
useEffect(() => {
if (!map || !flyTo) return;
@@ -92,7 +201,46 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
return (
<>
{restaurants.map((r) => {
{clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates;
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
if (isCluster) {
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
const size = getClusterSize(point_count);
return (
<AdvancedMarker
key={`cluster-${cluster_id}`}
position={{ lat, lng }}
onClick={() => handleClusterClick(cluster_id, lng, lat)}
zIndex={100}
>
<div
style={{
width: size,
height: size,
borderRadius: "50%",
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
border: "3px solid #fff",
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: size > 42 ? 15 : 13,
fontWeight: 700,
cursor: "pointer",
transition: "transform 0.2s ease",
}}
>
{point_count}
</div>
</AdvancedMarker>
);
}
// Individual marker
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
const isSelected = selected?.id === r.id;
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
@@ -167,16 +315,16 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
</p>
)}
{infoTarget.cuisine_type && (
<p className="text-sm text-gray-600">{infoTarget.cuisine_type}</p>
<p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
)}
{infoTarget.address && (
<p className="text-xs text-gray-500 mt-1">{infoTarget.address}</p>
<p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
)}
{infoTarget.price_range && (
<p className="text-xs text-gray-500">{infoTarget.price_range}</p>
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
)}
{infoTarget.phone && (
<p className="text-xs text-gray-500">{infoTarget.phone}</p>
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
)}
<button
onClick={() => onSelectRestaurant?.(infoTarget)}

View File

@@ -0,0 +1,194 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import Icon from "@/components/Icon";
interface MemoSectionProps {
restaurantId: string;
}
function StarSelector({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500 mr-1">:</span>
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
<button
key={v}
type="button"
onClick={() => onChange(v)}
className={`w-6 h-6 text-xs rounded border ${
value === v
? "bg-yellow-500 text-white border-yellow-600"
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
}`}
>
{v}
</button>
))}
</div>
);
}
function StarDisplay({ rating }: { rating: number }) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<span key={i} className={rating >= i - 0.5 ? "text-yellow-500" : "text-gray-300"}>
</span>
);
}
return <span className="text-sm">{stars}</span>;
}
export default function MemoSection({ restaurantId }: MemoSectionProps) {
const { user } = useAuth();
const [memo, setMemo] = useState<Memo | null>(null);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(false);
// Form state
const [rating, setRating] = useState(3);
const [text, setText] = useState("");
const [visitedAt, setVisitedAt] = useState(new Date().toISOString().slice(0, 10));
const [submitting, setSubmitting] = useState(false);
const loadMemo = useCallback(() => {
if (!user) { setLoading(false); return; }
setLoading(true);
api.getMemo(restaurantId)
.then(setMemo)
.catch(() => setMemo(null))
.finally(() => setLoading(false));
}, [restaurantId, user]);
useEffect(() => {
loadMemo();
}, [loadMemo]);
if (!user) return null;
const startEdit = () => {
if (memo) {
setRating(memo.rating || 3);
setText(memo.memo_text || "");
setVisitedAt(memo.visited_at || new Date().toISOString().slice(0, 10));
} else {
setRating(3);
setText("");
setVisitedAt(new Date().toISOString().slice(0, 10));
}
setEditing(true);
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
const saved = await api.upsertMemo(restaurantId, {
rating,
memo_text: text || undefined,
visited_at: visitedAt || undefined,
});
setMemo(saved);
setShowForm(false);
setEditing(false);
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!confirm("메모를 삭제하시겠습니까?")) return;
await api.deleteMemo(restaurantId);
setMemo(null);
};
return (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<Icon name="edit_note" size={18} className="text-brand-600" />
<h3 className="font-semibold text-sm"> </h3>
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded"></span>
</div>
{loading ? (
<div className="animate-pulse space-y-2">
<div className="h-3 w-32 bg-gray-200 rounded" />
<div className="h-3 w-full bg-gray-200 rounded" />
</div>
) : showForm ? (
<form onSubmit={handleSubmit} className="space-y-3 border border-brand-200 rounded-lg p-3 bg-brand-50/30">
<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 text-white text-sm rounded hover:bg-brand-600 disabled:opacity-50"
>
{submitting ? "저장 중..." : editing && memo ? "수정" : "저장"}
</button>
<button
type="button"
onClick={() => { setShowForm(false); setEditing(false); }}
className="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
>
</button>
</div>
</form>
) : memo ? (
<div className="border border-brand-200 rounded-lg p-3 bg-brand-50/30">
<div className="flex items-center gap-2 mb-1">
{memo.rating && <StarDisplay rating={memo.rating} />}
{memo.visited_at && (
<span className="text-xs text-gray-400">: {memo.visited_at}</span>
)}
</div>
{memo.memo_text && (
<p className="text-sm text-gray-700 mt-1">{memo.memo_text}</p>
)}
<div className="flex gap-2 mt-2">
<button onClick={startEdit} className="text-xs text-blue-600 hover:underline"></button>
<button onClick={handleDelete} className="text-xs text-red-600 hover:underline"></button>
</div>
</div>
) : (
<button
onClick={startEdit}
className="px-3 py-1.5 border border-dashed border-brand-300 text-brand-600 text-sm rounded-lg hover:bg-brand-50 transition-colors"
>
<Icon name="add" size={14} className="mr-0.5" />
</button>
)}
</div>
);
}

View File

@@ -1,36 +1,72 @@
"use client";
import type { Review } from "@/lib/api";
import { useState } from "react";
import type { Review, Memo } from "@/lib/api";
import Icon from "@/components/Icon";
interface MyReview extends Review {
restaurant_id: string;
restaurant_name: string | null;
}
interface MyMemo extends Memo {
restaurant_name: string | null;
}
interface MyReviewsListProps {
reviews: MyReview[];
memos: MyMemo[];
onClose: () => void;
onSelectRestaurant: (restaurantId: string) => void;
}
export default function MyReviewsList({
reviews,
memos,
onClose,
onSelectRestaurant,
}: MyReviewsListProps) {
const [tab, setTab] = useState<"reviews" | "memos">("reviews");
return (
<div className="p-4 space-y-3">
<div className="flex justify-between items-center">
<h2 className="font-bold text-lg"> ({reviews.length})</h2>
<h2 className="font-bold text-lg"> </h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
className="text-gray-400 hover:text-gray-600"
>
x
<Icon name="close" size={18} />
</button>
</div>
{reviews.length === 0 ? (
<div className="flex gap-1 border-b">
<button
onClick={() => setTab("reviews")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "reviews"
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Icon name="rate_review" size={14} className="mr-1" />
({reviews.length})
</button>
<button
onClick={() => setTab("memos")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "memos"
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Icon name="edit_note" size={14} className="mr-1" />
({memos.length})
</button>
</div>
{tab === "reviews" ? (
reviews.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
@@ -63,6 +99,44 @@ export default function MyReviewsList({
</button>
))}
</div>
)
) : (
memos.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
) : (
<div className="space-y-2">
{memos.map((m) => (
<button
key={m.id}
onClick={() => onSelectRestaurant(m.restaurant_id)}
className="w-full text-left border border-brand-200 rounded-lg p-3 bg-brand-50/30 hover:bg-brand-50 transition-colors"
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-sm truncate">
{m.restaurant_name || "알 수 없는 식당"}
</span>
{m.rating && (
<span className="text-yellow-500 text-sm shrink-0 ml-2">
{"★".repeat(Math.round(m.rating))}
<span className="text-gray-500 ml-1">{m.rating}</span>
</span>
)}
</div>
{m.memo_text && (
<p className="text-xs text-gray-600 line-clamp-2">
{m.memo_text}
</p>
)}
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-gray-400">
{m.visited_at && <span>: {m.visited_at}</span>}
<span className="text-brand-400"></span>
</div>
</button>
))}
</div>
)
)}
</div>
);

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { api, getToken } from "@/lib/api";
import type { Restaurant, VideoLink } from "@/lib/api";
import ReviewSection from "@/components/ReviewSection";
import MemoSection from "@/components/MemoSection";
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
import Icon from "@/components/Icon";
@@ -51,7 +52,7 @@ export default function RestaurantDetail({
<div className="p-4 space-y-4">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold dark:text-gray-100">{restaurant.name}</h2>
<h2 className="text-xl font-bold dark:text-gray-100">{restaurant.name}</h2>
{getToken() && (
<button
onClick={handleToggleFavorite}
@@ -94,30 +95,30 @@ export default function RestaurantDetail({
</div>
)}
<div className="space-y-1 text-sm dark:text-gray-300">
<div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
{restaurant.cuisine_type && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.cuisine_type}
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.cuisine_type}</span>
</p>
)}
{restaurant.address && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.address}
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
</p>
)}
{restaurant.region && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.region}
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.region}</span>
</p>
)}
{restaurant.price_range && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.price_range}
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.price_range}</span>
</p>
)}
{restaurant.phone && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span>{" "}
<span className="text-gray-400 dark:text-gray-500"></span>{" "}
<a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline">
{restaurant.phone}
</a>
@@ -257,6 +258,7 @@ export default function RestaurantDetail({
)}
<ReviewSection restaurantId={restaurant.id} />
<MemoSection restaurantId={restaurant.id} />
</div>
);
}

View File

@@ -44,28 +44,44 @@ export default function RestaurantList({
: "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<div className="flex items-start justify-between gap-2">
<h4 className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{/* 1줄: 식당명 + 지역 + 별점 (전체 폭) */}
<div className="flex items-baseline gap-1.5 flex-wrap">
<h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100 shrink-0">
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
{r.name}
</h4>
{r.region && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
)}
{r.rating && (
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">
{r.rating}
</span>
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0"> {r.rating}</span>
)}
</div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
{/* 2줄: 종류/가격(왼) + 유튜브채널(우) */}
<div className="flex items-center gap-2 mt-1.5">
<div className="flex gap-x-2 text-xs flex-1 min-w-0">
{r.cuisine_type && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.cuisine_type}</span>
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 shrink-0">{r.cuisine_type}</span>
)}
{r.price_range && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.price_range}</span>
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 truncate min-w-0">{r.price_range}</span>
)}
</div>
{r.region && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500 truncate">{r.region}</p>
{r.channels && r.channels.length > 0 && (
<div className="shrink-0 flex flex-wrap gap-1 justify-end">
{r.channels.map((ch) => (
<span
key={ch}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium truncate max-w-[120px]"
>
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
<span className="truncate">{ch}</span>
</span>
))}
</div>
)}
</div>
{/* 3줄: 태그 (전체 폭) */}
{r.foods_mentioned && r.foods_mentioned.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{r.foods_mentioned.slice(0, 5).map((f, i) => (
@@ -81,19 +97,6 @@ export default function RestaurantList({
)}
</div>
)}
{r.channels && r.channels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{r.channels.map((ch) => (
<span
key={ch}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
>
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
{ch}
</span>
))}
</div>
)}
</button>
))}
</div>

View File

@@ -234,6 +234,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
{showForm && (
<div className="mb-3">
<ReviewForm
initialDate={new Date().toISOString().slice(0, 10)}
onSubmit={handleCreate}
onCancel={() => setShowForm(false)}
submitLabel="작성"

View File

@@ -129,6 +129,17 @@ export interface Review {
user_avatar_url: string | null;
}
export interface Memo {
id: string;
user_id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
updated_at: string;
}
export interface DaemonConfig {
scan_enabled: boolean;
scan_interval_min: number;
@@ -256,6 +267,28 @@ export const api = {
);
},
// Memos
getMemo(restaurantId: string) {
return fetchApi<Memo>(`/api/restaurants/${restaurantId}/memo`);
},
upsertMemo(restaurantId: string, data: { rating?: number; memo_text?: string; visited_at?: string }) {
return fetchApi<Memo>(`/api/restaurants/${restaurantId}/memo`, {
method: "POST",
body: JSON.stringify(data),
});
},
deleteMemo(restaurantId: string) {
return fetchApi<void>(`/api/restaurants/${restaurantId}/memo`, {
method: "DELETE",
});
},
getMyMemos() {
return fetchApi<(Memo & { restaurant_name: string | null })[]>("/api/users/me/memos");
},
// Stats
recordVisit() {
return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" });
@@ -281,6 +314,7 @@ export const api = {
created_at: string | null;
favorite_count: number;
review_count: number;
memo_count: number;
}[];
total: number;
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
@@ -315,6 +349,20 @@ export const api = {
>(`/api/admin/users/${userId}/reviews`);
},
getAdminUserMemos(userId: string) {
return fetchApi<
{
id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
restaurant_name: string | null;
}[]
>(`/api/admin/users/${userId}/memos`);
},
// Admin
addChannel(channelId: string, channelName: string, titleFilter?: string) {
return fetchApi<{ id: string; channel_id: string }>("/api/channels", {

View File

@@ -1,49 +1,154 @@
/**
* Cuisine type → Material Symbols icon name mapping.
* Cuisine type → icon mapping.
* Material Symbols icon name for RestaurantList (existing usage).
* Tabler icon component name for genre card chips (home tab).
*
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
*/
// ── Material Symbols (for RestaurantList etc.) ──
const CUISINE_ICON_MAP: Record<string, string> = {
"한식": "rice_bowl",
"일식": "set_meal",
"중식": "ramen_dining",
"중식": "skillet",
"양식": "dinner_dining",
"아시아": "ramen_dining",
"아시아": "restaurant",
"기타": "flatware",
};
// Sub-category overrides for more specific icons
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
{ keyword: "회/횟집", icon: "set_meal" },
{ keyword: "해산물", icon: "set_meal" },
{ keyword: "백반/한정식", icon: "rice_bowl" },
{ keyword: "국밥/해장국", icon: "soup_kitchen" },
{ keyword: "찌개/전골/탕", icon: "outdoor_grill" },
{ keyword: "삼겹살/돼지구이", icon: "kebab_dining" },
{ keyword: "소고기/한우구이", icon: "kebab_dining" },
{ keyword: "곱창/막창", icon: "kebab_dining" },
{ keyword: "소고기/한우구이", icon: "local_fire_department" },
{ keyword: "곱창/막창", icon: "local_fire_department" },
{ keyword: "닭/오리구이", icon: "takeout_dining" },
{ keyword: "스테이크", icon: "kebab_dining" },
{ keyword: "족발/보쌈", icon: "stockpot" },
{ keyword: "회/횟집", icon: "phishing" },
{ keyword: "해산물", icon: "set_meal" },
{ keyword: "분식", icon: "egg_alt" },
{ keyword: "면", icon: "ramen_dining" },
{ keyword: "죽/죽집", icon: "soup_kitchen" },
{ keyword: "순대/순대국", icon: "soup_kitchen" },
{ keyword: "장어/민물", icon: "phishing" },
{ keyword: "주점/포차", icon: "local_bar" },
{ keyword: "파인다이닝/코스", icon: "auto_awesome" },
{ keyword: "스시/오마카세", icon: "set_meal" },
{ keyword: "라멘", icon: "ramen_dining" },
{ keyword: "돈카츠", icon: "lunch_dining" },
{ keyword: "텐동/튀김", icon: "tapas" },
{ keyword: "이자카야", icon: "sake" },
{ keyword: "야키니쿠", icon: "kebab_dining" },
{ keyword: "카레", icon: "skillet" },
{ keyword: "소바/우동", icon: "ramen_dining" },
{ keyword: "중화요리", icon: "skillet" },
{ keyword: "마라/훠궈", icon: "outdoor_grill" },
{ keyword: "딤섬/만두", icon: "egg_alt" },
{ keyword: "양꼬치", icon: "kebab_dining" },
{ keyword: "파스타/이탈리안", icon: "dinner_dining" },
{ keyword: "스테이크", icon: "restaurant" },
{ keyword: "햄버거", icon: "lunch_dining" },
{ keyword: "피자", icon: "local_pizza" },
{ keyword: "프렌치", icon: "auto_awesome" },
{ keyword: "바베큐", icon: "outdoor_grill" },
{ keyword: "브런치", icon: "brunch_dining" },
{ keyword: "비건/샐러드", icon: "eco" },
{ keyword: "베트남", icon: "ramen_dining" },
{ keyword: "태국", icon: "restaurant" },
{ keyword: "인도/중동", icon: "skillet" },
{ keyword: "동남아기타", icon: "restaurant" },
{ keyword: "치킨", icon: "takeout_dining" },
{ keyword: "카페/디저트", icon: "coffee" },
{ keyword: "베이커리", icon: "bakery_dining" },
{ keyword: "치킨", icon: "takeout_dining" },
{ keyword: "주점/포차", icon: "local_bar" },
{ keyword: "이자카야", icon: "sake" },
{ keyword: "라멘", icon: "ramen_dining" },
{ keyword: "국밥/해장국", icon: "soup_kitchen" },
{ keyword: "분식", icon: "ramen_dining" },
{ keyword: "뷔페", icon: "brunch_dining" },
{ keyword: "퓨전", icon: "auto_awesome" },
];
const DEFAULT_ICON = "flatware";
export function getCuisineIcon(cuisineType: string | null | undefined): string {
if (!cuisineType) return DEFAULT_ICON;
// Check sub-category first
for (const rule of SUB_ICON_RULES) {
if (cuisineType.includes(rule.keyword)) return rule.icon;
}
// Fall back to main category (prefix before |)
const main = cuisineType.split("|")[0];
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
}
// ── Tabler Icons (for genre card chips) ──
// Returns Tabler icon component name (PascalCase without "Icon" prefix)
const TABLER_CUISINE_MAP: Record<string, string> = {
"한식": "BowlChopsticks",
"일식": "Fish",
"중식": "Soup",
"양식": "Pizza",
"아시아": "BowlSpoon",
"기타": "Cookie",
};
const TABLER_SUB_RULES: { keyword: string; icon: string }[] = [
// 한식
{ keyword: "백반/한정식", icon: "BowlChopsticks" },
{ keyword: "국밥/해장국", icon: "Soup" },
{ keyword: "찌개/전골/탕", icon: "Cooker" },
{ keyword: "삼겹살/돼지구이", icon: "Meat" },
{ keyword: "소고기/한우구이", icon: "Grill" },
{ keyword: "곱창/막창", icon: "GrillFork" },
{ keyword: "닭/오리구이", icon: "Meat" },
{ keyword: "족발/보쌈", icon: "Meat" },
{ keyword: "회/횟집", icon: "Fish" },
{ keyword: "해산물", icon: "Fish" },
{ keyword: "분식", icon: "EggFried" },
{ keyword: "면", icon: "BowlChopsticks" },
{ keyword: "죽/죽집", icon: "BowlSpoon" },
{ keyword: "순대/순대국", icon: "Soup" },
{ keyword: "장어/민물", icon: "Fish" },
{ keyword: "주점/포차", icon: "Beer" },
{ keyword: "파인다이닝/코스", icon: "GlassChampagne" },
// 일식
{ keyword: "스시/오마카세", icon: "Fish" },
{ keyword: "라멘", icon: "Soup" },
{ keyword: "돈카츠", icon: "Meat" },
{ keyword: "텐동/튀김", icon: "EggFried" },
{ keyword: "이자카야", icon: "GlassCocktail" },
{ keyword: "야키니쿠", icon: "Grill" },
{ keyword: "카레", icon: "BowlSpoon" },
{ keyword: "소바/우동", icon: "BowlChopsticks" },
// 중식
{ keyword: "중화요리", icon: "Soup" },
{ keyword: "마라/훠궈", icon: "Pepper" },
{ keyword: "딤섬/만두", icon: "Egg" },
{ keyword: "양꼬치", icon: "Grill" },
// 양식
{ keyword: "파스타/이탈리안", icon: "BowlSpoon" },
{ keyword: "스테이크", icon: "Meat" },
{ keyword: "햄버거", icon: "Burger" },
{ keyword: "피자", icon: "Pizza" },
{ keyword: "프렌치", icon: "GlassChampagne" },
{ keyword: "바베큐", icon: "GrillSpatula" },
{ keyword: "브런치", icon: "EggFried" },
{ keyword: "비건/샐러드", icon: "Salad" },
// 아시아
{ keyword: "베트남", icon: "BowlChopsticks" },
{ keyword: "태국", icon: "Pepper" },
{ keyword: "인도/중동", icon: "BowlSpoon" },
{ keyword: "동남아기타", icon: "BowlSpoon" },
// 기타
{ keyword: "치킨", icon: "Meat" },
{ keyword: "카페/디저트", icon: "Coffee" },
{ keyword: "베이커리", icon: "Bread" },
{ keyword: "뷔페", icon: "Cheese" },
{ keyword: "퓨전", icon: "Cookie" },
];
export function getTablerCuisineIcon(cuisineType: string | null | undefined): string {
if (!cuisineType) return "Bowl";
for (const rule of TABLER_SUB_RULES) {
if (cuisineType.includes(rule.keyword)) return rule.icon;
}
const main = cuisineType.split("|")[0];
return TABLER_CUISINE_MAP[main] || "Bowl";
}