Compare commits
21 Commits
v0.1.60
...
1815a4c4b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1815a4c4b2 | ||
|
|
c13b976ca3 | ||
|
|
e0e9ab6936 | ||
|
|
fd4ab18fbf | ||
|
|
622ea58cd1 | ||
|
|
7bf21b5b45 | ||
|
|
6754976e72 | ||
|
|
5d62866941 | ||
|
|
7639fd009d | ||
|
|
4760b5a284 | ||
|
|
2eb16ce861 | ||
|
|
14384b0c71 | ||
|
|
250b067d87 | ||
|
|
6a885c5203 | ||
|
|
a9dc1dad6a | ||
|
|
21eb1e9562 | ||
|
|
94be5a81e6 | ||
|
|
1d767bee37 | ||
|
|
0676a31cfd | ||
|
|
1164139312 | ||
|
|
78f7e83a0e |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -20,3 +20,7 @@ k8s/secrets.yaml
|
|||||||
backend/cookies.txt
|
backend/cookies.txt
|
||||||
backend-java/cookies.txt
|
backend-java/cookies.txt
|
||||||
**/cookies.txt
|
**/cookies.txt
|
||||||
|
|
||||||
|
# 작업 산출물 (로컬 전용)
|
||||||
|
reviews/
|
||||||
|
screenshots/
|
||||||
|
|||||||
103
CHANGELOG.md
103
CHANGELOG.md
@@ -4,8 +4,111 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-27
|
||||||
|
|
||||||
|
### ✅ [QA] #533 언급 적은 식당 제외 필터 — 검증 통과
|
||||||
|
- 설계서(`docs/design/533-min-mentions-filter/README.md`)와 구현(commit 7639fd0) 일치 확인
|
||||||
|
- 백엔드: `RestaurantMapper.xml findAll` 스칼라 서브쿼리(`video_count`) + `min_mentions` 필터, `DISTINCT` 조합에도 중복 없음 확인
|
||||||
|
- 프론트: `page.tsx` `filteredRestaurants`에서 `video_count` 가드 적용, 데스크톱 툴바 "언급" 셀렉트(2/3/5회+)
|
||||||
|
- dev API 실측 검증: `min_mentions=0/1/2/3/5/100` 전 구간에서 경계 위반 0건 (=2→116개, =3→36개, =5→5개, =100→0개, 음수/0은 미적용과 동일)
|
||||||
|
- `cuisine`/`channel` 등 기존 필터와 조합 시에도 회귀 없음 확인
|
||||||
|
- 회귀 테스트 추가: `passesMinMentions()`을 `frontend/src/lib/filters.ts`로 추출(동작 변경 없음, 테스트 가능성 확보) + `frontend/__tests__/filters.test.ts`(6케이스: 미적용/음수/미만/경계값/초과/null·undefined)
|
||||||
|
- 백엔드 빌드, `tsc --noEmit`, 프론트 전체 jest 스위트(6 suites, 32 tests) 통과
|
||||||
|
- 갭(참고, 반려 사유 아님): `min_mentions` 필터 UI가 데스크톱 툴바에만 있고 모바일 FilterSheet에는 미적용 — 설계서에서 서버사이드 일원화(#535)로 이미 분리한 범위, 모바일 UI 적용은 #535 진행 시 함께 처리 권고
|
||||||
|
- 04-QA → 05-Designer 로 전진
|
||||||
|
|
||||||
|
### 🎨 [Designer] #532 지역 표시 빈 배지 방지 — 렌더 가드 정리
|
||||||
|
- 문제: `RestaurantList.tsx`/`RestaurantDetail.tsx`가 원본 `region` 문자열(예: `"|"`, `"한국|"`)의 truthy 여부로 표시 여부를 결정 → 전 토큰이 더미/빈 값이어서 `formatRegion()` 결과가 `""`이 되는 경우에도 빈 지역 배지/줄이 렌더되어 레이아웃에 불필요한 간격이 남을 수 있었음(dev 데이터에서는 미발견, 이론적 엣지케이스)
|
||||||
|
- 해결: 표시 가드를 `r.region &&` → `formatRegion(r.region) &&` 로 변경(두 파일 모두). `formatRegion()` 로직/시그니처는 변경 없음 — 호출부 조건만 정리
|
||||||
|
- `tsc --noEmit`, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
|
||||||
|
- 05-Designer → 06-Reviewer 로 전진
|
||||||
|
|
||||||
|
### ✅ [QA] #545 해외 식당 예약 버튼 숨김 — 검증 통과
|
||||||
|
- 설계서(`docs/design/545-hide-foreign-reservation-buttons/README.md`) 인수조건 대비 구현(`RestaurantDetail.tsx`) 일치 확인
|
||||||
|
- 테이블링/캐치테이블 버튼 2곳 모두 `isKoreaRestaurant(restaurant) &&` 게이트 적용됨 (지도 링크 분기와 동일 판정 기준 재사용)
|
||||||
|
- 회귀 테스트 추가: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스)
|
||||||
|
- 국내 좌표(서울) → 버튼 노출, 해외 좌표(방콕, 대표 발견 케이스) → 버튼 숨김
|
||||||
|
- 해외+URL 없음(도쿄) → 숨김 유지, 좌표 없음+region null → fallback으로 국내 간주(기존 동작 유지)
|
||||||
|
- 예약 URL `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지, 회귀 없음)
|
||||||
|
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
|
||||||
|
- 04-QA → 05-Designer 로 전진
|
||||||
|
|
||||||
|
### ✅ [QA] #532 지역 표시 계층화 — 검증 통과
|
||||||
|
- `formatRegion()` 설계서(docs/design/532-region-display/README.md) 명세대로 구현됨을 확인
|
||||||
|
- 단위 테스트 추가: `frontend/__tests__/region.test.ts` (falsy, 계층 조인, `null`/빈 토큰/더미 `나라` 제거, 단일 토큰, 전량 더미, trim 8케이스) — 전부 통과
|
||||||
|
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(4 suites, 21 tests) 통과
|
||||||
|
- 실제 dev DB 데이터(`region: "한국|서울|"` 등)로 엣지케이스 재확인 — 설계서 예시와 일치
|
||||||
|
- 04-QA → 05-Designer 로 전진
|
||||||
|
|
||||||
|
## 2026-07-19
|
||||||
|
|
||||||
|
### 📋 설계서/Redmine 현행화 — #545/#533/#532 파이프라인 정지 점검
|
||||||
|
- 발견: #545/#533/#532 세 이슈가 Developer 구현 완료(2026-06-28) 이후 04-QA 단계에서 3주간 정지 확인
|
||||||
|
- #545: Architect 단계 없이 구현되어 설계서 게이트 위반 확인 → `docs/design/545-hide-foreign-reservation-buttons/README.md` 소급 작성
|
||||||
|
- #533, #532: 기존 설계서가 실제 구현과 일치함을 재확인 (수정 없음)
|
||||||
|
- Redmine 3건에 현재 상태 저널 노트 반영 (잔여: 육안 QA)
|
||||||
|
- git/Redmine/설계서 3자 정합성 점검 — git 클린, 대응 커밋 확인 완료
|
||||||
|
|
||||||
|
## 2026-06-28
|
||||||
|
|
||||||
|
### 🐛 해외 식당에 테이블링/캐치테이블 예약 버튼 노출 수정 (#545)
|
||||||
|
- 문제: 국내 전용 예약 서비스 버튼이 해외 식당 상세에도 노출 (URL만 있으면 렌더)
|
||||||
|
- 해결: RestaurantDetail.tsx 두 예약 버튼을 `isKoreaRestaurant()`로 게이트 (지도 링크와 동일 기준)
|
||||||
|
- 후속(옵션): 백엔드 예약 URL 검색/벌크가 해외 식당 제외
|
||||||
|
|
||||||
|
### ✨ 언급 적은(관련도 낮은) 식당 제외 필터 (#533)
|
||||||
|
- 신호: "의미있는 언급 수" = video_restaurants 중 relevance ∈ (strong, unknown) 개수 (weak/incidental 제외)
|
||||||
|
- 백엔드: 식당 목록 응답에 `video_count` 추가 + `min_mentions` 서버 필터 파라미터
|
||||||
|
- RestaurantMapper.xml findAll: 스칼라 서브쿼리 video_count + minMentions 필터
|
||||||
|
- Restaurant.videoCount 필드 / Service·Controller 스레드 / 캐시키 `mm=` 포함
|
||||||
|
- 검증: min_mentions=2 → 116개(전부 ≥2, 위반 0), =3 → 36개
|
||||||
|
- 프론트: Restaurant.video_count 타입 + 데스크톱 툴바 "언급" 필터(2/3/5회+) 클라이언트 필터
|
||||||
|
- 후속: 모바일 FilterSheet 적용 + 서버사이드 일원화(#535)
|
||||||
|
- 설계서: `docs/design/533-min-mentions-filter/README.md`
|
||||||
|
|
||||||
|
### 🐛 지역 표시 계층화 — "한국|서울" → "한국 › 서울" (#532)
|
||||||
|
- 문제: 식당 상세/리스트가 region 원본(`한국|서울|강남구`, 파이프 노출)을 그대로 표시
|
||||||
|
- 해결: 공용 유틸 `lib/region.ts` `formatRegion()` 신설 — 빈토큰/`null`/더미(`나라`) 제거 후 ` › ` 계층 조인
|
||||||
|
- 적용: `RestaurantDetail.tsx`(지역 표기), `RestaurantList.tsx`(카드 지역)
|
||||||
|
- 검색 쿼리용 `buildSearchQuery`(공백 조인)는 유지 — 외부 지도 검색 호환
|
||||||
|
- 설계서: `docs/design/532-region-display/README.md`
|
||||||
|
|
||||||
|
## 2026-06-17
|
||||||
|
|
||||||
|
### 🎯 클러스터 expansion 후 마커 클릭 시 재묶임 방지 (v0.1.66)
|
||||||
|
- 기존: selected 시 무조건 zoom 16 → 클러스터 expansion(18)에서 마커 클릭하면 다시 16으로 줄어 재묶임
|
||||||
|
- 변경: 현재 zoom ≥ 16이면 유지, 미만이면 16으로 (NaverMap morph + GoogleMap setZoom 조건부)
|
||||||
|
- page.tsx의 setRegionFlyTo 호출 제거 — selected useEffect로 일원화 (zoom 16 강제 회피)
|
||||||
|
|
||||||
|
### 🐛 NaverMap 클러스터 클릭 시 중앙 이동 fix (v0.1.65)
|
||||||
|
- panTo(애니메이션) + setZoom(z, true)(애니메이션) 두 애니메이션 동시 호출이 서로 cancel
|
||||||
|
- 단일 식당은 줌 변화 없어서 안 보였고, 클러스터는 줌 크게 변경 → 충돌 가시화
|
||||||
|
- NaverMap SDK의 `morph(latlng, zoom)` 한 메서드로 통일 — center+zoom 동시 변경
|
||||||
|
|
||||||
|
### 🎯 NaverMapView selected 자동 panTo + zoom (v0.1.64)
|
||||||
|
- 마커/클러스터/리스트 어디서 선택해도 그 식당이 화면 중앙으로 + zoom 16
|
||||||
|
- GoogleMapView에는 이미 있던 useEffect [selected] 패턴을 동일하게 추가
|
||||||
|
|
||||||
## 2026-06-16
|
## 2026-06-16
|
||||||
|
|
||||||
|
### 🧹 미커밋 잡변경 정리 + 5개 분리 commit (v0.1.63)
|
||||||
|
- backend: 어드민 권한 토글 service/mapper/CORS PATCH 미커밋분 적용
|
||||||
|
- backend: JsonUtil.normalizeEvaluation 정식 commit (PipelineService/RestaurantService 이미 호출 중)
|
||||||
|
- frontend: Phosphor 아이콘 마이그레이션 + FoodIcon 신규 + brand-guide.md
|
||||||
|
- docs: oke-deploy-howto.md 신규
|
||||||
|
- chore: ecosystem PORT=3001 + reviews/screenshots gitignore
|
||||||
|
|
||||||
|
### 🎯 식당 선택 시 지도 자동 줌인/이동 (v0.1.62)
|
||||||
|
- 리스트 / 검색결과에서 식당 클릭 → setRegionFlyTo로 그 식당 좌표 + zoom 16
|
||||||
|
- 지도가 선택 식당으로 panTo + zoom — NaverMap/GoogleMap 둘 다
|
||||||
|
- 마커의 selected 강조(1.15× + 파란 박스)와 함께 동작
|
||||||
|
|
||||||
|
### 🏷️ NaverMapView 마커에 식당명 박스 (v0.1.61)
|
||||||
|
- 단순 동그라미 → GoogleMapView와 동일 핀 디자인(박스+화살표+식당명+cuisine 아이콘)
|
||||||
|
- 채널별 배경/테두리/화살표 색상, 폐업(business_status CLOSED_*) 표시 회색 + 취소선
|
||||||
|
- selected 식당 강조 (1.15× scale + 파란 박스), zIndex 1000
|
||||||
|
- InfoWindow 제거 (식당명 자체가 박스로 보이므로 불필요)
|
||||||
|
|
||||||
### 🎨 NaverMapView 채널별 마커 색상 (v0.1.60)
|
### 🎨 NaverMapView 채널별 마커 색상 (v0.1.60)
|
||||||
- GoogleMapView와 동일 팔레트 (amber/blue/green/pink/purple/red/teal/yellow)
|
- GoogleMapView와 동일 팔레트 (amber/blue/green/pink/purple/red/teal/yellow)
|
||||||
- 식당의 첫 채널 기준 색상, activeChannel 있으면 그 채널 우선
|
- 식당의 첫 채널 기준 색상, activeChannel 있으면 그 채널 우선
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
config.setAllowedHeaders(List.of("*"));
|
config.setAllowedHeaders(List.of("*"));
|
||||||
config.setAllowCredentials(true);
|
config.setAllowCredentials(true);
|
||||||
|
|
||||||
|
|||||||
@@ -56,17 +56,18 @@ public class RestaurantController {
|
|||||||
@RequestParam(defaultValue = "0") int offset,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@RequestParam(required = false) String cuisine,
|
@RequestParam(required = false) String cuisine,
|
||||||
@RequestParam(required = false) String region,
|
@RequestParam(required = false) String region,
|
||||||
@RequestParam(required = false) String channel) {
|
@RequestParam(required = false) String channel,
|
||||||
|
@RequestParam(name = "min_mentions", required = false) Integer minMentions) {
|
||||||
if (limit > 500) limit = 500;
|
if (limit > 500) limit = 500;
|
||||||
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
|
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
|
||||||
"c=" + cuisine, "r=" + region, "ch=" + channel);
|
"c=" + cuisine, "r=" + region, "ch=" + channel, "mm=" + minMentions);
|
||||||
String cached = cache.getRaw(key);
|
String cached = cache.getRaw(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||||
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
var result = restaurantService.findAll(limit, offset, cuisine, region, channel, minMentions, false);
|
||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,7 @@ public class Restaurant {
|
|||||||
// Transient enrichment fields
|
// Transient enrichment fields
|
||||||
private List<String> channels;
|
private List<String> channels;
|
||||||
private List<String> foodsMentioned;
|
private List<String> foodsMentioned;
|
||||||
|
|
||||||
|
// #533 — 의미있는(strong/unknown) 영상 언급 수
|
||||||
|
private Integer videoCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public interface RestaurantMapper {
|
|||||||
@Param("cuisine") String cuisine,
|
@Param("cuisine") String cuisine,
|
||||||
@Param("region") String region,
|
@Param("region") String region,
|
||||||
@Param("channel") String channel,
|
@Param("channel") String channel,
|
||||||
|
@Param("minMentions") Integer minMentions,
|
||||||
@Param("includeHidden") boolean includeHidden);
|
@Param("includeHidden") boolean includeHidden);
|
||||||
|
|
||||||
// #322 LLM 검증: hidden 표시 갱신
|
// #322 LLM 검증: hidden 표시 갱신
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ public interface UserMapper {
|
|||||||
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
|
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
|
||||||
|
|
||||||
int countAll();
|
int countAll();
|
||||||
|
|
||||||
|
int updateAdmin(@Param("id") String id, @Param("admin") int admin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ public class RestaurantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
|
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
|
||||||
return findAll(limit, offset, cuisine, region, channel, false);
|
return findAll(limit, offset, cuisine, region, channel, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel, boolean includeHidden) {
|
// #533 — minMentions: 의미있는 언급 수가 N 미만인 식당 제외 (null/0이면 미적용)
|
||||||
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, includeHidden);
|
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel,
|
||||||
|
Integer minMentions, boolean includeHidden) {
|
||||||
|
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, minMentions, includeHidden);
|
||||||
enrichRestaurants(restaurants);
|
enrichRestaurants(restaurants);
|
||||||
return restaurants;
|
return restaurants;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package com.tasteby.service;
|
|||||||
import com.tasteby.domain.UserInfo;
|
import com.tasteby.domain.UserInfo;
|
||||||
import com.tasteby.mapper.UserMapper;
|
import com.tasteby.mapper.UserMapper;
|
||||||
import com.tasteby.util.IdGenerator;
|
import com.tasteby.util.IdGenerator;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -47,4 +49,12 @@ public class UserService {
|
|||||||
public int countAll() {
|
public int countAll() {
|
||||||
return mapper.countAll();
|
return mapper.countAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateAdmin(String userId, boolean admin) {
|
||||||
|
int rows = mapper.updateAdmin(userId, admin ? 1 : 0);
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ public final class JsonUtil {
|
|||||||
try {
|
try {
|
||||||
return MAPPER.readValue(json, new TypeReference<>() {});
|
return MAPPER.readValue(json, new TypeReference<>() {});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Collections.emptyMap();
|
// Plain text or malformed JSON (e.g. Python-style single quotes) → wrap as {"text": "..."}
|
||||||
|
return Map.of("text", json.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,24 @@ public final class JsonUtil {
|
|||||||
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
|
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize evaluation to a valid JSON object string (e.g. {"text":"..."}).
|
||||||
|
* Plain text is wrapped, already-valid JSON is returned as-is, and text is truncated to maxLen.
|
||||||
|
*/
|
||||||
|
public static String normalizeEvaluation(String eval, int maxLen) {
|
||||||
|
if (eval == null || eval.isBlank()) return null;
|
||||||
|
String trimmed = eval.trim();
|
||||||
|
if (trimmed.startsWith("{")) return trimmed;
|
||||||
|
if (trimmed.length() > maxLen) {
|
||||||
|
trimmed = trimmed.substring(0, maxLen);
|
||||||
|
}
|
||||||
|
return toJson(Map.of("text", trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalizeEvaluation(String eval) {
|
||||||
|
return normalizeEvaluation(eval, 300);
|
||||||
|
}
|
||||||
|
|
||||||
public static String toJson(Object value) {
|
public static String toJson(Object value) {
|
||||||
try {
|
try {
|
||||||
return MAPPER.writeValueAsString(value);
|
return MAPPER.writeValueAsString(value);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
|
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
|
||||||
<result property="hiddenReason" column="hidden_reason"/>
|
<result property="hiddenReason" column="hidden_reason"/>
|
||||||
<result property="verifiedAt" column="verified_at"/>
|
<result property="verifiedAt" column="verified_at"/>
|
||||||
|
<result property="videoCount" column="video_count"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<!-- ===== Queries ===== -->
|
<!-- ===== Queries ===== -->
|
||||||
@@ -33,7 +34,9 @@
|
|||||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||||
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
|
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
|
||||||
r.business_status, r.rating, r.rating_count, r.updated_at,
|
r.business_status, r.rating, r.rating_count, r.updated_at,
|
||||||
r.hidden, r.hidden_reason, r.verified_at
|
r.hidden, r.hidden_reason, r.verified_at,
|
||||||
|
(SELECT COUNT(*) FROM video_restaurants vrc
|
||||||
|
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count
|
||||||
FROM restaurants r
|
FROM restaurants r
|
||||||
<if test="channel != null and channel != ''">
|
<if test="channel != null and channel != ''">
|
||||||
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
||||||
@@ -55,6 +58,10 @@
|
|||||||
<if test="channel != null and channel != ''">
|
<if test="channel != null and channel != ''">
|
||||||
AND c_f.channel_name = #{channel}
|
AND c_f.channel_name = #{channel}
|
||||||
</if>
|
</if>
|
||||||
|
<if test="minMentions != null and minMentions > 0">
|
||||||
|
AND (SELECT COUNT(*) FROM video_restaurants vrc
|
||||||
|
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) >= #{minMentions}
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY r.updated_at DESC
|
ORDER BY r.updated_at DESC
|
||||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findAllWithCounts" resultMap="userResultMap">
|
<select id="findAllWithCounts" resultMap="userResultMap">
|
||||||
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
|
SELECT u.id, u.email, u.nickname, u.avatar_url, u.is_admin, u.provider, u.created_at,
|
||||||
NVL(fav.cnt, 0) AS favorite_count,
|
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
|
NVL(memo.cnt, 0) AS memo_count
|
||||||
@@ -53,4 +53,8 @@
|
|||||||
SELECT COUNT(*) FROM tasteby_users
|
SELECT COUNT(*) FROM tasteby_users
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<update id="updateAdmin">
|
||||||
|
UPDATE tasteby_users SET is_admin = #{admin,jdbcType=NUMERIC} WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
46
docs/design/532-region-display/README.md
Normal file
46
docs/design/532-region-display/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# #532 — 지역 표시 계층화 (formatRegion)
|
||||||
|
|
||||||
|
## 배경 / 문제
|
||||||
|
지역(`region`)은 `나라|시도|구군` 파이프 구분 문자열(예: `한국|서울|강남구`).
|
||||||
|
사용자 화면에 **원본이 그대로 노출**되어 `한국|서울|강남구`(파이프) 또는 평문 `한국 서울`로 보임.
|
||||||
|
기대: **`한국 › 서울 › 구군`** 계층(breadcrumb) 표시.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
- 식당 상세 시트의 "지역" 표기 (`RestaurantDetail.tsx`)
|
||||||
|
- 식당 리스트 카드의 지역 표기 (`RestaurantList.tsx`)
|
||||||
|
- 공용 유틸 `formatRegion()` 신설 (`lib/region.ts`)
|
||||||
|
|
||||||
|
비범위: 지역 *필터* 동작/정렬(정상), 외부 지도 검색 쿼리(`buildSearchQuery` — 공백 조인 유지), 데이터 정합성(별도 이슈).
|
||||||
|
|
||||||
|
## 함수 설계
|
||||||
|
|
||||||
|
### `formatRegion(region: string | null | undefined): string`
|
||||||
|
- **입력**: 파이프 구분 지역 문자열 (예: `"한국|서울|강남구"`, `"일본|null"`, `"한국||관악구"`, `""`)
|
||||||
|
- **처리**:
|
||||||
|
1. falsy → `""` 반환
|
||||||
|
2. `split("|")` → 각 토큰 `trim()`
|
||||||
|
3. 빈 토큰, `"null"`(대소문자 무시), 더미 `"나라"` 제거
|
||||||
|
4. 남은 토큰을 `" › "`(U+203A)로 조인
|
||||||
|
- **출력 예**:
|
||||||
|
| 입력 | 출력 |
|
||||||
|
|---|---|
|
||||||
|
| `한국\|서울\|강남구` | `한국 › 서울 › 강남구` |
|
||||||
|
| `일본\|null` | `일본` |
|
||||||
|
| `한국\|\|관악구` | `한국 › 관악구` |
|
||||||
|
| `""` / `null` | `""` (호출부에서 미표시) |
|
||||||
|
- **순수 함수**(I/O 없음) → 단위 테스트 용이.
|
||||||
|
|
||||||
|
### 적용
|
||||||
|
- `RestaurantDetail.tsx:132`: `{restaurant.region}` → `{formatRegion(restaurant.region)}`
|
||||||
|
- `RestaurantList.tsx:54`: `{r.region}` → `{formatRegion(r.region)}`
|
||||||
|
- 표시 가드: 원본 `region &&`(truthy) 가 아니라 `formatRegion(region) &&`(변환 결과 truthy) 로 감싼다.
|
||||||
|
원본이 `"|"`/`"한국|"`처럼 전 토큰 더미인 경우 원본은 truthy 여도 변환 결과는 `""` 이므로,
|
||||||
|
가드를 원본 기준으로 두면 빈 배지/빈 줄이 렌더된다(#532 Designer 단계에서 발견·수정).
|
||||||
|
|
||||||
|
## 엣지케이스
|
||||||
|
- 모든 토큰이 더미/빈 값(`"한국|"`, `"|"`) → 유효 토큰만 남김(`한국`) 또는 `""`. `formatRegion(region) &&` 가드로 `""`이면 미표시.
|
||||||
|
- 단일 토큰(`"한국"`) → `한국` 그대로.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
- 단위 테스트(가능 시) 또는 dev 화면에서 상세/리스트 지역 표기가 `›` 계층으로 보이는지.
|
||||||
|
- `npm run build`(또는 tsc/lint) 통과.
|
||||||
44
docs/design/533-min-mentions-filter/README.md
Normal file
44
docs/design/533-min-mentions-filter/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# #533 — 언급 적은(관련도 낮은) 식당 제외 필터
|
||||||
|
|
||||||
|
## 배경 / 문제
|
||||||
|
영상에서 잠깐·우연히 언급된(=관련도 낮은) 식당까지 모두 노출되어 신호 대비 잡음이 큼.
|
||||||
|
사용자가 "언급이 충분한 식당만" 보도록 제외 필터 제공.
|
||||||
|
|
||||||
|
## 신호 정의
|
||||||
|
"의미있는 언급 수(`video_count`)" = 해당 식당의 `video_restaurants` 링크 중
|
||||||
|
**relevance ∈ ('strong','unknown')** 개수. (weak/incidental = 곁다리 언급 → 제외)
|
||||||
|
→ 기존 `findVideoLinks` 기본 노출 정책(strong/unknown)과 일관.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
- **백엔드**: 식당 목록 응답에 `video_count` 포함 + `min_mentions` 서버 필터 파라미터.
|
||||||
|
- **프론트**: `Restaurant.video_count` 타입 + 클라이언트 필터 UI(전체/2회+/3회+).
|
||||||
|
- 서버사이드 필터 일원화(현 클라 필터 패턴 통일)는 **#535**로 분리.
|
||||||
|
|
||||||
|
## 함수/변경 설계
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
1. `domain/Restaurant.java` — `Integer videoCount` 필드 추가(전이 enrichment).
|
||||||
|
2. `mapper/RestaurantMapper.xml`
|
||||||
|
- `restaurantMap` resultMap: `<result property="videoCount" column="video_count"/>`
|
||||||
|
- `findAll` SELECT에 스칼라 서브쿼리 추가:
|
||||||
|
`(SELECT COUNT(*) FROM video_restaurants vrc WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count`
|
||||||
|
- `findAll` `<where>`에 optional 필터:
|
||||||
|
`<if test="minMentions != null and minMentions > 0"> AND (위 서브쿼리) >= #{minMentions} </if>`
|
||||||
|
3. `mapper/RestaurantMapper.java` — `@Param("minMentions") Integer minMentions` 추가.
|
||||||
|
4. `service/RestaurantService.java` — `findAll(..., Integer minMentions, boolean includeHidden)`로 스레드. 5-arg 오버로드는 `null, false` 전달.
|
||||||
|
5. `controller/RestaurantController.java` — `@RequestParam("min_mentions") Integer minMentions` + 캐시 키에 `mm=` 포함.
|
||||||
|
|
||||||
|
### 프론트
|
||||||
|
6. `lib/api.ts` — `Restaurant.video_count?: number | null`; `getRestaurants` 파라미터 `min_mentions?`(미래/서버필터용).
|
||||||
|
7. `app/page.tsx` — 필터 상태 `minMentions`(0/2/3) + `filteredRestaurants`에 `(r.video_count ?? 0) >= minMentions` 가드 + UI 컨트롤 + useMemo deps 추가.
|
||||||
|
|
||||||
|
## 엣지케이스
|
||||||
|
- `video_count` 미산정(findById 등) → null. 클라 필터는 `?? 0`로 안전.
|
||||||
|
- minMentions=0(기본) → 필터 미적용(기존 동작 유지).
|
||||||
|
- DISTINCT + 스칼라 서브쿼리: r.id 단위라 결과 불변.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
- 백엔드 빌드 성공 → PM2 재시작.
|
||||||
|
- `GET /api/restaurants?min_mentions=2` 가 2회 미만 식당 제외하는지.
|
||||||
|
- 응답에 `video_count` 포함 확인.
|
||||||
|
- 프론트 토글로 목록/지도가 줄어드는지.
|
||||||
86
docs/design/545-hide-foreign-reservation-buttons/README.md
Normal file
86
docs/design/545-hide-foreign-reservation-buttons/README.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 설계서: 해외 식당 예약 버튼(테이블링/캐치테이블) 숨김 (#545)
|
||||||
|
|
||||||
|
> **상태**: Approved · QA 통과 · Designer 접근성 다듬기 완료
|
||||||
|
> **작성**: [AI] Architect (소급 작성 — Developer 구현 후 게이트 보완) · **최종수정**: 2026-07-27 ([AI] Designer)
|
||||||
|
> **추적성** — Redmine: #545 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `frontend/src/components/RestaurantDetail.tsx` · 테스트: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스, 육안 QA를 자동화 회귀 테스트로 대체)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
해외 식당 상세에 국내 전용 예약 서비스(테이블링/캐치테이블) 버튼이 노출되는 버그를 막는다.
|
||||||
|
테이블링/캐치테이블은 국내 식당만 지원하므로, 해외 식당에서는 두 버튼을 숨겨야 한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**: `RestaurantDetail.tsx`의 테이블링/캐치테이블 예약 버튼 2종에 국내 판정 게이트 적용.
|
||||||
|
- **제외 (out of scope)**: 백엔드 예약 URL 검색/벌크 수집이 해외 식당을 제외하도록 하는 것 (#545 후속, 별도 이슈로 분리 가능).
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [x] 해외 식당(좌표가 한국 bbox 밖) 상세에서 테이블링/캐치테이블 버튼이 보이지 않는다.
|
||||||
|
- [x] 국내 식당 상세에서는 기존과 동일하게 두 버튼이 보인다 (URL 존재 시).
|
||||||
|
- [x] `tsc` 타입체크 통과.
|
||||||
|
- [x] QA 확인 (해외/국내 식당 각 1건 이상, 대표 발견 케이스 포함) — 자동화 회귀 테스트로 대체 검증 완료(`RestaurantDetail.test.tsx`).
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- 의존성: `Restaurant.latitude/longitude`, `Restaurant.region` (좌표 없는 구 데이터 fallback).
|
||||||
|
- 제약: 기존 지도 링크 분기(151행)에 이미 쓰이던 `isKoreaRestaurant()` 판정 기준과 동일하게 유지해야 함(판정 기준 이원화 방지).
|
||||||
|
- 가정: `tabling_url`/`catchtable_url`이 `"NONE"`이 아니고 값이 있어도, 해외 식당이면 노출하지 않는다.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- 변경 파일: `frontend/src/components/RestaurantDetail.tsx` 1개.
|
||||||
|
- 데이터 흐름: `restaurant` prop → `isKoreaRestaurant(restaurant)` 순수 판정 → JSX 렌더 게이트.
|
||||||
|
- I/O 없음, 순수 함수 기반 조건부 렌더링만 추가.
|
||||||
|
|
||||||
|
```
|
||||||
|
Restaurant (좌표/region)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
isKoreaRestaurant(r) ──false──▶ 테이블링/캐치테이블 버튼 미렌더
|
||||||
|
│
|
||||||
|
true
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
기존 로직: url 존재 && != "NONE" → 버튼 렌더
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- 입력: `Restaurant { latitude?, longitude?, region?, tabling_url?, catchtable_url? }` (기존 타입, 변경 없음).
|
||||||
|
- 출력: 없음 (렌더 분기만).
|
||||||
|
- 경계 검증: `isKoreaRestaurant()` 내부에서 이미 null/undefined 가드 처리(좌표 없으면 region 첫 토큰 fallback).
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------|------|------|-----------|-------|
|
||||||
|
| `isKoreaRestaurant` | 좌표/region 기반 국내 식당 판정 (기존 함수, 변경 없음 — 적용 범위만 확대) | `(r: Restaurant) => boolean` | `Restaurant` | `boolean` | 없음 (순수 함수) | 단순 |
|
||||||
|
|
||||||
|
> 신규 함수 없음. 기존 `isKoreaRestaurant()`(30행, 지도 링크 분기용으로 이미 존재)를 예약 버튼 2곳에도 동일 적용.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
1. `isKoreaRestaurant(restaurant)` 평가 (좌표 우선, KR bbox 33~38.7°N / 124~132°E; 좌표 없으면 `region` 첫 토큰이 `"한국"`인지로 fallback).
|
||||||
|
2. `true`일 때만 기존 조건(`tabling_url`/`catchtable_url` 존재 && `!== "NONE"`)을 이어서 평가.
|
||||||
|
3. 최종 `true`일 때만 버튼 렌더.
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- 좌표와 region 모두 없는 구 데이터 → `isKoreaRestaurant`는 `!r.region` 조건으로 `true` 반환(국내로 간주, 기존 동작 유지 — 회귀 아님).
|
||||||
|
- 좌표가 KR bbox 경계값에 걸치는 경우(제주/울릉도 등) → 기존 지도 링크 판정과 동일 로직이므로 별도 리스크 없음(기존에 검증된 임계값 재사용).
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- `tsc` 통과 (완료).
|
||||||
|
- 자동화 회귀 테스트(`frontend/__tests__/RestaurantDetail.test.tsx`, RTL) — [AI] QA 작성, 5케이스 전부 통과:
|
||||||
|
1. 국내 좌표(서울) + URL 존재 → 두 버튼 노출.
|
||||||
|
2. 해외 좌표(방콕, 대표 발견 케이스) + URL 존재 → 두 버튼 숨김.
|
||||||
|
3. 해외 좌표(도쿄) + URL 없음 → 숨김 유지(회귀 없음).
|
||||||
|
4. 좌표 없음 + region null → fallback으로 국내 간주, 버튼 노출(기존 동작 유지).
|
||||||
|
5. URL이 `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지).
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- 대안: 백엔드에서 해외 식당의 `tabling_url`/`catchtable_url`을 애초에 null로 채우지 않는 방법도 있으나, 프론트 게이트가 더 즉각적이고 기존 판정 로직 재사용 가능 → 채택.
|
||||||
|
- 되돌리기 어려운 결정 없음. ADR 불필요.
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- 백엔드 예약 URL 수집/검색 단계에서 해외 식당을 원천 제외할지 여부는 별도 이슈로 분리할 가치가 있음 (설명에 "후속(옵션)"으로 명시됨).
|
||||||
|
|
||||||
|
## 13. Designer 다듬기 (표현/접점, 기능 변경 없음)
|
||||||
|
- 판정 로직(`isKoreaRestaurant`)과 노출 조건은 QA 통과 상태 그대로 — 변경하지 않음.
|
||||||
|
- 테이블링/캐치테이블 앵커에 `title` 속성 추가(`"… (새 탭)"`) — 새 탭 이동을 사전에 알려 예약 흐름 중 예기치 않은 이탈감 완화.
|
||||||
|
- 로고 대체용 단일 문자 배지(`T`/`C`)에 `aria-hidden="true"` 적용 — 스크린리더가 의미 없는 낱글자를 중복 낭독하지 않고 실제 라벨만 읽도록 정리.
|
||||||
|
- 회귀 확인: `tsc --noEmit` 통과, `frontend/__tests__/RestaurantDetail.test.tsx` 5/5 통과.
|
||||||
766
docs/oke-deploy-howto.md
Normal file
766
docs/oke-deploy-howto.md
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
# OKE(Oracle Kubernetes Engine) 배포 가이드
|
||||||
|
|
||||||
|
> Spring Boot + Next.js 앱을 OKE에 배포하는 전체 과정을 정리한 문서.
|
||||||
|
> Colima(로컬 ARM64 Docker) → OCIR(이미지 레지스트리) → OKE(K8s 클러스터) 파이프라인 기준.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [사전 준비](#1-사전-준비)
|
||||||
|
2. [인프라 아키텍처](#2-인프라-아키텍처)
|
||||||
|
3. [최초 클러스터 설정 (1회성)](#3-최초-클러스터-설정-1회성)
|
||||||
|
4. [K8s 매니페스트 구조](#4-k8s-매니페스트-구조)
|
||||||
|
5. [Dockerfile 작성](#5-dockerfile-작성)
|
||||||
|
6. [배포 스크립트 (deploy.sh)](#6-배포-스크립트-deploysh)
|
||||||
|
7. [일상적인 배포 절차](#7-일상적인-배포-절차)
|
||||||
|
8. [환경별 설정 관리](#8-환경별-설정-관리)
|
||||||
|
9. [도메인 및 SSL 설정](#9-도메인-및-ssl-설정)
|
||||||
|
10. [트러블슈팅](#10-트러블슈팅)
|
||||||
|
11. [유용한 kubectl 명령어](#11-유용한-kubectl-명령어)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 사전 준비
|
||||||
|
|
||||||
|
### 필요한 도구
|
||||||
|
|
||||||
|
| 도구 | 용도 | 설치 |
|
||||||
|
|------|------|------|
|
||||||
|
| **OCI CLI** | OKE 인증, kubeconfig 설정 | `brew install oci-cli` |
|
||||||
|
| **kubectl** | K8s 클러스터 관리 | `brew install kubectl` |
|
||||||
|
| **Colima** | 로컬 ARM64 Docker 빌드 (Docker Desktop 대체) | `brew install colima` |
|
||||||
|
| **Docker CLI** | 이미지 빌드/푸시 | `brew install docker` |
|
||||||
|
| **Helm** | Nginx Ingress, cert-manager 설치 | `brew install helm` |
|
||||||
|
|
||||||
|
### OCI 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OCI CLI 프로파일 설정 (~/.oci/config)
|
||||||
|
[DEFAULT]
|
||||||
|
user=ocid1.user.oc1..xxxx
|
||||||
|
fingerprint=xx:xx:xx:...
|
||||||
|
tenancy=ocid1.tenancy.oc1..xxxx
|
||||||
|
region=ap-seoul-1
|
||||||
|
key_file=~/.oci/oci_api_key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### kubeconfig 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OKE 클러스터 접근 설정
|
||||||
|
oci ce cluster create-kubeconfig \
|
||||||
|
--cluster-id ocid1.cluster.oc1.<region>.<cluster-id> \
|
||||||
|
--file $HOME/.kube/config \
|
||||||
|
--region <region> \
|
||||||
|
--token-version 2.0.0 \
|
||||||
|
--kube-endpoint PUBLIC_ENDPOINT
|
||||||
|
|
||||||
|
# 연결 확인
|
||||||
|
kubectl get nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
### OCIR(Oracle Container Image Registry) 로그인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OCIR 로그인
|
||||||
|
# 사용자명 형식: <namespace>/oracleidentitycloudservice/<email>
|
||||||
|
# 비밀번호: OCI Console → User Settings → Auth Tokens에서 발급
|
||||||
|
|
||||||
|
docker login <region-code>.ocir.io
|
||||||
|
# 예) docker login icn.ocir.io
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colima 시작 (ARM64 빌드용)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OKE 노드가 ARM64인 경우 반드시 ARM64로 빌드해야 함
|
||||||
|
colima start --arch aarch64 --cpu 4 --memory 4
|
||||||
|
|
||||||
|
# ⚠️ Colima 시작 시 kubectl 컨텍스트가 'colima'로 바뀜
|
||||||
|
# OKE 컨텍스트로 복원 필수
|
||||||
|
kubectl config use-context <oke-context-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 인프라 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Internet (사용자) │
|
||||||
|
│ https://www.example.com │
|
||||||
|
└────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────▼────────┐
|
||||||
|
│ DNS Provider │ ← A 레코드 → NLB Public IP
|
||||||
|
└───────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼──────────────┐
|
||||||
|
│ OCI Network Load Balancer │ ← Nginx Ingress가 자동 생성
|
||||||
|
└────────────┬──────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼──────────────────────────────────┐
|
||||||
|
│ OKE Cluster │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────┐ │
|
||||||
|
│ │ Nginx Ingress Controller │ │
|
||||||
|
│ │ + cert-manager (Let's Encrypt)│ │
|
||||||
|
│ └───┬─────────────┬───────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ /api/* /* │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌───▼──────┐ ┌──▼───────┐ ┌──────────┐ │
|
||||||
|
│ │ Backend │ │ Frontend │ │ Redis │ │
|
||||||
|
│ │ :8000 │ │ :3001 │ │ :6379 │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼──────────────────────┐
|
||||||
|
│ External Services │
|
||||||
|
│ ├─ Oracle ADB (mTLS + Wallet) │
|
||||||
|
│ ├─ OCI GenAI │
|
||||||
|
│ └─ 기타 외부 API │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 리소스 할당 예시 (ARM64 × 2노드, 2CPU/8GB 각)
|
||||||
|
|
||||||
|
| 컴포넌트 | CPU request/limit | Memory request/limit |
|
||||||
|
|----------|-------------------|----------------------|
|
||||||
|
| Backend | 500m / 1 | 768Mi / 1536Mi |
|
||||||
|
| Frontend | 200m / 500m | 256Mi / 512Mi |
|
||||||
|
| Redis | 100m / 200m | 128Mi / 256Mi |
|
||||||
|
| **합계** | **~800m** | **~1.2GB** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 최초 클러스터 설정 (1회성)
|
||||||
|
|
||||||
|
### 3.1 Nginx Ingress Controller 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
helm install ingress-nginx ingress-nginx/ingress-nginx \
|
||||||
|
--namespace ingress-nginx \
|
||||||
|
--create-namespace \
|
||||||
|
--set controller.service.type=LoadBalancer \
|
||||||
|
--set controller.service.annotations."oci\.oraclecloud\.com/load-balancer-type"="nlb" \
|
||||||
|
--set controller.service.externalTrafficPolicy=Local
|
||||||
|
|
||||||
|
# NLB 외부 IP 확인 (DNS에 연결할 IP)
|
||||||
|
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||||
|
```
|
||||||
|
|
||||||
|
> **OKE 네트워크 설정**: VCN Security List에서 NodePort 범위(30000~32767)를 NLB IP 대역에서 허용해야 함.
|
||||||
|
|
||||||
|
### 3.2 cert-manager 설치 (Let's Encrypt 자동 인증서)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add jetstack https://charts.jetstack.io
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
helm install cert-manager jetstack/cert-manager \
|
||||||
|
--namespace cert-manager \
|
||||||
|
--create-namespace \
|
||||||
|
--set crds.enabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 ClusterIssuer 생성
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/cert-manager/cluster-issuer.yaml
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: ClusterIssuer
|
||||||
|
metadata:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
spec:
|
||||||
|
acme:
|
||||||
|
server: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
email: your-email@example.com
|
||||||
|
privateKeySecretRef:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
solvers:
|
||||||
|
- http01:
|
||||||
|
ingress:
|
||||||
|
class: nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/cert-manager/cluster-issuer.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 앱 네임스페이스 및 시크릿 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 네임스페이스
|
||||||
|
kubectl apply -f k8s/namespace.yaml
|
||||||
|
|
||||||
|
# OCIR pull secret (이미지 다운로드용)
|
||||||
|
kubectl create secret docker-registry ocir-secret \
|
||||||
|
--namespace <app-namespace> \
|
||||||
|
--docker-server=<region>.ocir.io \
|
||||||
|
--docker-username='<namespace>/oracleidentitycloudservice/<email>' \
|
||||||
|
--docker-password='<auth-token>'
|
||||||
|
|
||||||
|
# Oracle Wallet (DB mTLS 인증)
|
||||||
|
kubectl create secret generic oracle-wallet \
|
||||||
|
--namespace <app-namespace> \
|
||||||
|
--from-file=cwallet.sso \
|
||||||
|
--from-file=ewallet.p12 \
|
||||||
|
--from-file=tnsnames.ora \
|
||||||
|
--from-file=sqlnet.ora \
|
||||||
|
--from-file=ojdbc.properties \
|
||||||
|
--from-file=keystore.jks \
|
||||||
|
--from-file=truststore.jks
|
||||||
|
|
||||||
|
# OCI 설정 (GenAI 등 OCI SDK 인증)
|
||||||
|
kubectl create secret generic oci-config \
|
||||||
|
--namespace <app-namespace> \
|
||||||
|
--from-file=config=~/.oci/config \
|
||||||
|
--from-file=oci_api_key.pem=~/.oci/oci_api_key.pem
|
||||||
|
|
||||||
|
# 앱 시크릿/설정
|
||||||
|
kubectl apply -f k8s/secrets.yaml
|
||||||
|
kubectl apply -f k8s/configmap.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 앱 배포 (최초)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/redis-deployment.yaml
|
||||||
|
kubectl apply -f k8s/backend-deployment.yaml
|
||||||
|
kubectl apply -f k8s/frontend-deployment.yaml
|
||||||
|
kubectl apply -f k8s/ingress.yaml
|
||||||
|
|
||||||
|
# 롤아웃 확인
|
||||||
|
kubectl rollout status deployment/backend -n <app-namespace>
|
||||||
|
kubectl rollout status deployment/frontend -n <app-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. K8s 매니페스트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
k8s/
|
||||||
|
├── namespace.yaml # 네임스페이스 정의
|
||||||
|
├── configmap.yaml # 비민감 설정 (Redis 호스트, API 엔드포인트 등)
|
||||||
|
├── secrets.yaml # 민감 정보 (DB 비밀번호, API 키 등) ← .gitignore
|
||||||
|
├── secrets.yaml.template # 시크릿 템플릿 (Git에 포함)
|
||||||
|
├── backend-deployment.yaml # Backend Deployment + Service
|
||||||
|
├── frontend-deployment.yaml # Frontend Deployment + Service
|
||||||
|
├── redis-deployment.yaml # Redis Deployment + Service
|
||||||
|
├── ingress.yaml # Ingress (라우팅 + TLS)
|
||||||
|
└── cert-manager/
|
||||||
|
└── cluster-issuer.yaml # Let's Encrypt ClusterIssuer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Deployment 예시
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: <app-namespace>
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: backend
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: ocir-secret
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
image: <region>.ocir.io/<namespace>/<repo>/backend:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: <app>-config
|
||||||
|
- secretRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
volumeMounts:
|
||||||
|
- name: oracle-wallet
|
||||||
|
mountPath: /etc/oracle/wallet
|
||||||
|
readOnly: true
|
||||||
|
- name: oci-config
|
||||||
|
mountPath: /root/.oci
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "768Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: "1536Mi"
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
volumes:
|
||||||
|
- name: oracle-wallet
|
||||||
|
secret:
|
||||||
|
secretName: oracle-wallet
|
||||||
|
- name: oci-config
|
||||||
|
secret:
|
||||||
|
secretName: oci-config
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: <app-namespace>
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: backend
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress 예시
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: <app>-ingress
|
||||||
|
namespace: <app-namespace>
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- www.example.com
|
||||||
|
secretName: <app>-tls
|
||||||
|
rules:
|
||||||
|
- host: www.example.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: backend
|
||||||
|
port:
|
||||||
|
number: 8000
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: frontend
|
||||||
|
port:
|
||||||
|
number: 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dockerfile 작성
|
||||||
|
|
||||||
|
### Backend (Spring Boot 멀티스테이지)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ── Build Stage ──
|
||||||
|
FROM eclipse-temurin:21-jdk AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 캐싱 (소스 변경 시에도 재사용)
|
||||||
|
COPY gradlew settings.gradle build.gradle ./
|
||||||
|
COPY gradle/ gradle/
|
||||||
|
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon || true
|
||||||
|
|
||||||
|
# 소스 복사 & 빌드
|
||||||
|
COPY src/ src/
|
||||||
|
RUN ./gradlew bootJar -x test --no-daemon
|
||||||
|
|
||||||
|
# ── Runtime Stage ──
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/build/libs/*.jar app.jar
|
||||||
|
EXPOSE 8000
|
||||||
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
|
||||||
|
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**포인트:**
|
||||||
|
- Gradle 의존성을 먼저 복사/다운로드 → Docker 레이어 캐시 활용
|
||||||
|
- `-XX:MaxRAMPercentage=75.0` → 컨테이너 메모리 제한의 75%를 JVM 힙으로 사용
|
||||||
|
- 최종 이미지에 JDK 대신 JRE만 포함 → 이미지 크기 절감
|
||||||
|
|
||||||
|
### Frontend (Next.js standalone 멀티스테이지)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ── Build Stage ──
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 빌드 시점 환경변수 주입 (NEXT_PUBLIC_* 변수)
|
||||||
|
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||||
|
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runtime Stage ──
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# standalone 출력 + 정적 파일 + public 복사
|
||||||
|
COPY --from=build /app/.next/standalone ./
|
||||||
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
ENV PORT=3001 HOSTNAME=0.0.0.0
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**포인트:**
|
||||||
|
- `next.config.ts`에 `output: "standalone"` 필수
|
||||||
|
- `NEXT_PUBLIC_*` 환경변수는 **빌드 시점**에만 주입 가능 (런타임 X)
|
||||||
|
- `.next/static`과 `public/`은 standalone에 포함되지 않으므로 수동 복사
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 배포 스크립트 (deploy.sh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Configuration ──
|
||||||
|
REGISTRY="<region>.ocir.io/<namespace>/<repo>"
|
||||||
|
APP_NAMESPACE="<app-namespace>"
|
||||||
|
PLATFORM="linux/arm64" # OKE 노드 아키텍처에 맞춤
|
||||||
|
|
||||||
|
# ── Parse arguments ──
|
||||||
|
TARGET="all" # all | backend | frontend
|
||||||
|
MESSAGE=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--backend-only) TARGET="backend"; shift ;;
|
||||||
|
--frontend-only) TARGET="frontend"; shift ;;
|
||||||
|
--dry-run) echo "[DRY RUN]"; exit 0 ;;
|
||||||
|
-m) MESSAGE="$2"; shift 2 ;;
|
||||||
|
*) MESSAGE="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Auto-increment version ──
|
||||||
|
LATEST_TAG=$(git tag --sort=-v:refname | grep '^v' | head -1 2>/dev/null || echo "v0.1.0")
|
||||||
|
MAJOR=$(echo "$LATEST_TAG" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "$LATEST_TAG" | cut -d. -f2)
|
||||||
|
PATCH=$(echo "$LATEST_TAG" | cut -d. -f3)
|
||||||
|
TAG="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
|
||||||
|
echo "━━━ Deploying ${TAG} (${TARGET}) ━━━"
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# ── Build & Push ──
|
||||||
|
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
|
||||||
|
echo "▶ Building backend..."
|
||||||
|
docker build --platform "$PLATFORM" \
|
||||||
|
-t "$REGISTRY/backend:$TAG" \
|
||||||
|
-t "$REGISTRY/backend:latest" \
|
||||||
|
backend-java/
|
||||||
|
docker push "$REGISTRY/backend:$TAG"
|
||||||
|
docker push "$REGISTRY/backend:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||||
|
echo "▶ Building frontend..."
|
||||||
|
|
||||||
|
# .env.local에서 빌드 인자 읽기
|
||||||
|
MAPS_KEY=$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)
|
||||||
|
CLIENT_ID=$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)
|
||||||
|
|
||||||
|
docker build --platform "$PLATFORM" \
|
||||||
|
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
||||||
|
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
||||||
|
-t "$REGISTRY/frontend:$TAG" \
|
||||||
|
-t "$REGISTRY/frontend:latest" \
|
||||||
|
frontend/
|
||||||
|
docker push "$REGISTRY/frontend:$TAG"
|
||||||
|
docker push "$REGISTRY/frontend:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Rolling Update ──
|
||||||
|
# ⚠️ kubectl 컨텍스트가 OKE를 가리키는지 반드시 확인
|
||||||
|
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
|
||||||
|
kubectl set image deployment/backend \
|
||||||
|
backend="$REGISTRY/backend:$TAG" -n "$APP_NAMESPACE"
|
||||||
|
kubectl rollout status deployment/backend -n "$APP_NAMESPACE" --timeout=180s
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||||
|
kubectl set image deployment/frontend \
|
||||||
|
frontend="$REGISTRY/frontend:$TAG" -n "$APP_NAMESPACE"
|
||||||
|
kubectl rollout status deployment/frontend -n "$APP_NAMESPACE" --timeout=120s
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Git Tag ──
|
||||||
|
git tag -a "$TAG" -m "Deploy ${TAG}: ${MESSAGE}"
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
echo "━━━ ✅ Deploy complete: ${TAG} ━━━"
|
||||||
|
kubectl get pods -n "$APP_NAMESPACE"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 일상적인 배포 절차
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Colima 시작 (ARM64 Docker 빌드용, 꺼져있을 때만)
|
||||||
|
colima start --arch aarch64 --cpu 4 --memory 4
|
||||||
|
|
||||||
|
# 2. kubectl 컨텍스트를 OKE로 복원 (Colima가 바꿔버림)
|
||||||
|
kubectl config use-context <oke-context-name>
|
||||||
|
|
||||||
|
# 3. 배포 실행
|
||||||
|
./deploy.sh "변경 내용 설명" # 전체 배포
|
||||||
|
./deploy.sh --backend-only "API 수정" # 백엔드만
|
||||||
|
./deploy.sh --frontend-only "UI 수정" # 프론트엔드만
|
||||||
|
|
||||||
|
# 4. 확인
|
||||||
|
kubectl get pods -n <app-namespace>
|
||||||
|
kubectl logs -f deployment/backend -n <app-namespace>
|
||||||
|
|
||||||
|
# 5. (선택) Colima 중지 (리소스 절약)
|
||||||
|
colima stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 시크릿/설정만 변경할 때
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# secrets.yaml 수정 후
|
||||||
|
kubectl apply -f k8s/secrets.yaml
|
||||||
|
|
||||||
|
# 시크릿 변경은 Pod를 자동 재시작하지 않음 → 수동 재시작 필요
|
||||||
|
kubectl rollout restart deployment/backend -n <app-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 이전 버전으로 롤백
|
||||||
|
kubectl rollout undo deployment/backend -n <app-namespace>
|
||||||
|
kubectl rollout undo deployment/frontend -n <app-namespace>
|
||||||
|
|
||||||
|
# 특정 리비전으로 롤백
|
||||||
|
kubectl rollout history deployment/backend -n <app-namespace>
|
||||||
|
kubectl rollout undo deployment/backend -n <app-namespace> --to-revision=3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 환경별 설정 관리
|
||||||
|
|
||||||
|
### 개발(Local) vs 운영(OKE) 비교
|
||||||
|
|
||||||
|
| 항목 | 개발 (Local) | 운영 (OKE) |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| DB 설정 | `backend/.env` | K8s Secret (`secrets.yaml`) |
|
||||||
|
| 환경변수 | 파일 기반 (`.env`, `.env.local`) | ConfigMap + Secret |
|
||||||
|
| Oracle Wallet | 로컬 디렉토리 | K8s Secret → Volume 마운트 |
|
||||||
|
| OCI 인증 | `~/.oci/config` | K8s Secret → Volume 마운트 |
|
||||||
|
| Redis | 로컬 IP (`192.168.x.x`) | K8s Service DNS (`redis`) |
|
||||||
|
| 프로세스 관리 | PM2 | K8s Deployment |
|
||||||
|
| 프론트엔드 모드 | `npm run dev` | `node server.js` (standalone) |
|
||||||
|
| DB 프로파일 | `_low` (리소스 절약) | `_medium` (적정 성능) |
|
||||||
|
|
||||||
|
### Oracle ADB 프로파일 선택
|
||||||
|
|
||||||
|
| 프로파일 | 병렬 처리 | 용도 |
|
||||||
|
|---------|----------|------|
|
||||||
|
| `_high` | 최대 | 대규모 배치/분석 |
|
||||||
|
| `_medium` | 중간 | **운영 권장** |
|
||||||
|
| `_low` | 최소 | **개발/테스트** (OCPU 절약) |
|
||||||
|
| `_tp` | 트랜잭션 | OLTP 워크로드 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 도메인 및 SSL 설정
|
||||||
|
|
||||||
|
### DNS 설정
|
||||||
|
|
||||||
|
```
|
||||||
|
# DNS Provider (예: Namecheap)에서 설정
|
||||||
|
Type Host Value TTL
|
||||||
|
A @ <NLB Public IP> 300
|
||||||
|
A www <NLB Public IP> 300
|
||||||
|
```
|
||||||
|
|
||||||
|
NLB Public IP 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get svc -n ingress-nginx ingress-nginx-controller \
|
||||||
|
-o jsonpath='{.status.loadBalancer.ingress[0].ip}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL 인증서
|
||||||
|
|
||||||
|
cert-manager + ClusterIssuer가 설정되어 있으면 Ingress에 annotation만 추가하면 자동 발급/갱신됨.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Ingress에 추가
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- www.example.com
|
||||||
|
secretName: <app>-tls # cert-manager가 자동 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
인증서 상태 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get certificate -n <app-namespace>
|
||||||
|
kubectl describe certificate <app>-tls -n <app-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 트러블슈팅
|
||||||
|
|
||||||
|
### 이미지 Pull 실패 (ImagePullBackOff)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl describe pod <pod-name> -n <app-namespace>
|
||||||
|
# Events 섹션에서 에러 확인
|
||||||
|
|
||||||
|
# 원인 1: OCIR 인증 실패 → pull secret 재생성
|
||||||
|
kubectl delete secret ocir-secret -n <app-namespace>
|
||||||
|
kubectl create secret docker-registry ocir-secret \
|
||||||
|
--namespace <app-namespace> \
|
||||||
|
--docker-server=<region>.ocir.io \
|
||||||
|
--docker-username='<namespace>/oracleidentitycloudservice/<email>' \
|
||||||
|
--docker-password='<auth-token>'
|
||||||
|
|
||||||
|
# 원인 2: 이미지가 존재하지 않음 → OCIR 콘솔에서 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
### OKE CRI-O: short image name 오류
|
||||||
|
|
||||||
|
```
|
||||||
|
# ❌ 틀림
|
||||||
|
image: redis:7-alpine
|
||||||
|
|
||||||
|
# ✅ 맞음 (docker.io/library/ 접두사 필수)
|
||||||
|
image: docker.io/library/redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
OKE는 CRI-O 런타임을 사용하며, Docker Hub의 `library/` 이미지도 전체 경로를 명시해야 함.
|
||||||
|
|
||||||
|
### DB 연결 실패 (HikariPool timeout)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pod 로그 확인
|
||||||
|
kubectl logs deployment/backend -n <app-namespace> | grep -i "hikari\|oracle\|connection"
|
||||||
|
|
||||||
|
# 원인 1: Wallet이 마운트되지 않음
|
||||||
|
kubectl exec deployment/backend -n <app-namespace> -- ls /etc/oracle/wallet/
|
||||||
|
|
||||||
|
# 원인 2: ORACLE_DSN에 TNS_ADMIN 경로가 잘못됨
|
||||||
|
# 형식: <tns-alias>_medium?TNS_ADMIN=/etc/oracle/wallet
|
||||||
|
```
|
||||||
|
|
||||||
|
### Let's Encrypt 인증서 발급 실패
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Challenge 상태 확인
|
||||||
|
kubectl get challenges -n <app-namespace>
|
||||||
|
kubectl describe challenge <name> -n <app-namespace>
|
||||||
|
|
||||||
|
# 원인: VCN Security List에서 80번 포트가 막혀있음
|
||||||
|
# OCI Console → VCN → Security Lists → Ingress Rules에 HTTP 80 허용 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
### kubectl 인증 실패
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# kubeconfig 재생성
|
||||||
|
oci ce cluster create-kubeconfig \
|
||||||
|
--cluster-id <cluster-ocid> \
|
||||||
|
--file $HOME/.kube/config \
|
||||||
|
--region <region> \
|
||||||
|
--token-version 2.0.0 \
|
||||||
|
--kube-endpoint PUBLIC_ENDPOINT
|
||||||
|
|
||||||
|
# OCI CLI 프로파일 지정 (여러 프로파일 사용 시)
|
||||||
|
# kubeconfig의 args에 --profile <PROFILE_NAME> 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colima 시작 후 kubectl 안 됨
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Colima가 kubectl 컨텍스트를 가져감
|
||||||
|
kubectl config get-contexts
|
||||||
|
kubectl config use-context <oke-context-name> # OKE로 복원
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 유용한 kubectl 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── 상태 확인 ──
|
||||||
|
kubectl get pods -n <ns> # Pod 목록
|
||||||
|
kubectl get pods -n <ns> -w # 실시간 감시
|
||||||
|
kubectl get svc -n <ns> # Service 목록
|
||||||
|
kubectl get ingress -n <ns> # Ingress 확인
|
||||||
|
kubectl top pods -n <ns> # 리소스 사용량
|
||||||
|
|
||||||
|
# ── 로그 ──
|
||||||
|
kubectl logs deployment/backend -n <ns> # 최근 로그
|
||||||
|
kubectl logs deployment/backend -n <ns> -f # 실시간 로그
|
||||||
|
kubectl logs deployment/backend -n <ns> --previous # 이전 Pod 로그 (크래시 시)
|
||||||
|
|
||||||
|
# ── 디버깅 ──
|
||||||
|
kubectl describe pod <pod> -n <ns> # Pod 상세 (이벤트 포함)
|
||||||
|
kubectl exec -it deployment/backend -n <ns> -- sh # Pod 접속
|
||||||
|
kubectl port-forward svc/backend 8000:8000 -n <ns> # 로컬 포트 포워딩
|
||||||
|
|
||||||
|
# ── 배포 관리 ──
|
||||||
|
kubectl rollout status deployment/backend -n <ns> # 롤아웃 상태
|
||||||
|
kubectl rollout restart deployment/backend -n <ns> # 재시작 (설정 변경 반영)
|
||||||
|
kubectl rollout undo deployment/backend -n <ns> # 이전 버전 롤백
|
||||||
|
kubectl rollout history deployment/backend -n <ns> # 배포 이력
|
||||||
|
|
||||||
|
# ── 설정 변경 ──
|
||||||
|
kubectl apply -f k8s/configmap.yaml # ConfigMap 업데이트
|
||||||
|
kubectl apply -f k8s/secrets.yaml # Secret 업데이트
|
||||||
|
kubectl edit deployment backend -n <ns> # 직접 수정 (비추천)
|
||||||
|
|
||||||
|
# ── 정리 ──
|
||||||
|
kubectl delete pod <pod> -n <ns> # Pod 강제 재시작
|
||||||
|
kubectl scale deployment/backend --replicas=0 -n <ns> # 일시 중지
|
||||||
|
kubectl scale deployment/backend --replicas=1 -n <ns> # 복원
|
||||||
|
```
|
||||||
@@ -26,6 +26,7 @@ module.exports = {
|
|||||||
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
|
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
|
||||||
script: "npm",
|
script: "npm",
|
||||||
args: "run dev",
|
args: "run dev",
|
||||||
|
env: { PORT: 3001 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
117
frontend/__tests__/RestaurantDetail.test.tsx
Normal file
117
frontend/__tests__/RestaurantDetail.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* #545 — 해외 식당 상세에서 테이블링/캐치테이블 예약 버튼 숨김 회귀 테스트.
|
||||||
|
* 좌표 기반(isKoreaRestaurant) 판정으로 국내/해외 식당에서 버튼 노출 여부가
|
||||||
|
* 올바르게 갈리는지 검증한다.
|
||||||
|
*/
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import RestaurantDetail from "@/components/RestaurantDetail";
|
||||||
|
import type { Restaurant } from "@/lib/api";
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 발생하는 비동기 데이터 로딩(영상/리뷰 등)을 플러시해
|
||||||
|
// act() 경고 없이 안정적으로 렌더 결과를 검증한다.
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock("@/lib/api", () => ({
|
||||||
|
getToken: jest.fn(() => null),
|
||||||
|
api: {
|
||||||
|
getRestaurantVideos: jest.fn(() => Promise.resolve([])),
|
||||||
|
getFavoriteStatus: jest.fn(() => Promise.resolve({ favorited: false })),
|
||||||
|
getReviews: jest.fn(() =>
|
||||||
|
Promise.resolve({ reviews: [], avg_rating: null, review_count: 0 })
|
||||||
|
),
|
||||||
|
getMemo: jest.fn(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeRestaurant(overrides: Partial<Restaurant>): Restaurant {
|
||||||
|
return {
|
||||||
|
id: "r1",
|
||||||
|
name: "테스트 식당",
|
||||||
|
address: null,
|
||||||
|
region: null,
|
||||||
|
latitude: 37.5,
|
||||||
|
longitude: 127.0,
|
||||||
|
cuisine_type: null,
|
||||||
|
price_range: null,
|
||||||
|
google_place_id: null,
|
||||||
|
tabling_url: null,
|
||||||
|
catchtable_url: null,
|
||||||
|
business_status: null,
|
||||||
|
rating: null,
|
||||||
|
rating_count: null,
|
||||||
|
phone: null,
|
||||||
|
website: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RestaurantDetail — #545 해외 식당 예약 버튼 숨김", () => {
|
||||||
|
it("국내 식당(좌표 기반)이면 테이블링/캐치테이블 버튼이 보인다", async () => {
|
||||||
|
const restaurant = makeRestaurant({
|
||||||
|
latitude: 37.5665,
|
||||||
|
longitude: 126.978, // 서울
|
||||||
|
tabling_url: "https://tabling.example.com/1",
|
||||||
|
catchtable_url: "https://catchtable.example.com/1",
|
||||||
|
});
|
||||||
|
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("테이블링에서 줄서기")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("캐치테이블에서 예약하기")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("해외 식당(좌표가 KR bbox 밖)이면 두 버튼 모두 숨겨진다 (대표 발견 케이스)", async () => {
|
||||||
|
const restaurant = makeRestaurant({
|
||||||
|
latitude: 13.7563,
|
||||||
|
longitude: 100.5018, // 방콕
|
||||||
|
tabling_url: "https://tabling.example.com/1",
|
||||||
|
catchtable_url: "https://catchtable.example.com/1",
|
||||||
|
});
|
||||||
|
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||||
|
await flush();
|
||||||
|
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("해외 식당이어도 예약 URL이 없으면 애초에 버튼이 없다 (회귀 없음 확인)", async () => {
|
||||||
|
const restaurant = makeRestaurant({
|
||||||
|
latitude: 35.6762,
|
||||||
|
longitude: 139.6503, // 도쿄
|
||||||
|
tabling_url: null,
|
||||||
|
catchtable_url: null,
|
||||||
|
});
|
||||||
|
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||||
|
await flush();
|
||||||
|
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("좌표 없는 구 데이터는 region으로 fallback되어 국내로 간주된다 (기존 동작 유지)", async () => {
|
||||||
|
const restaurant = makeRestaurant({
|
||||||
|
latitude: undefined as unknown as number,
|
||||||
|
longitude: undefined as unknown as number,
|
||||||
|
region: null,
|
||||||
|
tabling_url: "https://tabling.example.com/1",
|
||||||
|
catchtable_url: null,
|
||||||
|
});
|
||||||
|
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("테이블링에서 줄서기")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("예약 URL이 'NONE' 문자열이면 국내 식당이어도 버튼이 없다 (기존 게이트 유지)", async () => {
|
||||||
|
const restaurant = makeRestaurant({
|
||||||
|
latitude: 37.5665,
|
||||||
|
longitude: 126.978,
|
||||||
|
tabling_url: "NONE",
|
||||||
|
catchtable_url: "NONE",
|
||||||
|
});
|
||||||
|
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||||
|
await flush();
|
||||||
|
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
36
frontend/__tests__/filters.test.ts
Normal file
36
frontend/__tests__/filters.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* #533 — passesMinMentions() 순수 함수 단위 테스트.
|
||||||
|
* 설계서: docs/design/533-min-mentions-filter/README.md
|
||||||
|
*/
|
||||||
|
import { passesMinMentions } from "@/lib/filters";
|
||||||
|
|
||||||
|
describe("passesMinMentions", () => {
|
||||||
|
it("minMentions=0(기본)이면 필터 미적용 — 항상 true", () => {
|
||||||
|
expect(passesMinMentions(0, 0)).toBe(true);
|
||||||
|
expect(passesMinMentions(null, 0)).toBe(true);
|
||||||
|
expect(passesMinMentions(100, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("음수 minMentions도 필터 미적용으로 취급한다", () => {
|
||||||
|
expect(passesMinMentions(0, -1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("video_count 가 minMentions 미만이면 제외(false)", () => {
|
||||||
|
expect(passesMinMentions(1, 2)).toBe(false);
|
||||||
|
expect(passesMinMentions(0, 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("video_count 가 minMentions 와 같으면 포함(경계값 포함)", () => {
|
||||||
|
expect(passesMinMentions(2, 2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("video_count 가 minMentions 초과면 포함", () => {
|
||||||
|
expect(passesMinMentions(5, 2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("video_count 가 null/undefined(미산정)이면 0으로 간주한다", () => {
|
||||||
|
expect(passesMinMentions(null, 2)).toBe(false);
|
||||||
|
expect(passesMinMentions(undefined, 2)).toBe(false);
|
||||||
|
expect(passesMinMentions(null, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/__tests__/region.test.ts
Normal file
44
frontend/__tests__/region.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* #532 — formatRegion() 순수 함수 단위 테스트.
|
||||||
|
* 설계서: docs/design/532-region-display/README.md
|
||||||
|
*/
|
||||||
|
import { formatRegion } from "@/lib/region";
|
||||||
|
|
||||||
|
describe("formatRegion", () => {
|
||||||
|
it("falsy 입력은 빈 문자열", () => {
|
||||||
|
expect(formatRegion("")).toBe("");
|
||||||
|
expect(formatRegion(null)).toBe("");
|
||||||
|
expect(formatRegion(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("파이프 구분 지역을 계층(breadcrumb)으로 변환한다", () => {
|
||||||
|
expect(formatRegion("한국|서울|강남구")).toBe("한국 › 서울 › 강남구");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'null' 토큰(대소문자 무시)은 제거한다", () => {
|
||||||
|
expect(formatRegion("일본|null")).toBe("일본");
|
||||||
|
expect(formatRegion("일본|NULL")).toBe("일본");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("빈 토큰은 제거한다", () => {
|
||||||
|
expect(formatRegion("한국||관악구")).toBe("한국 › 관악구");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("더미 토큰 '나라'는 제거한다", () => {
|
||||||
|
expect(formatRegion("나라|서울")).toBe("서울");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("단일 토큰은 그대로 반환한다", () => {
|
||||||
|
expect(formatRegion("한국")).toBe("한국");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("모든 토큰이 더미/빈 값이면 빈 문자열", () => {
|
||||||
|
expect(formatRegion("한국|")).toBe("한국");
|
||||||
|
expect(formatRegion("|")).toBe("");
|
||||||
|
expect(formatRegion("나라|null|")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("토큰 앞뒤 공백은 trim 된다", () => {
|
||||||
|
expect(formatRegion(" 한국 | 서울 ")).toBe("한국 › 서울");
|
||||||
|
});
|
||||||
|
});
|
||||||
341
frontend/docs/brand-guide.md
Normal file
341
frontend/docs/brand-guide.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Tasteby Brand & Design Guide
|
||||||
|
|
||||||
|
> Tasteby의 브랜드 아이덴티티, 디자인 시스템, 아이콘 정책을 정의하는 가이드 문서.
|
||||||
|
> 최종 수정: 2026-03-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 브랜드 개요
|
||||||
|
|
||||||
|
### 1.1 서비스 정의
|
||||||
|
**Tasteby**는 유튜브 맛집 영상에서 레스토랑 정보를 추출하여 지도 위에 큐레이션하는 서비스.
|
||||||
|
"유튜버의 입맛으로(Taste by)" 찾는 맛집이라는 의미를 담고 있다.
|
||||||
|
|
||||||
|
### 1.2 브랜드 키워드
|
||||||
|
- **미식 큐레이션** — 단순 검색이 아닌, 유튜버가 직접 방문한 맛집을 큐레이팅
|
||||||
|
- **신뢰** — 실제 영상 기반, 출처가 명확한 정보
|
||||||
|
- **따뜻함** — 음식과 사람을 연결하는 따뜻한 경험
|
||||||
|
- **프리미엄 캐주얼** — 고급스럽지만 접근하기 쉬운 톤
|
||||||
|
|
||||||
|
### 1.3 브랜드 보이스
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| 친근하고 자연스러운 한국어 | 과도한 존댓말·마케팅 투 |
|
||||||
|
| 간결하고 핵심적인 정보 전달 | 불필요한 수식어 남발 |
|
||||||
|
| 음식에 대한 애정이 묻어나는 톤 | 딱딱한 기술 용어 노출 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 로고
|
||||||
|
|
||||||
|
### 2.1 로고 구성
|
||||||
|
Tasteby 로고는 **워드마크(Wordmark)** 형태로, 글자 "e" 안에 **위치 핀 마커**와 **스마일 곡선**이 결합되어 있다.
|
||||||
|
|
||||||
|
- 핀 마커 = 지도 기반 서비스
|
||||||
|
- 스마일 = 맛있는 음식의 만족감
|
||||||
|
- 색상: 본문 다크 그레이(`#3C3C3C`) + 핀/스마일 레드(`#E8534A`)
|
||||||
|
|
||||||
|
### 2.2 로고 파일
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `logo.png` | 원본 (고해상도) |
|
||||||
|
| `logo-200h.png` | 히어로/소개 영역 |
|
||||||
|
| `logo-120h.png` | 헤더/네비게이션 |
|
||||||
|
| `logo-80h.png` | 파비콘/소형 UI |
|
||||||
|
| `logo-dark.png` | 다크 배경용 (현재 미사용) |
|
||||||
|
| `logo-dark-120h.png` | 다크 배경 헤더용 (현재 미사용) |
|
||||||
|
| `logo-dark-80h.png` | 다크 배경 소형 (현재 미사용) |
|
||||||
|
|
||||||
|
### 2.3 로고 사용 규칙
|
||||||
|
- 최소 여백: 로고 높이의 50% 이상
|
||||||
|
- 배경: 크림 화이트(`#FFFAF5`) 또는 순백(`#FFFFFF`) 위에 사용
|
||||||
|
- 변형 금지: 회전, 기울임, 색상 임의 변경 불가
|
||||||
|
- 현재 라이트 모드 전용 — 다크 모드 미지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 컬러 시스템
|
||||||
|
|
||||||
|
### 3.1 디자인 테마: Saffron (사프란)
|
||||||
|
따뜻한 금빛 오렌지 톤. 고급스러운 미식 큐레이션 느낌을 전달한다.
|
||||||
|
|
||||||
|
### 3.2 브랜드 팔레트
|
||||||
|
CSS 변수로 `globals.css`의 `@theme inline`에 등록되어 있다.
|
||||||
|
|
||||||
|
| 토큰 | Hex | 용도 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `brand-50` | `#FFF8F0` | 선택 상태 배경, 카드 hover |
|
||||||
|
| `brand-100` | `#FFEDD5` | 연한 배경, 뱃지 배경 |
|
||||||
|
| `brand-200` | `#FFD6A5` | 보조 배경 |
|
||||||
|
| `brand-300` | `#FFBC72` | 호버 보더, 보조 강조 |
|
||||||
|
| `brand-400` | `#F5A623` | 스피너, 중간 강조 |
|
||||||
|
| `brand-500` | `#F59E3F` | Primary 라이트 |
|
||||||
|
| `brand-600` | `#E8720C` | **Primary** — 아이콘, 버튼, 핵심 강조색 |
|
||||||
|
| `brand-700` | `#C45A00` | Primary 다크 — 텍스트 강조 |
|
||||||
|
| `brand-800` | `#9A4500` | 진한 강조 |
|
||||||
|
| `brand-900` | `#6B3000` | 다크 상태 |
|
||||||
|
| `brand-950` | `#3D1A00` | 최진한 강조 |
|
||||||
|
|
||||||
|
### 3.3 시맨틱 토큰
|
||||||
|
| 토큰 | Hex | 용도 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `background` | `#FFFAF5` | 페이지 배경 (크림 화이트) |
|
||||||
|
| `foreground` | `#171717` | 본문 텍스트 |
|
||||||
|
| `surface` | `#FFFFFF` | 카드/패널 배경 |
|
||||||
|
|
||||||
|
### 3.4 컬러 사용 원칙
|
||||||
|
- 인터랙티브/강조 요소에는 반드시 `brand-*` 토큰 사용 (`orange-*` 금지)
|
||||||
|
- Tailwind 기본 `gray` 팔레트는 변경하지 않음 (다크 모드 호환 문제)
|
||||||
|
- 라이트 모드 전용 (`color-scheme: only light`)
|
||||||
|
- 다크 모드 CSS는 코멘트 처리하여 보존 중 (향후 지원 가능성)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 타이포그래피
|
||||||
|
|
||||||
|
### 4.1 서체 스택
|
||||||
|
```
|
||||||
|
Pretendard Variable → Geist → system-ui → sans-serif
|
||||||
|
```
|
||||||
|
|
||||||
|
| 서체 | 역할 | 로딩 방식 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **Pretendard Variable** | 1순위 본문 서체 | 로컬 woff2 (`src/fonts/`) |
|
||||||
|
| **Geist** | 폴백 서체 | Google Fonts (`next/font/google`) |
|
||||||
|
|
||||||
|
### 4.2 선택 이유
|
||||||
|
- **Pretendard**: 한국어 가독성 최적화, 다양한 웨이트 지원, 깔끔하고 현대적
|
||||||
|
- **Geist**: 영문/숫자 렌더링 우수, Next.js 기본 서체로 호환성 확보
|
||||||
|
|
||||||
|
### 4.3 사용 원칙
|
||||||
|
- 웨이트: Regular(400), Medium(500), SemiBold(600), Bold(700) 주로 사용
|
||||||
|
- 한글 최소 본문 크기: 14px (가독성 확보)
|
||||||
|
- 레이블/캡션: 12px까지 허용
|
||||||
|
- HTML lang: `ko` (한국어 기본)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 아이콘 시스템
|
||||||
|
|
||||||
|
Tasteby는 **3개의 아이콘 레이어**를 사용한다.
|
||||||
|
|
||||||
|
### 5.1 Material Symbols Rounded — UI 아이콘
|
||||||
|
**용도**: 범용 UI (검색, 재생, 네비게이션, 상태 표시 등)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 폰트 | Material Symbols Rounded (Google Fonts) |
|
||||||
|
| 기본 설정 | FILL 0, wght 400, GRAD 0, opsz 24 |
|
||||||
|
| 컴포넌트 | `<Icon name="search" size={20} />` |
|
||||||
|
| 파일 | `src/components/Icon.tsx` |
|
||||||
|
|
||||||
|
**사용 위치**:
|
||||||
|
- RestaurantList 음식 카테고리 아이콘
|
||||||
|
- MapView 마커/InfoWindow 카테고리 아이콘
|
||||||
|
- SearchBar, 네비게이션, 재생 버튼 등 모든 UI 아이콘
|
||||||
|
- RestaurantDetail 상세 정보 아이콘
|
||||||
|
|
||||||
|
**주요 아이콘 매핑** (`getCuisineIcon()`):
|
||||||
|
- 한식 → `rice_bowl`, 일식 → `set_meal`, 중식 → `skillet`
|
||||||
|
- 양식 → `dinner_dining`, 아시아 → `restaurant`, 기타 → `flatware`
|
||||||
|
- 소분류별 세부 매핑 (67개 규칙) — `cuisine-icons.ts` 참조
|
||||||
|
|
||||||
|
**스타일 규칙**:
|
||||||
|
- 아이콘 크기와 `width`/`height`를 동일하게 설정하여 종횡비 고정
|
||||||
|
- `overflow: hidden`으로 비정형 아이콘(예: sake) 크기 넘침 방지
|
||||||
|
|
||||||
|
### 5.2 Phosphor Icons — 장르 카드 픽토그램
|
||||||
|
**용도**: 홈 탭 장르 필터 카드의 카테고리 아이콘
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 패키지 | `@phosphor-icons/react` (^2.1.10) |
|
||||||
|
| 라이선스 | MIT |
|
||||||
|
| 스타일 | Regular (outline) 기본 |
|
||||||
|
| 파일 | `src/lib/cuisine-icons.ts` → `getPhosphorCuisineIcon()` |
|
||||||
|
|
||||||
|
**Phosphor 아이콘 매핑**:
|
||||||
|
|
||||||
|
| 카테고리 | 아이콘 | Phosphor 컴포넌트명 |
|
||||||
|
|----------|--------|---------------------|
|
||||||
|
| 한식 | 밥그릇 | `BowlFood` |
|
||||||
|
| 일식 | 물고기 | `FishSimple` |
|
||||||
|
| 중식 | 불꽃 | `Fire` |
|
||||||
|
| 양식 | 피자 | `Pizza` |
|
||||||
|
| 아시아 | 김 나는 그릇 | `BowlSteam` |
|
||||||
|
| 기타 | 쿠키 | `Cookie` |
|
||||||
|
|
||||||
|
**소분류 Phosphor 매핑** (주요 항목):
|
||||||
|
| 소분류 | 아이콘 | 비고 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 국밥/해장국 | `BowlSteam` | |
|
||||||
|
| 소고기/한우구이 | `Cow` | |
|
||||||
|
| 곱창/막창 | `Flame` | |
|
||||||
|
| 닭/오리구이 | `Bird` | |
|
||||||
|
| 회/횟집 | `Fish` | |
|
||||||
|
| 해산물 | `Shrimp` | |
|
||||||
|
| 주점/포차 | `BeerStein` | |
|
||||||
|
| 이자카야 | `Martini` | 술잔 모양 |
|
||||||
|
| 파인다이닝/코스 | `Champagne` | |
|
||||||
|
| 스시/오마카세 | `Fish` | |
|
||||||
|
| 스테이크 | `Knife` | |
|
||||||
|
| 햄버거 | `Hamburger` | |
|
||||||
|
| 피자 | `Pizza` | |
|
||||||
|
| 프렌치 | `Champagne` | |
|
||||||
|
| 마라/훠궈 | `Pepper` | |
|
||||||
|
| 딤섬/만두 | `Egg` | |
|
||||||
|
| 비건/샐러드 | `Leaf` | |
|
||||||
|
| 카페/디저트 | `Coffee` | |
|
||||||
|
| 베이커리 | `Bread` | |
|
||||||
|
|
||||||
|
### 5.3 Custom SVG Food Icons — 커스텀 음식 픽토그램
|
||||||
|
**용도**: Phosphor에 적합한 아이콘이 없는 특수 음식 카테고리
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 컴포넌트 | `<FoodIcon name="noodle" size={22} />` |
|
||||||
|
| 파일 | `src/components/FoodIcon.tsx` |
|
||||||
|
| 접두어 | `food:` (예: `food:noodle`) |
|
||||||
|
| 렌더링 | SVG inline, `fill` 또는 `stroke` 모드 |
|
||||||
|
|
||||||
|
**등록된 커스텀 아이콘**:
|
||||||
|
|
||||||
|
| 이름 | 대상 소분류 | viewBox | 렌더링 모드 | 출처 |
|
||||||
|
|------|------------|---------|------------|------|
|
||||||
|
| `jjigae` | 찌개/전골/탕 | 0 0 24 24 | stroke | 자체 제작 (뚝배기+김) |
|
||||||
|
| `tteok` | 분식 | 0 0 24 24 | stroke | 자체 제작 (가래떡) |
|
||||||
|
| `noodle` | 면, 라멘, 소바/우동, 베트남 | 0 0 363.674 363.674 | fill | 외부 SVG (그릇+면+젓가락) |
|
||||||
|
| `tempura` | 텐동/튀김 | 0 0 64 64 | fill | Flaticon (텐푸라) |
|
||||||
|
| `pig` | 삼겹살/돼지구이, 족발/보쌈, 돈카츠 | 0 0 90 90 | fill | 외부 SVG (돼지 전신) |
|
||||||
|
|
||||||
|
### 5.4 아이콘 선택 기준
|
||||||
|
|
||||||
|
```
|
||||||
|
1단계: Phosphor Icons에 적합한 아이콘이 있는가?
|
||||||
|
→ 있으면 Phosphor 사용
|
||||||
|
|
||||||
|
2단계: 없으면 커스텀 SVG(FoodIcon)로 제작/수급
|
||||||
|
→ food: 접두어로 매핑
|
||||||
|
|
||||||
|
3단계: UI 범용 아이콘은 Material Symbols
|
||||||
|
→ 검색, 네비, 상태 표시 등
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 아이콘 제작/추가 가이드
|
||||||
|
|
||||||
|
**커스텀 SVG 추가 시:**
|
||||||
|
1. `FoodIcon.tsx`의 `FOOD_ICONS` Record에 `IconDef` 추가
|
||||||
|
2. `viewBox`는 원본 SVG에 맞게 설정 (24×24가 아닐 수 있음)
|
||||||
|
3. `fill: true`면 fill 모드, 생략/false면 stroke 모드
|
||||||
|
4. `cuisine-icons.ts`의 `PHOSPHOR_SUB_RULES`에서 `food:이름`으로 매핑
|
||||||
|
5. SVG 경로는 JSX로 변환 (`clip-rule` → `clipRule` 등)
|
||||||
|
|
||||||
|
**라이선스 확인:**
|
||||||
|
- Phosphor Icons: MIT ✅
|
||||||
|
- Material Symbols: Apache 2.0 ✅
|
||||||
|
- 외부 SVG: 출처별 라이선스 확인 필수 (Flaticon은 Attribution 필요할 수 있음)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 레이아웃 & 컴포넌트 스타일
|
||||||
|
|
||||||
|
### 6.1 홈 탭 구성
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ [로고] [검색] [유튜버▼] │ ← 헤더
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [한식][일식][중식][양식]... →scroll │ ← 장르 카드 (가로 스크롤+드래그)
|
||||||
|
│ [가격▼][지역▼][내위치][N개 결과] │ ← 필터 바 (flex-wrap)
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 지도 / 리스트 / 근처 탭 │ ← 메인 콘텐츠
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 장르 카드 스타일
|
||||||
|
- 칩(chip) 형태, 가로 스크롤 + 마우스 드래그 지원 (`useDragScroll`)
|
||||||
|
- 아이콘(22px) + 텍스트 라벨
|
||||||
|
- 선택 시: `bg-brand-50 border-brand-300 text-brand-700`
|
||||||
|
- 비선택 시: `bg-white border-gray-200 text-gray-600`
|
||||||
|
- 아이콘 색상: 선택 시 `brand-600`, 비선택 시 `gray-400`
|
||||||
|
|
||||||
|
### 6.3 카드/서피스
|
||||||
|
- 배경: `surface` (#FFFFFF)
|
||||||
|
- 보더: `border-gray-200` (1px)
|
||||||
|
- 라운딩: `rounded-lg` (8px) 기본
|
||||||
|
- 그림자: 최소한 사용 (flat 디자인 지향)
|
||||||
|
|
||||||
|
### 6.4 버튼/인터랙티브
|
||||||
|
- Primary: `bg-brand-600 text-white hover:bg-brand-700`
|
||||||
|
- Secondary: `bg-brand-50 text-brand-700 border-brand-300`
|
||||||
|
- 포커스 링: `ring-brand-400`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 지도 마커 스타일
|
||||||
|
|
||||||
|
### 7.1 마커 디자인
|
||||||
|
텍스트 라벨 마커 (핀이 아닌 말풍선 형태):
|
||||||
|
- 배경: 흰색 (폐업 시 회색)
|
||||||
|
- 보더: 카테고리별 색상
|
||||||
|
- 텍스트: 레스토랑 이름 + 카테고리 아이콘 (Material Symbols)
|
||||||
|
- 하단 꼬리(arrow): 삼각형
|
||||||
|
- 선택 시: 파란색(`#1d4ed8`) 보더+꼬리
|
||||||
|
|
||||||
|
### 7.2 마커 카테고리 색상
|
||||||
|
| 카테고리 | 보더 | 배경 | 화살표 |
|
||||||
|
|----------|------|------|--------|
|
||||||
|
| 한식 | `#E8720C` | `#FFF8F0` | `#E8720C` |
|
||||||
|
| 일식 | `#D94F5A` | `#FFF5F5` | `#D94F5A` |
|
||||||
|
| 기타 | 기본 그레이 | 기본 화이트 | 기본 그레이 |
|
||||||
|
|
||||||
|
### 7.3 클러스터링
|
||||||
|
- 라이브러리: supercluster
|
||||||
|
- 클러스터 마커: 원형 + 개수 텍스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 향후 방향
|
||||||
|
|
||||||
|
### 8.1 다크 모드
|
||||||
|
- 현재 라이트 모드 전용
|
||||||
|
- 다크 모드 CSS 토큰은 정의되어 있으나 비활성 상태
|
||||||
|
- 다크 배경 로고 에셋 준비 완료 (`logo-dark-*.png`)
|
||||||
|
- 활성화 시 Tailwind 기본 gray 팔레트 유지 (커스텀 gray 금지)
|
||||||
|
|
||||||
|
### 8.2 아이콘 정리
|
||||||
|
- `@tabler/icons-react` 의존성 제거 가능 (현재 미사용)
|
||||||
|
- 커스텀 SVG 아이콘 추가 확장 가능 (소고기/해산물 등)
|
||||||
|
- Flaticon 에셋 사용 시 Attribution 라이선스 확인 필요
|
||||||
|
|
||||||
|
### 8.3 디자인 토큰 확장
|
||||||
|
- 현재: 색상 + 폰트 토큰만 정의
|
||||||
|
- 향후: spacing, radius, shadow, motion 토큰 추가 가능
|
||||||
|
- Tailwind v4 `@theme` 기반으로 확장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── docs/
|
||||||
|
│ ├── brand-guide.md ← 이 문서
|
||||||
|
│ └── design-concepts.md ← 초기 컨셉 후보안
|
||||||
|
├── public/
|
||||||
|
│ ├── logo.png ← 로고 에셋들
|
||||||
|
│ ├── logo-{80h,120h,200h}.png
|
||||||
|
│ ├── logo-dark.png
|
||||||
|
│ └── logo-dark-{80h,120h}.png
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── globals.css ← 디자인 토큰 (@theme inline)
|
||||||
|
│ │ ├── layout.tsx ← 폰트, 메타 설정
|
||||||
|
│ │ └── page.tsx ← 홈 탭 (장르 카드 렌더링)
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Icon.tsx ← Material Symbols 래퍼
|
||||||
|
│ │ └── FoodIcon.tsx ← 커스텀 SVG 아이콘
|
||||||
|
│ ├── fonts/
|
||||||
|
│ │ └── PretendardVariable.woff2
|
||||||
|
│ └── lib/
|
||||||
|
│ └── cuisine-icons.ts ← 아이콘 매핑 (Material + Phosphor + Food)
|
||||||
|
```
|
||||||
@@ -15,6 +15,7 @@ import MyReviewsList from "@/components/MyReviewsList";
|
|||||||
import BottomSheet from "@/components/BottomSheet";
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
|
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
|
||||||
import { getCuisineIcon, getPhosphorCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon, getPhosphorCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import { passesMinMentions } from "@/lib/filters";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
import FoodIcon from "@/components/FoodIcon";
|
import FoodIcon from "@/components/FoodIcon";
|
||||||
import * as PhosphorIcons from "@phosphor-icons/react";
|
import * as PhosphorIcons from "@phosphor-icons/react";
|
||||||
@@ -196,6 +197,7 @@ export default function Home() {
|
|||||||
const [channelFilter, setChannelFilter] = useState("");
|
const [channelFilter, setChannelFilter] = useState("");
|
||||||
const [cuisineFilter, setCuisineFilter] = useState("");
|
const [cuisineFilter, setCuisineFilter] = useState("");
|
||||||
const [priceFilter, setPriceFilter] = useState("");
|
const [priceFilter, setPriceFilter] = useState("");
|
||||||
|
const [minMentions, setMinMentions] = useState(0); // #533 — 의미있는 언급 N회 미만 식당 제외 (0=전체)
|
||||||
const [viewMode, setViewMode] = useState<"map" | "list">("list");
|
const [viewMode, setViewMode] = useState<"map" | "list">("list");
|
||||||
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
|
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
|
||||||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||||
@@ -248,6 +250,7 @@ export default function Home() {
|
|||||||
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
||||||
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
|
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
|
||||||
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
|
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
|
||||||
|
if (!passesMinMentions(r.video_count, minMentions)) return false;
|
||||||
if (countryFilter) {
|
if (countryFilter) {
|
||||||
const parsed = parseRegion(r.region);
|
const parsed = parseRegion(r.region);
|
||||||
if (!parsed || parsed.country !== countryFilter) return false;
|
if (!parsed || parsed.country !== countryFilter) return false;
|
||||||
@@ -271,7 +274,7 @@ export default function Home() {
|
|||||||
if (da !== db) return da - db;
|
if (da !== db) return da - db;
|
||||||
return (b.rating || 0) - (a.rating || 0);
|
return (b.rating || 0) - (a.rating || 0);
|
||||||
});
|
});
|
||||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, minMentions, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||||
|
|
||||||
// FilterSheet option builders
|
// FilterSheet option builders
|
||||||
const cuisineOptions = useMemo<FilterOption[]>(() => {
|
const cuisineOptions = useMemo<FilterOption[]>(() => {
|
||||||
@@ -366,6 +369,7 @@ export default function Home() {
|
|||||||
setChannelFilter("");
|
setChannelFilter("");
|
||||||
setCuisineFilter("");
|
setCuisineFilter("");
|
||||||
setPriceFilter("");
|
setPriceFilter("");
|
||||||
|
setMinMentions(0);
|
||||||
setCountryFilter("");
|
setCountryFilter("");
|
||||||
setCityFilter("");
|
setCityFilter("");
|
||||||
setDistrictFilter("");
|
setDistrictFilter("");
|
||||||
@@ -385,6 +389,8 @@ export default function Home() {
|
|||||||
const handleSelectRestaurant = useCallback((r: Restaurant) => {
|
const handleSelectRestaurant = useCallback((r: Restaurant) => {
|
||||||
setSelected(r);
|
setSelected(r);
|
||||||
setShowDetail(true);
|
setShowDetail(true);
|
||||||
|
// selected 변경 자체가 MapView 내부의 useEffect에서 중앙 이동을 처리.
|
||||||
|
// (flyTo를 같이 set하면 줌 16으로 강제되어 클러스터 expansion 후 마커 클릭 시 다시 묶임)
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCloseDetail = useCallback(() => {
|
const handleCloseDetail = useCallback(() => {
|
||||||
@@ -475,6 +481,7 @@ export default function Home() {
|
|||||||
setChannelFilter("");
|
setChannelFilter("");
|
||||||
setCuisineFilter("");
|
setCuisineFilter("");
|
||||||
setPriceFilter("");
|
setPriceFilter("");
|
||||||
|
setMinMentions(0);
|
||||||
setCountryFilter("");
|
setCountryFilter("");
|
||||||
setCityFilter("");
|
setCityFilter("");
|
||||||
setDistrictFilter("");
|
setDistrictFilter("");
|
||||||
@@ -816,9 +823,23 @@ export default function Home() {
|
|||||||
<option key={g.label} value={g.label}>{g.label}</option>
|
<option key={g.label} value={g.label}>{g.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(cuisineFilter || priceFilter) && (
|
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||||
|
<select
|
||||||
|
value={minMentions}
|
||||||
|
onChange={(e) => { setMinMentions(Number(e.target.value)); if (Number(e.target.value)) setBoundsFilterOn(false); }}
|
||||||
|
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||||
|
minMentions > 0 ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||||
|
}`}
|
||||||
|
title="영상에서 충분히 언급된(관련도 높은) 식당만 보기"
|
||||||
|
>
|
||||||
|
<option value={0}>언급</option>
|
||||||
|
<option value={2}>2회+</option>
|
||||||
|
<option value={3}>3회+</option>
|
||||||
|
<option value={5}>5회+</option>
|
||||||
|
</select>
|
||||||
|
{(cuisineFilter || priceFilter || minMentions > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
|
onClick={() => { setCuisineFilter(""); setPriceFilter(""); setMinMentions(0); }}
|
||||||
className="p-1.5 -mr-1 text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
|
className="p-1.5 -mr-1 text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
|
||||||
title="음식 필터 초기화"
|
title="음식 필터 초기화"
|
||||||
>
|
>
|
||||||
|
|||||||
93
frontend/src/components/FoodIcon.tsx
Normal file
93
frontend/src/components/FoodIcon.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface FoodIconProps {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IconDef {
|
||||||
|
viewBox: string;
|
||||||
|
fill?: boolean; // true = fill icon (no stroke), false = stroke icon (default)
|
||||||
|
paths: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOOD_ICONS: Record<string, IconDef> = {
|
||||||
|
// 찌개/전골/탕: 뚝배기 + 김
|
||||||
|
"jjigae": {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
paths: (
|
||||||
|
<>
|
||||||
|
<path d="M5 11h14v1c0 4-2.5 7-7 7s-7-3-7-7v-1z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M4 11h16" strokeLinecap="round" />
|
||||||
|
<path d="M3 11.5h1M20 11.5h1" strokeLinecap="round" strokeWidth="1.5" />
|
||||||
|
<path d="M9 8c0-1.5 1-2 0-3M12 7c0-1.5 1-2 0-3M15 8c0-1.5 1-2 0-3" strokeLinecap="round" fill="none" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 분식: 가래떡 1개 사선
|
||||||
|
"tteok": {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
paths: (
|
||||||
|
<rect x="2" y="10" width="20" height="4" rx="2" transform="rotate(-30 12 12)" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 면: 그릇에 면+젓가락 (noodles-in-a-bowl)
|
||||||
|
"noodle": {
|
||||||
|
viewBox: "0 0 363.674 363.674",
|
||||||
|
fill: true,
|
||||||
|
paths: (
|
||||||
|
<>
|
||||||
|
<path d="M79.496,292.615c-16.686-10.313-28.229-27.017-33.383-48.308c-0.975-4.025-5.027-6.502-9.054-5.524c-4.026,0.975-6.499,5.027-5.524,9.054c6.102,25.21,19.959,45.105,40.074,57.538c1.229,0.759,2.59,1.121,3.936,1.121c2.514,0,4.969-1.263,6.387-3.558C84.109,299.415,83.02,294.793,79.496,292.615z" />
|
||||||
|
<path d="M363.465,70.469c-0.967-4.027-5.016-6.5-9.045-5.542l-179.247,43.032V85.908l163.056-69.827c3.807-1.631,5.572-6.039,3.941-9.847c-1.631-3.809-6.039-5.574-9.848-3.942L174.755,69.768c-1.017-2.937-3.8-5.049-7.082-5.049c-4.142,0-7.5,3.357-7.5,7.5v3.794l-4.55,1.948c-0.631-3.505-3.689-6.166-7.375-6.166c-4.143,0-7.5,3.357-7.5,7.5v5.036l-4.543,1.945c-0.611-3.527-3.68-6.213-7.382-6.213c-4.142,0-7.5,3.357-7.5,7.5v5.086l-4.497,1.926c-0.482-3.677-3.621-6.517-7.428-6.517c-4.143,0-7.5,3.357-7.5,7.5v5.409l-4.631,1.983c-0.779-3.313-3.745-5.78-7.295-5.78c-4.142,0-7.5,3.357-7.5,7.5v4.615l-20.778,8.898c-3.808,1.631-5.572,6.039-3.942,9.847c1.219,2.846,3.988,4.55,6.898,4.55c0.984,0,1.986-0.195,2.949-0.607l14.873-6.369v4.608l-14.318,3.438c-4.027,0.967-6.509,5.016-5.542,9.044c0.825,3.439,3.899,5.751,7.286,5.751c0.58,0,1.17-0.068,1.758-0.209l10.816-2.597v64.284H7.5c-4.142,0-7.5,3.357-7.5,7.5c0,28.225,6.892,54.242,19.93,75.239c12.92,20.807,31.711,36.318,54.55,45.096v16.73c0,4.143,3.358,7.5,7.5,7.5h97.493c4.143,0,7.5-3.357,7.5-7.5v-16.73c22.838-8.777,41.631-24.29,54.549-45.096c13.039-20.997,19.932-47.015,19.932-75.239c0-4.143-3.358-7.5-7.5-7.5H116.898v-72.549l4.719-1.133c0.898,3.138,3.781,5.436,7.206,5.436c4.142,0,7.5-3.357,7.5-7.5v-1.466l4.471-1.074c0.405,3.76,3.587,6.688,7.454,6.688c4.143,0,7.5-3.357,7.5-7.5v-2.777l4.425-1.063v14.33c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5v-17.931l182.749-43.873C361.949,78.546,364.432,74.497,363.465,70.469z M97.473,119.181l4.426-1.895v8.264l-4.426,1.063V119.181z M89.479,346.99v-7.19h82.493v7.19H89.479z M178.76,324.8H82.693c-40.367-14.153-65.217-51.11-67.519-99.875h231.103C243.975,273.689,219.127,310.646,178.76,324.8z M101.898,209.925h-4.426v-67.886l4.426-1.063V209.925z M116.898,110.863l4.425-1.895v11.918l-4.425,1.063V110.863z M136.323,102.545l4.425-1.896v15.574l-4.425,1.063V102.545z M155.748,112.623V94.226l4.425-1.896v19.229L155.748,112.623z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 텐동/튀김: 텐푸라 (flaticon tempura)
|
||||||
|
"tempura": {
|
||||||
|
viewBox: "0 0 64 64",
|
||||||
|
fill: true,
|
||||||
|
paths: (
|
||||||
|
<>
|
||||||
|
<path d="M59.96092,38.28982c-.58769-2.348-4.12861-1.72934-6.10276-1.50418a48.12688,48.12688,0,0,0,2.50872-4.73925A1.91121,1.91121,0,0,0,54.08314,29.416c-3.94455,1.2-6.79557,4.77253-8.49153,7.55764a8.05234,8.05234,0,0,1-1.77036,2.03025c-.05638-.04614-.115-.097-.16482-.13169a5.09683,5.09683,0,0,0-5.11816-.48233,4.7649,4.7649,0,0,0-5.21482-.55654,5.28288,5.28288,0,0,0-3.90841-.05207,4.9359,4.9359,0,0,0-4.18868-1.25476A4.63263,4.63263,0,0,0,21.0592,34.644a4.73344,4.73344,0,0,0-8.3529-1.07386c-2.98316-.34766-6.15924,2.35525-5.34459,5.723A5.18758,5.18758,0,0,0,5.5691,43.06339a4.84285,4.84285,0,0,0,3.72,4.64886,4.72272,4.72272,0,0,0,5.13667,4.69644A4.74936,4.74936,0,0,0,20.736,54.771a4.73286,4.73286,0,0,0,6.6833,1.47475.99956.99956,0,1,0-1.1287-1.65,2.71212,2.71212,0,0,1-3.84593-.86364,2.05527,2.05527,0,0,0-2.603-.75617,2.5586,2.5586,0,0,1-1.09452.24556,2.73778,2.73778,0,0,1-2.67039-2.17292,1.00314,1.00314,0,0,0-.46182-.65271c-.46123-.33887-1.17682.06863-1.60712.03614a2.72229,2.72229,0,0,1-2.7192-2.71971c-.00244-.45741.32815-1.1176-.06741-1.54852a1.00725,1.00725,0,0,0-.93241-.38128,2.72268,2.72268,0,0,1-1.6968-4.8248,2.11365,2.11365,0,0,0,.72141-2.09722c-.55724-1.9936,1.76582-3.57014,2.96729-3.2786a2.07918,2.07918,0,0,0,2.08545-.89638,2.709,2.709,0,0,1,4.94632,1.24537,1.00583,1.00583,0,0,0,1.30933.8426,2.64386,2.64386,0,0,1,3.00723.9549,2.065,2.065,0,0,0,2.04257.78843,2.793,2.793,0,0,1,2.357.70543,2.12238,2.12238,0,0,0,2.32378.41545,2.67127,2.67127,0,0,1,2.48878.1982.99706.99706,0,0,0,1.10819-.0415,2.72809,2.72809,0,0,1,3.29624.12156,2.05987,2.05987,0,0,0,2.27495.27241,2.70644,2.70644,0,0,1,3.72479,3.33392c-.38932.81316.21723,1.64631.15282,2.47613A2.71632,2.71632,0,0,1,40.1902,48.528a2.00549,2.00549,0,0,0-2.17527,1.16787,2.71478,2.71478,0,0,1-2.49171,1.64568,2.592,2.592,0,0,1-.949-.17282,2.03329,2.03329,0,0,0-2.42337.79575,2.73019,2.73019,0,0,1-1.58075,1.17262.99911.99911,0,0,0-.69908,1.22877c.77166,1.84716,3.33875-.273,3.98066-1.33265A4.69753,4.69753,0,0,0,39.828,50.49447,4.90789,4.90789,0,0,0,44.20519,49.091c1.07331-1.28142.69028-2.47595,2.314-3.18146a7.17839,7.17839,0,0,1,2.02786-.83393,32.47063,32.47063,0,0,0,10.64445-4.85112A1.88349,1.88349,0,0,0,59.96092,38.28982ZM48.05305,43.138a8.88032,8.88032,0,0,0-2.879,1.26343c.07057-1.027.56233-2.1911-.21569-3.75184,3.30834-2.7934,4.28439-7.41256,9.50762-9.25727A41.37828,41.37828,0,0,1,50.91767,37.626c-1.32533.664-3.54,1.03493-2.8774,2.52866a.99875.99875,0,0,0,1.36213.37843c2.1403-1.32937,6.2594-2.42108,8.52181-1.86132A30.48308,30.48308,0,0,1,48.05305,43.138Z" />
|
||||||
|
<path d="M20.71747,49.98436A3.136,3.136,0,0,0,24.414,47.5605a1.00042,1.00042,0,0,0-1.95861-.40708,1.12553,1.12553,0,0,1-2.20366-.45852,1.00043,1.00043,0,0,0-1.95863-.407A3.12939,3.12939,0,0,0,20.71747,49.98436Z" />
|
||||||
|
<path d="M33.96005,46.60805a1.13194,1.13194,0,0,1-1.5456-.38031,1.00012,1.00012,0,0,0-1.71058,1.036,3.126,3.126,0,0,0,5.34752-3.23828,1.0001,1.0001,0,0,0-1.71052,1.03606A1.13019,1.13019,0,0,1,33.96005,46.60805Z" />
|
||||||
|
<path d="M14.89436,39.24667A1.131,1.131,0,0,1,16.44,39.627a1.00011,1.00011,0,0,0,1.71058-1.036A3.12612,3.12612,0,0,0,12.803,41.82975a1.00009,1.00009,0,0,0,1.71052-1.03606A1.13084,1.13084,0,0,1,14.89436,39.24667Z" />
|
||||||
|
<path d="M6.12651,33.96448a2.694,2.694,0,0,1,.36907-2.33061c1.05665-1.50674-.70244-2.64347-.19038-4.31314C7.64257,24.515,9.289,26.224,10.16584,23.95766a2.71747,2.71747,0,0,1,5.09174-.25918,1.00613,1.00613,0,0,0,1.51436.41191,2.23774,2.23774,0,0,1,.74692-.37979,2.64262,2.64262,0,0,1,2.39017.41545,2.05437,2.05437,0,0,0,2.192.15622,2.71372,2.71372,0,0,1,2.47706-.00635,2.09934,2.09934,0,0,0,2.31206-.28413,2.64664,2.64664,0,0,1,2.44094-.54725,1.00237,1.00237,0,0,0,1.04765-.35882,2.68307,2.68307,0,0,1,3.20154-.85528,2.04549,2.04549,0,0,0,2.23589-.40278c.466-.57119,2.41006-1.24944,3.47261-.19757a2.77124,2.77124,0,0,1,1.07535,2.30556,2.12056,2.12056,0,0,0,.42765,1.36351,2.71658,2.71658,0,0,1-1.84936,4.30037,2.00726,2.00726,0,0,0-1.774,1.76536,2.64106,2.64106,0,0,1-1.3718,2.06064,1.00024,1.00024,0,0,0,.953,1.75842,4.56245,4.56245,0,0,0,2.38621-3.594,4.8526,4.8526,0,0,0,3.78058-2.63716,5.43994,5.43994,0,0,0,.35986-2.5729,8.82313,8.82313,0,0,1,2.61707-2.53306A32.49913,32.49913,0,0,0,54.64846,16.108a1.88292,1.88292,0,0,0,.167-2.07578c-1.24835-2.07129-4.45549-.44274-6.275.35141a48.01336,48.01336,0,0,0,1.00846-5.265,1.91029,1.91029,0,0,0-2.95353-1.84583c-3.42122,2.30473-5.09863,6.55538-5.90317,9.71591-.21291.58392-.67229,2.46176-1.29549,2.3801a5.14553,5.14553,0,0,0-5.03435,1.04112,4.69673,4.69673,0,0,0-5.14842.99789,5.16687,5.16687,0,0,0-3.75118,1.09557,4.88152,4.88152,0,0,0-4.37323.02872,4.60545,4.60545,0,0,0-4.14375-.71718,3.74334,3.74334,0,0,0-.39739.1362A4.70187,4.70187,0,0,0,8.256,23.365a5.23681,5.23681,0,0,0-3.69162,2.97413,4.56732,4.56732,0,0,0,.26544,4.08018,5.13094,5.13094,0,0,0-.62272,4.1052A1.01478,1.01478,0,1,0,6.12651,33.96448Zm41.413-24.91369a41.36518,41.36518,0,0,1-1.56268,6.99793,18.57825,18.57825,0,0,0-2.04893,1.8495.99993.99993,0,0,0,1.45289,1.37371c1.65257-1.89613,5.27564-4.14923,7.60011-4.27732a30.49647,30.49647,0,0,1-8.12739,7.16451A8.87242,8.87242,0,0,0,42.465,24.21738a5.82025,5.82025,0,0,0-1.30541-3.52356C43.51355,17.05538,43.06651,12.37112,47.53947,9.05079Z" />
|
||||||
|
<path d="M31.62065,29.98282a1.14811,1.14811,0,0,1-.81332-.28461,1.00012,1.00012,0,0,0-1.33173,1.49194,3.10428,3.10428,0,0,0,2.07768.79424,3.14507,3.14507,0,0,0,2.08541-5.458,1.00008,1.00008,0,0,0-1.3316,1.492A1.13309,1.13309,0,0,1,31.62065,29.98282Z" />
|
||||||
|
<path d="M12.78637,27.78646a1.13979,1.13979,0,0,1,.81332.28462,1.00013,1.00013,0,0,0,1.33173-1.49195,3.12577,3.12577,0,0,0-4.16309,4.66374,1.00009,1.00009,0,0,0,1.3316-1.492A1.13318,1.13318,0,0,1,12.78637,27.78646Z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 돼지 (삼겹살/돼지구이, 족발/보쌈, 돈카츠)
|
||||||
|
"pig": {
|
||||||
|
viewBox: "0 0 90 90",
|
||||||
|
fill: true,
|
||||||
|
paths: (
|
||||||
|
<>
|
||||||
|
<path d="m66.005 57.841c-.76 3.428-2.077 6.355-3.24 8.521h-2.301c.645-2.74 1.151-5.421 1.312-7.973 1.428-.158 2.844-.334 4.229-.548zm-27.672-.004c.745.129 1.505.239 2.265.348.147 2.609.667 5.365 1.324 8.177h-2.344c-.729-1.359-1.505-2.968-2.172-4.901.355-1.156.672-2.37.927-3.624zm27.09-33.334c8.703-.376 14.869 5.025 17.547 10.541 2.765 5.713.796 11.839-3.765 16.443-.209.208-.328.489-.339.781-.224 6.339-2.589 12.077-4.584 15.739h-3.864c.765-3.176 1.396-6.301 1.572-9.244.032-.547-.323-1.041-.853-1.188-3.975-1.072-5.729-3.661-6.443-6.213-.407-1.511-2.667-.88-2.24.62.349 1.245 1.161 2.469 2.041 3.645-9.041 1.261-17.015 1.464-25.776-.088.219-1.615.328-3.287.276-4.989.011-1.604-2.421-1.532-2.317.068.199 6.905-2.432 13.395-4.609 17.389h-3.844c1.532-6.337 2.12-12.307 1.089-17.645-.1-.557-.589-.964-1.152-.959-.739 0-1.285.677-1.135 1.396.183.937.296 1.912.38 2.901-4.984-.828-8.287-2.407-11.203-4.027-3.157-1.755-5.933-3.624-9.776-4.385-.048-.009-.183-.067-.355-.395-.172-.329-.312-.855-.344-1.396-.025-.543.057-1.095.199-1.469.145-.369.307-.505.411-.541 4.541-1.765 7.147-3.251 11.161-7.683.396-.443.401-1.109.005-1.552-2.077-2.349-2.219-4.749-1.869-7.177l8.244 6.371c1.224.948 2.647-.891 1.423-1.839l-1-.771c1.296-1.172 2.421-2.256 6.073-3.172 4.364-1.089 8.124-.729 13.389-.376 5.272.349 9.917.663 19.887-.629.599-.079 1.192-.131 1.771-.156zm14.598-4.849c-1.547.073-1.437 2.396.115 2.323 1.817 0 2.536 1.063 2.599 2.344.057 1.244-.625 2.64-2.583 3.24-4.048-3.667-9.797-6.109-16.803-5.199-9.787 1.265-14.197.953-19.427.604-5.235-.348-9.324-.749-14.104.448-4.167 1.041-5.891 2.489-7.443 3.932l-6.964-5.385c-.219-.167-.484-.255-.756-.244-.509.02-.943.375-1.077.864-.823 3.109-.667 6.781 1.683 10.136-3.491 3.739-5.568 4.963-9.772 6.599-.891.344-1.432 1.115-1.728 1.88-.297.771-.396 1.599-.349 2.423.041.823.224 1.629.604 2.353.374.718 1.026 1.416 1.964 1.598 3.339.661 5.833 2.319 9.093 4.131 3.099 1.724 6.875 3.505 12.443 4.364.041 3.948-.584 8.235-1.797 12.803-.197.74.36 1.464 1.125 1.459h5.989c.423 0 .808-.224 1.016-.589.765-1.359 1.588-3.052 2.36-4.952.536 1.244 1.088 2.369 1.609 3.301.203.369.593.599 1.009.599h4.448c.767.005 1.323-.719 1.131-1.457-.808-3.079-1.303-5.996-1.464-8.745 6.043.672 10.36.672 16.495.14-.172 2.709-.661 5.579-1.452 8.605-.199.739.364 1.463 1.124 1.457h4.448c.423 0 .813-.229 1.016-.599 1.281-2.297 2.765-5.631 3.645-9.489.412.244.849.473 1.344.661-.229 3.041-.744 6.235-1.64 9.609-.199.735.359 1.459 1.12 1.459h5.995c.416 0 .801-.224 1.005-.589 2.145-3.796 4.771-10.063 5.088-17.093 4.792-5.088 7.167-12.131 3.937-18.615-.891-1.776-2.052-3.427-3.156-4.692 2.135-1.1 3.24-3.167 3.145-5.131-.109-2.333-2.025-4.552-4.921-4.552-.04-.001-.078-.001-.114-.001z" />
|
||||||
|
<path clipRule="evenodd" d="m18.354 36.679c1.177 0 2.135.959 2.135 2.141 0 1.183-.957 2.14-2.135 2.14-1.183 0-2.141-.957-2.141-2.14 0-1.182.959-2.141 2.141-2.141z" fillRule="evenodd" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FoodIcon({ name, size = 24, className = "" }: FoodIconProps) {
|
||||||
|
const icon = FOOD_ICONS[name];
|
||||||
|
if (!icon) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={icon.viewBox}
|
||||||
|
fill={icon.fill ? "currentColor" : "none"}
|
||||||
|
stroke={icon.fill ? "none" : "currentColor"}
|
||||||
|
strokeWidth={icon.fill ? undefined : 1.5}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{icon.paths}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -163,11 +163,12 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
||||||
}, [map, flyTo]);
|
}, [map, flyTo]);
|
||||||
|
|
||||||
// Pan and zoom to selected restaurant
|
// Pan and zoom to selected restaurant — 현재 줌이 16 이상이면 유지(클러스터 expansion 후 재묶임 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !selected) return;
|
if (!map || !selected) return;
|
||||||
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
||||||
map.setZoom(16);
|
const currentZoom = map.getZoom() ?? 13;
|
||||||
|
if (currentZoom < 16) map.setZoom(16);
|
||||||
setInfoTarget(selected);
|
setInfoTarget(selected);
|
||||||
}, [map, selected]);
|
}, [map, selected]);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function Icon({ name, size = 20, filled, className = "" }: IconPr
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
|
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
|
||||||
style={{ fontSize: size }}
|
style={{ fontSize: size, width: size, height: size, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import Supercluster from "supercluster";
|
import Supercluster from "supercluster";
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
|
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -27,6 +28,7 @@ type NaverMapInstance = {
|
|||||||
getZoom: () => number;
|
getZoom: () => number;
|
||||||
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
|
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
|
||||||
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
|
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
|
||||||
|
morph: (latlng: unknown, zoom?: number, transitionOptions?: Record<string, unknown>) => void;
|
||||||
refresh: (noEffect?: boolean) => void;
|
refresh: (noEffect?: boolean) => void;
|
||||||
};
|
};
|
||||||
type NaverMarker = {
|
type NaverMarker = {
|
||||||
@@ -46,14 +48,14 @@ const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
|
|||||||
|
|
||||||
// Channel color palette — GoogleMapView와 동일
|
// Channel color palette — GoogleMapView와 동일
|
||||||
const CHANNEL_COLORS = [
|
const CHANNEL_COLORS = [
|
||||||
{ border: "#f59e0b" }, // amber (default)
|
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
|
||||||
{ border: "#3b82f6" }, // blue
|
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
|
||||||
{ border: "#22c55e" }, // green
|
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
|
||||||
{ border: "#ec4899" }, // pink
|
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
|
||||||
{ border: "#a855f7" }, // purple
|
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
|
||||||
{ border: "#ef4444" }, // red
|
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
|
||||||
{ border: "#14b8a6" }, // teal
|
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
|
||||||
{ border: "#eab308" }, // yellow
|
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
|
||||||
];
|
];
|
||||||
|
|
||||||
function getChannelColorMap(restaurants: Restaurant[]) {
|
function getChannelColorMap(restaurants: Restaurant[]) {
|
||||||
@@ -125,9 +127,29 @@ function getClusterSize(count: number): number {
|
|||||||
return 54;
|
return 54;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 단일 마커 — 채널 색상별
|
// 단일 마커 — 식당명 박스 + 화살표 핀 (GoogleMapView와 동일 디자인)
|
||||||
function markerIconHtml(color: string): string {
|
function markerIconHtml(
|
||||||
return `<div style="width:28px;height:28px;border-radius:9999px;background:${color};border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.25);"></div>`;
|
name: string,
|
||||||
|
cuisineIcon: string,
|
||||||
|
c: typeof CHANNEL_COLORS[0],
|
||||||
|
opts: { isSelected: boolean; isClosed: boolean }
|
||||||
|
): string {
|
||||||
|
const { isSelected, isClosed } = opts;
|
||||||
|
const bg = isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg;
|
||||||
|
const text = isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text;
|
||||||
|
const border = isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`;
|
||||||
|
const shadow = isSelected ? "0 2px 8px rgba(37,99,235,0.4)" : `0 1px 4px ${c.border}40`;
|
||||||
|
const arrowColor = isSelected ? "#1d4ed8" : c.arrow;
|
||||||
|
const opacity = isClosed ? 0.5 : 1;
|
||||||
|
const deco = isClosed ? "line-through" : "none";
|
||||||
|
return `
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;transition:transform .2s ease;transform:scale(${isSelected ? 1.15 : 1});opacity:${opacity};">
|
||||||
|
<div style="padding:4px 8px;background:${bg};color:${text};font-size:12px;font-weight:600;border-radius:6px;border:${border};box-shadow:${shadow};white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis;text-decoration:${deco};">
|
||||||
|
<span class="material-symbols-rounded" style="font-size:14px;width:14px;height:14px;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;margin-right:3px;vertical-align:middle;color:#E8720C;">${escapeHtml(cuisineIcon)}</span>${escapeHtml(name)}
|
||||||
|
</div>
|
||||||
|
<div style="width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid ${arrowColor};margin-top:-1px;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
// SVG data URL — 클러스터(숫자)
|
// SVG data URL — 클러스터(숫자)
|
||||||
function clusterIconHtml(count: number, size: number): string {
|
function clusterIconHtml(count: number, size: number): string {
|
||||||
@@ -210,14 +232,26 @@ export default function NaverMapView({
|
|||||||
}
|
}
|
||||||
}, [ready, flyTo, selected, onBoundsChanged]);
|
}, [ready, flyTo, selected, onBoundsChanged]);
|
||||||
|
|
||||||
// flyTo 변경 반영
|
// flyTo 변경 반영 — morph로 panTo+setZoom 충돌 회피
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = mapRef.current;
|
const m = mapRef.current;
|
||||||
if (!m || !flyTo || !window.naver?.maps) return;
|
if (!m || !flyTo || !window.naver?.maps) return;
|
||||||
m.panTo(new window.naver.maps.LatLng(flyTo.lat, flyTo.lng));
|
const latlng = new window.naver.maps.LatLng(flyTo.lat, flyTo.lng);
|
||||||
if (flyTo.zoom) m.setZoom(flyTo.zoom, true);
|
if (flyTo.zoom) m.morph(latlng, flyTo.zoom);
|
||||||
|
else m.panTo(latlng);
|
||||||
}, [flyTo]);
|
}, [flyTo]);
|
||||||
|
|
||||||
|
// selected 변경 시 자동 중앙 — 현재 줌이 16 이상이면 그대로, 미만이면 16으로 확대
|
||||||
|
// (클러스터 expansion으로 18까지 들어간 상태에서 마커 클릭 시 다시 16으로 줄어 클러스터링되는 것 방지)
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m || !selected || !window.naver?.maps) return;
|
||||||
|
if (selected.latitude == null || selected.longitude == null) return;
|
||||||
|
const latlng = new window.naver.maps.LatLng(selected.latitude, selected.longitude);
|
||||||
|
const targetZoom = Math.max(m.getZoom(), 16);
|
||||||
|
m.morph(latlng, targetZoom);
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
// 클러스터 계산 (bounds/zoom 변경 시)
|
// 클러스터 계산 (bounds/zoom 변경 시)
|
||||||
const clusters = useMemo(() => {
|
const clusters = useMemo(() => {
|
||||||
if (!bounds) return [];
|
if (!bounds) return [];
|
||||||
@@ -250,37 +284,35 @@ export default function NaverMapView({
|
|||||||
});
|
});
|
||||||
naver.Event.addListener(marker, "click", () => {
|
naver.Event.addListener(marker, "click", () => {
|
||||||
const z = Math.min(getExpansionZoom(cluster_id), 18);
|
const z = Math.min(getExpansionZoom(cluster_id), 18);
|
||||||
m.panTo(new naver.LatLng(lat, lng));
|
m.morph(new naver.LatLng(lat, lng), z);
|
||||||
m.setZoom(z, true);
|
|
||||||
});
|
});
|
||||||
markersRef.current.push(marker);
|
markersRef.current.push(marker);
|
||||||
} else {
|
} else {
|
||||||
const r = (feature.properties as RestaurantProps).restaurant;
|
const r = (feature.properties as RestaurantProps).restaurant;
|
||||||
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||||
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
||||||
|
const isSelected = selected?.id === r.id;
|
||||||
|
const isClosed = r.business_status === "CLOSED_PERMANENTLY" || r.business_status === "CLOSED_TEMPORARILY";
|
||||||
|
const cuisineIcon = getCuisineIcon(r.cuisine_type);
|
||||||
const marker = new naver.Marker({
|
const marker = new naver.Marker({
|
||||||
position: new naver.LatLng(lat, lng),
|
position: new naver.LatLng(lat, lng),
|
||||||
map: m,
|
map: m,
|
||||||
title: r.name,
|
title: r.name,
|
||||||
|
zIndex: isSelected ? 1000 : 1,
|
||||||
icon: {
|
icon: {
|
||||||
content: markerIconHtml(chColor?.border ?? "#f59e0b"),
|
content: markerIconHtml(r.name, cuisineIcon, chColor ?? CHANNEL_COLORS[0], { isSelected, isClosed }),
|
||||||
anchor: new naver.Point(14, 14),
|
// 박스 폭 가변 — 화살표 끝(하단 중앙)이 좌표에 위치하도록 추정 anchor
|
||||||
|
// approxWidth = textLen * 7 + 30 (icon+padding), height = box 24 + arrow 6 = 30
|
||||||
|
anchor: new naver.Point(Math.min(r.name.length * 4 + 18, 64), 30),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
naver.Event.addListener(marker, "click", () => {
|
naver.Event.addListener(marker, "click", () => {
|
||||||
const iw = infoWindowRef.current;
|
|
||||||
if (iw && m) {
|
|
||||||
iw.setContent(
|
|
||||||
`<div style="background:#fff;border-radius:8px;padding:8px 12px;box-shadow:0 4px 12px rgba(0,0,0,.18);font-size:13px;font-weight:600;color:#111;white-space:nowrap;">${escapeHtml(r.name)}</div>`
|
|
||||||
);
|
|
||||||
iw.open(m, marker);
|
|
||||||
}
|
|
||||||
onSelectRestaurant?.(r);
|
onSelectRestaurant?.(r);
|
||||||
});
|
});
|
||||||
markersRef.current.push(marker);
|
markersRef.current.push(marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [clusters, getExpansionZoom, onSelectRestaurant, channelColors, activeChannel]);
|
}, [clusters, getExpansionZoom, onSelectRestaurant, channelColors, activeChannel, selected]);
|
||||||
|
|
||||||
// 컴포넌트 unmount 시 마커 정리
|
// 컴포넌트 unmount 시 마커 정리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ReviewSection from "@/components/ReviewSection";
|
|||||||
import MemoSection from "@/components/MemoSection";
|
import MemoSection from "@/components/MemoSection";
|
||||||
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import { formatRegion } from "@/lib/region";
|
||||||
|
|
||||||
interface RestaurantDetailProps {
|
interface RestaurantDetailProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
@@ -127,9 +128,9 @@ export default function RestaurantDetail({
|
|||||||
<span className="text-gray-400 dark:text-gray-500">주소</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
|
<span className="text-gray-400 dark:text-gray-500">주소</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{restaurant.region && (
|
{formatRegion(restaurant.region) && (
|
||||||
<p>
|
<p>
|
||||||
<span className="text-gray-400 dark:text-gray-500">지역</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.region}</span>
|
<span className="text-gray-400 dark:text-gray-500">지역</span> <span className="text-gray-600 dark:text-gray-300">{formatRegion(restaurant.region)}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{restaurant.price_range && (
|
{restaurant.price_range && (
|
||||||
@@ -180,7 +181,7 @@ export default function RestaurantDetail({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
|
{isKoreaRestaurant(restaurant) && restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
|
||||||
<a
|
<a
|
||||||
href={restaurant.tabling_url}
|
href={restaurant.tabling_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -192,7 +193,7 @@ export default function RestaurantDetail({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
|
{isKoreaRestaurant(restaurant) && restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
|
||||||
<a
|
<a
|
||||||
href={restaurant.catchtable_url}
|
href={restaurant.catchtable_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import { formatRegion } from "@/lib/region";
|
||||||
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
||||||
|
|
||||||
interface RestaurantListProps {
|
interface RestaurantListProps {
|
||||||
@@ -50,8 +51,8 @@ export default function RestaurantList({
|
|||||||
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
||||||
{r.name}
|
{r.name}
|
||||||
</h4>
|
</h4>
|
||||||
{r.region && (
|
{formatRegion(r.region) && (
|
||||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
|
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{formatRegion(r.region)}</span>
|
||||||
)}
|
)}
|
||||||
{r.rating && (
|
{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>
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface Restaurant {
|
|||||||
website: string | null;
|
website: string | null;
|
||||||
channels?: string[];
|
channels?: string[];
|
||||||
foods_mentioned?: string[];
|
foods_mentioned?: string[];
|
||||||
|
// #533 — 의미있는(strong/unknown) 영상 언급 수
|
||||||
|
video_count?: number | null;
|
||||||
// #322 LLM 검증
|
// #322 LLM 검증
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
hidden_reason?: string | null;
|
hidden_reason?: string | null;
|
||||||
@@ -168,6 +170,7 @@ export const api = {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
min_mentions?: number;
|
||||||
}) {
|
}) {
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
if (params?.cuisine) sp.set("cuisine", params.cuisine);
|
if (params?.cuisine) sp.set("cuisine", params.cuisine);
|
||||||
@@ -175,6 +178,7 @@ export const api = {
|
|||||||
if (params?.channel) sp.set("channel", params.channel);
|
if (params?.channel) sp.set("channel", params.channel);
|
||||||
if (params?.limit) sp.set("limit", String(params.limit));
|
if (params?.limit) sp.set("limit", String(params.limit));
|
||||||
if (params?.offset) sp.set("offset", String(params.offset));
|
if (params?.offset) sp.set("offset", String(params.offset));
|
||||||
|
if (params?.min_mentions) sp.set("min_mentions", String(params.min_mentions));
|
||||||
const qs = sp.toString();
|
const qs = sp.toString();
|
||||||
return fetchApi<Restaurant[]>(`/api/restaurants${qs ? `?${qs}` : ""}`);
|
return fetchApi<Restaurant[]>(`/api/restaurants${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
|||||||
{ keyword: "라멘", icon: "ramen_dining" },
|
{ keyword: "라멘", icon: "ramen_dining" },
|
||||||
{ keyword: "돈카츠", icon: "lunch_dining" },
|
{ keyword: "돈카츠", icon: "lunch_dining" },
|
||||||
{ keyword: "텐동/튀김", icon: "tapas" },
|
{ keyword: "텐동/튀김", icon: "tapas" },
|
||||||
{ keyword: "이자카야", icon: "sake" },
|
{ keyword: "이자카야", icon: "local_bar" },
|
||||||
{ keyword: "야키니쿠", icon: "kebab_dining" },
|
{ keyword: "야키니쿠", icon: "kebab_dining" },
|
||||||
{ keyword: "카레", icon: "skillet" },
|
{ keyword: "카레", icon: "skillet" },
|
||||||
{ keyword: "소바/우동", icon: "ramen_dining" },
|
{ keyword: "소바/우동", icon: "ramen_dining" },
|
||||||
@@ -77,78 +77,78 @@ export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
|||||||
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
|
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tabler Icons (for genre card chips) ──
|
// ── Phosphor Icons (for genre card chips) ──
|
||||||
// Returns Tabler icon component name (PascalCase without "Icon" prefix)
|
// Returns Phosphor icon component name (PascalCase)
|
||||||
|
|
||||||
const TABLER_CUISINE_MAP: Record<string, string> = {
|
const PHOSPHOR_CUISINE_MAP: Record<string, string> = {
|
||||||
"한식": "BowlChopsticks",
|
"한식": "BowlFood",
|
||||||
"일식": "Fish",
|
"일식": "FishSimple",
|
||||||
"중식": "Soup",
|
"중식": "Fire",
|
||||||
"양식": "Pizza",
|
"양식": "Pizza",
|
||||||
"아시아": "BowlSpoon",
|
"아시아": "BowlSteam",
|
||||||
"기타": "Cookie",
|
"기타": "Cookie",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABLER_SUB_RULES: { keyword: string; icon: string }[] = [
|
const PHOSPHOR_SUB_RULES: { keyword: string; icon: string }[] = [
|
||||||
// 한식
|
// 한식
|
||||||
{ keyword: "백반/한정식", icon: "BowlChopsticks" },
|
{ keyword: "백반/한정식", icon: "BowlFood" },
|
||||||
{ keyword: "국밥/해장국", icon: "Soup" },
|
{ keyword: "국밥/해장국", icon: "BowlSteam" },
|
||||||
{ keyword: "찌개/전골/탕", icon: "Cooker" },
|
{ keyword: "찌개/전골/탕", icon: "CookingPot" },
|
||||||
{ keyword: "삼겹살/돼지구이", icon: "Meat" },
|
{ keyword: "삼겹살/돼지구이", icon: "food:pig" },
|
||||||
{ keyword: "소고기/한우구이", icon: "Grill" },
|
{ keyword: "소고기/한우구이", icon: "Cow" },
|
||||||
{ keyword: "곱창/막창", icon: "GrillFork" },
|
{ keyword: "곱창/막창", icon: "Flame" },
|
||||||
{ keyword: "닭/오리구이", icon: "Meat" },
|
{ keyword: "닭/오리구이", icon: "Bird" },
|
||||||
{ keyword: "족발/보쌈", icon: "Meat" },
|
{ keyword: "족발/보쌈", icon: "food:pig" },
|
||||||
{ keyword: "회/횟집", icon: "Fish" },
|
{ keyword: "회/횟집", icon: "Fish" },
|
||||||
{ keyword: "해산물", icon: "Fish" },
|
{ keyword: "해산물", icon: "Shrimp" },
|
||||||
{ keyword: "분식", icon: "EggFried" },
|
{ keyword: "분식", icon: "food:tteok" },
|
||||||
{ keyword: "면", icon: "BowlChopsticks" },
|
{ keyword: "면", icon: "food:noodle" },
|
||||||
{ keyword: "죽/죽집", icon: "BowlSpoon" },
|
{ keyword: "죽/죽집", icon: "BowlFood" },
|
||||||
{ keyword: "순대/순대국", icon: "Soup" },
|
{ keyword: "순대/순대국", icon: "BowlSteam" },
|
||||||
{ keyword: "장어/민물", icon: "Fish" },
|
{ keyword: "장어/민물", icon: "FishSimple" },
|
||||||
{ keyword: "주점/포차", icon: "Beer" },
|
{ keyword: "주점/포차", icon: "BeerStein" },
|
||||||
{ keyword: "파인다이닝/코스", icon: "GlassChampagne" },
|
{ keyword: "파인다이닝/코스", icon: "Champagne" },
|
||||||
// 일식
|
// 일식
|
||||||
{ keyword: "스시/오마카세", icon: "Fish" },
|
{ keyword: "스시/오마카세", icon: "Fish" },
|
||||||
{ keyword: "라멘", icon: "Soup" },
|
{ keyword: "라멘", icon: "food:noodle" },
|
||||||
{ keyword: "돈카츠", icon: "Meat" },
|
{ keyword: "돈카츠", icon: "food:pig" },
|
||||||
{ keyword: "텐동/튀김", icon: "EggFried" },
|
{ keyword: "텐동/튀김", icon: "food:tempura" },
|
||||||
{ keyword: "이자카야", icon: "GlassCocktail" },
|
{ keyword: "이자카야", icon: "Martini" },
|
||||||
{ keyword: "야키니쿠", icon: "Grill" },
|
{ keyword: "야키니쿠", icon: "Flame" },
|
||||||
{ keyword: "카레", icon: "BowlSpoon" },
|
{ keyword: "카레", icon: "BowlFood" },
|
||||||
{ keyword: "소바/우동", icon: "BowlChopsticks" },
|
{ keyword: "소바/우동", icon: "food:noodle" },
|
||||||
// 중식
|
// 중식
|
||||||
{ keyword: "중화요리", icon: "Soup" },
|
{ keyword: "중화요리", icon: "Fire" },
|
||||||
{ keyword: "마라/훠궈", icon: "Pepper" },
|
{ keyword: "마라/훠궈", icon: "Pepper" },
|
||||||
{ keyword: "딤섬/만두", icon: "Egg" },
|
{ keyword: "딤섬/만두", icon: "Egg" },
|
||||||
{ keyword: "양꼬치", icon: "Grill" },
|
{ keyword: "양꼬치", icon: "Flame" },
|
||||||
// 양식
|
// 양식
|
||||||
{ keyword: "파스타/이탈리안", icon: "BowlSpoon" },
|
{ keyword: "파스타/이탈리안", icon: "ForkKnife" },
|
||||||
{ keyword: "스테이크", icon: "Meat" },
|
{ keyword: "스테이크", icon: "Knife" },
|
||||||
{ keyword: "햄버거", icon: "Burger" },
|
{ keyword: "햄버거", icon: "Hamburger" },
|
||||||
{ keyword: "피자", icon: "Pizza" },
|
{ keyword: "피자", icon: "Pizza" },
|
||||||
{ keyword: "프렌치", icon: "GlassChampagne" },
|
{ keyword: "프렌치", icon: "Champagne" },
|
||||||
{ keyword: "바베큐", icon: "GrillSpatula" },
|
{ keyword: "바베큐", icon: "Flame" },
|
||||||
{ keyword: "브런치", icon: "EggFried" },
|
{ keyword: "브런치", icon: "Coffee" },
|
||||||
{ keyword: "비건/샐러드", icon: "Salad" },
|
{ keyword: "비건/샐러드", icon: "Leaf" },
|
||||||
// 아시아
|
// 아시아
|
||||||
{ keyword: "베트남", icon: "BowlChopsticks" },
|
{ keyword: "베트남", icon: "food:noodle" },
|
||||||
{ keyword: "태국", icon: "Pepper" },
|
{ keyword: "태국", icon: "Pepper" },
|
||||||
{ keyword: "인도/중동", icon: "BowlSpoon" },
|
{ keyword: "인도/중동", icon: "BowlFood" },
|
||||||
{ keyword: "동남아기타", icon: "BowlSpoon" },
|
{ keyword: "동남아기타", icon: "BowlFood" },
|
||||||
// 기타
|
// 기타
|
||||||
{ keyword: "치킨", icon: "Meat" },
|
{ keyword: "치킨", icon: "Egg" },
|
||||||
{ keyword: "카페/디저트", icon: "Coffee" },
|
{ keyword: "카페/디저트", icon: "Coffee" },
|
||||||
{ keyword: "베이커리", icon: "Bread" },
|
{ keyword: "베이커리", icon: "Bread" },
|
||||||
{ keyword: "뷔페", icon: "Cheese" },
|
{ keyword: "뷔페", icon: "ForkKnife" },
|
||||||
{ keyword: "퓨전", icon: "Cookie" },
|
{ keyword: "퓨전", icon: "Cookie" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getTablerCuisineIcon(cuisineType: string | null | undefined): string {
|
export function getPhosphorCuisineIcon(cuisineType: string | null | undefined): string {
|
||||||
if (!cuisineType) return "Bowl";
|
if (!cuisineType) return "ForkKnife";
|
||||||
for (const rule of TABLER_SUB_RULES) {
|
for (const rule of PHOSPHOR_SUB_RULES) {
|
||||||
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
||||||
}
|
}
|
||||||
const main = cuisineType.split("|")[0];
|
const main = cuisineType.split("|")[0];
|
||||||
return TABLER_CUISINE_MAP[main] || "Bowl";
|
return PHOSPHOR_CUISINE_MAP[main] || "ForkKnife";
|
||||||
}
|
}
|
||||||
|
|||||||
23
frontend/src/lib/filters.ts
Normal file
23
frontend/src/lib/filters.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// 클라이언트 필터 순수 함수 — #533
|
||||||
|
// page.tsx 의 filteredRestaurants 에서 사용하는 경계 로직을 테스트 가능하도록 분리.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "의미있는 언급 수(video_count)" 기준으로 식당을 포함할지 판정한다.
|
||||||
|
* 설계서: docs/design/533-min-mentions-filter/README.md
|
||||||
|
*
|
||||||
|
* - minMentions <= 0(기본값) → 필터 미적용, 항상 true.
|
||||||
|
* - videoCount 가 null/undefined(미산정) → 0 으로 간주.
|
||||||
|
* - videoCount >= minMentions 인 경우에만 true (경계값 포함).
|
||||||
|
*
|
||||||
|
* 예) passesMinMentions(1, 2) → false (1회는 2회 미만이라 제외)
|
||||||
|
* passesMinMentions(2, 2) → true (경계값 포함)
|
||||||
|
* passesMinMentions(null, 2) → false (미산정은 0 취급)
|
||||||
|
* passesMinMentions(0, 0) → true (필터 미적용)
|
||||||
|
*/
|
||||||
|
export function passesMinMentions(
|
||||||
|
videoCount: number | null | undefined,
|
||||||
|
minMentions: number
|
||||||
|
): boolean {
|
||||||
|
if (minMentions <= 0) return true;
|
||||||
|
return (videoCount ?? 0) >= minMentions;
|
||||||
|
}
|
||||||
22
frontend/src/lib/region.ts
Normal file
22
frontend/src/lib/region.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// 지역 표시 유틸 — #532
|
||||||
|
// region 은 "나라|시도|구군" 파이프 구분 문자열. 사용자에게는 계층(breadcrumb)으로 보여준다.
|
||||||
|
|
||||||
|
/** 표시에서 제외할 더미/무의미 토큰. */
|
||||||
|
const DUMMY_TOKENS = new Set(["", "null", "나라"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파이프 구분 region 을 "나라 › 시도 › 구군" 계층 문자열로 변환한다.
|
||||||
|
* 빈 토큰·"null"·더미("나라")는 제거한다. 유효 토큰이 없으면 "".
|
||||||
|
*
|
||||||
|
* 예) "한국|서울|강남구" → "한국 › 서울 › 강남구"
|
||||||
|
* "일본|null" → "일본"
|
||||||
|
* "한국||관악구" → "한국 › 관악구"
|
||||||
|
*/
|
||||||
|
export function formatRegion(region: string | null | undefined): string {
|
||||||
|
if (!region) return "";
|
||||||
|
return region
|
||||||
|
.split("|")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => !DUMMY_TOKENS.has(t.toLowerCase()))
|
||||||
|
.join(" › ");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user