From f2861b6b79739a5881f1008c1b6393e46d1e3dfe Mon Sep 17 00:00:00 2001 From: joungmin Date: Thu, 12 Mar 2026 22:52:42 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=88=20=ED=83=AD=20=EC=9E=A5=EB=A5=B4=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20UI=20+=20Tabler=20Icons=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20+=20=EC=A7=80=EC=97=AD=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈 탭: 장르 가로 스크롤 카드 (Tabler Icons 픽토그램) - 홈 탭: 가격/지역/내위치 필터 2줄 배치 - 리스트 탭: 기존 바텀시트 필터 UI 유지 - cuisine-icons: Tabler 아이콘 매핑 추가 (getTablerCuisineIcon) - 드래그 스크롤 장르 카드에 적용 - 배포 가이드 문서 추가 Co-Authored-By: Claude Opus 4.6 --- docs/deployment-guide.md | 262 +++++++++++++++++++++++ frontend/package-lock.json | 27 +++ frontend/package.json | 1 + frontend/src/app/page.tsx | 331 +++++++++++++++++++++--------- frontend/src/lib/cuisine-icons.ts | 143 +++++++++++-- 5 files changed, 649 insertions(+), 115 deletions(-) create mode 100644 docs/deployment-guide.md diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md new file mode 100644 index 0000000..d036a55 --- /dev/null +++ b/docs/deployment-guide.md @@ -0,0 +1,262 @@ +# Tasteby 배포 가이드 + +## 환경 요약 + +| 항목 | Dev (개발) | Prod (운영) | +|------|-----------|-------------| +| URL | dev.tasteby.net | www.tasteby.net | +| 호스트 | 로컬 Mac mini | OKE (Oracle Kubernetes Engine) | +| 프로세스 관리 | PM2 | Kubernetes Deployment | +| 프론트엔드 실행 | `npm run dev` (Next.js dev server) | `node server.js` (standalone 빌드) | +| 백엔드 실행 | `./gradlew bootRun` | `java -jar app.jar` (bootJar 빌드) | +| Redis | 로컬 Redis 서버 | K8s Pod (redis:7-alpine) | +| TLS | Nginx(192.168.0.147) + Certbot | cert-manager + Let's Encrypt | +| 리버스 프록시 | Nginx (192.168.0.147 → 192.168.0.208) | Nginx Ingress Controller (K8s) | +| 도메인 DNS | dev.tasteby.net → Mac mini IP | www.tasteby.net → OCI NLB 217.142.131.194 | + +--- + +## 1. Dev 환경 (dev.tasteby.net) + +### 구조 + +``` +브라우저 → dev.tasteby.net (HTTPS) + ↓ +Nginx (192.168.0.147) — Certbot Let's Encrypt TLS + ├── /api/* → proxy_pass http://192.168.0.208:8000 (tasteby-api) + └── /* → proxy_pass http://192.168.0.208:3001 (tasteby-web) + ↓ +Mac mini (192.168.0.208) — PM2 프로세스 매니저 + ├── tasteby-api → ./gradlew bootRun (:8000) + └── tasteby-web → npm run dev (:3001) +``` + +- **192.168.0.147**: Nginx 리버스 프록시 서버 (TLS 종료, Certbot 자동 갱신) +- **192.168.0.208**: Mac mini (실제 앱 서버, PM2 관리) + +### PM2 프로세스 구성 (ecosystem.config.js) + +```javascript +module.exports = { + apps: [ + { + name: "tasteby-api", + cwd: "/Users/joungmin/workspaces/tasteby/backend-java", + script: "./start.sh", // gradlew bootRun 실행 + interpreter: "/bin/bash", + }, + { + name: "tasteby-web", + cwd: "/Users/joungmin/workspaces/tasteby/frontend", + script: "npm", + args: "run dev", // ⚠️ 절대 standalone으로 바꾸지 말 것! + }, + ], +}; +``` + +### 백엔드 start.sh + +```bash +#!/bin/bash +export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home" +export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH" +set -a +source /Users/joungmin/workspaces/tasteby/backend/.env # 환경변수 로드 +set +a +exec ./gradlew bootRun +``` + +### 코드 수정 후 반영 방법 + +```bash +# 프론트엔드: npm run dev라서 코드 수정 시 자동 Hot Reload (재시작 불필요) + +# 백엔드: 코드 수정 후 재시작 필요 +pm2 restart tasteby-api + +# 전체 재시작 +pm2 restart tasteby-api tasteby-web + +# PM2 상태 확인 +pm2 status + +# 로그 확인 +pm2 logs tasteby-api --lines 50 +pm2 logs tasteby-web --lines 50 +``` + +### 주의사항 + +- `tasteby-web`은 반드시 `npm run dev`로 실행 (dev server) + - standalone 모드(`node .next/standalone/server.js`)로 바꾸면 static/public 파일을 못 찾아서 404 발생 + - standalone은 prod(Docker/K8s) 전용 +- dev 포트: 프론트 3001, 백엔드 8000 (3000은 Gitea가 사용 중) +- 환경변수는 `backend/.env`에서 로드 + +--- + +## 2. Prod 환경 (www.tasteby.net) + +### 구조 + +``` +브라우저 → www.tasteby.net (HTTPS) + ↓ +OCI Network Load Balancer (217.142.131.194) + ↓ 80→NodePort:32530, 443→NodePort:31437 +Nginx Ingress Controller (K8s) + ├── /api/* → backend Service (:8000) + └── /* → frontend Service (:3001) +``` + +### 클러스터 정보 + +- **OKE 클러스터**: tasteby-cluster-prod +- **노드**: ARM64 × 2 (2 CPU / 8GB) +- **네임스페이스**: tasteby +- **K8s context**: `context-c6ap7ecrdeq` + +### Pod 구성 + +| Pod | Image | Port | 리소스 | +|-----|-------|------|--------| +| backend | `icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG` | 8000 | 500m~1 CPU, 768Mi~1536Mi | +| frontend | `icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG` | 3001 | 200m~500m CPU, 256Mi~512Mi | +| redis | `docker.io/library/redis:7-alpine` | 6379 | 100m~200m CPU, 128Mi~256Mi | + +### 배포 명령어 (deploy.sh) + +```bash +# 전체 배포 (백엔드 + 프론트엔드) +./deploy.sh "배포 메시지" + +# 백엔드만 배포 +./deploy.sh --backend-only "백엔드 수정 사항" + +# 프론트엔드만 배포 +./deploy.sh --frontend-only "프론트 수정 사항" + +# 드라이런 (실제 배포 없이 확인) +./deploy.sh --dry-run "테스트" +``` + +### deploy.sh 동작 순서 + +1. **버전 계산**: 최신 git tag에서 patch +1 (v0.1.9 → v0.1.10) +2. **Docker 빌드**: Colima로 `linux/arm64` 이미지 빌드 (로컬 Mac에서) + - 백엔드: `backend-java/Dockerfile` → multi-stage (JDK build → JRE runtime) + - 프론트: `frontend/Dockerfile` → multi-stage (node build → standalone runtime) +3. **OCIR Push**: `icn.ocir.io/idyhsdamac8c/tasteby/{backend,frontend}:TAG` + `:latest` +4. **K8s 배포**: `kubectl set image` → `kubectl rollout status` (롤링 업데이트) +5. **Git tag**: `vX.Y.Z` 태그 생성 후 origin push + +### Docker 빌드 상세 + +**백엔드 Dockerfile** (multi-stage): +```dockerfile +# Build: eclipse-temurin:21-jdk에서 gradlew bootJar +# Runtime: eclipse-temurin:21-jre에서 java -jar app.jar +# JVM 옵션: -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC +``` + +**프론트엔드 Dockerfile** (multi-stage): +```dockerfile +# Build: node:22-alpine에서 npm ci + npm run build +# Runtime: node:22-alpine에서 standalone 출력물 복사 + node server.js +# ⚠️ standalone 모드는 Docker(prod) 전용. .next/static과 public을 직접 복사해야 함 +``` + +### Ingress 설정 + +```yaml +# 주요 annotation +cert-manager.io/cluster-issuer: letsencrypt-prod # 자동 TLS 인증서 +nginx.ingress.kubernetes.io/ssl-redirect: "true" # HTTP → HTTPS 리다이렉트 +nginx.ingress.kubernetes.io/from-to-www-redirect: "true" # tasteby.net → www 리다이렉트 + +# 라우팅 +www.tasteby.net/api/* → backend:8000 +www.tasteby.net/* → frontend:3001 +``` + +### TLS 인증서 (cert-manager) + +- ClusterIssuer: `letsencrypt-prod` +- HTTP-01 challenge 방식 (포트 80 필수) +- Secret: `tasteby-tls` +- 인증서 상태 확인: `kubectl get certificate -n tasteby` + +### 운영 확인 명령어 + +```bash +# Pod 상태 +kubectl get pods -n tasteby + +# 로그 확인 +kubectl logs -f deployment/backend -n tasteby +kubectl logs -f deployment/frontend -n tasteby + +# 인증서 상태 +kubectl get certificate -n tasteby + +# Ingress 상태 +kubectl get ingress -n tasteby + +# 롤백 (이전 이미지로) +kubectl rollout undo deployment/backend -n tasteby +kubectl rollout undo deployment/frontend -n tasteby +``` + +--- + +## 3. OCI 네트워크 구성 + +### VCN 서브넷 + +| 서브넷 | CIDR | 용도 | +|--------|------|------| +| oke-k8sApiEndpoint-subnet | 10.0.0.0/28 | K8s API 서버 | +| oke-nodesubnet | 10.0.10.0/24 | 워커 노드 | +| oke-svclbsubnet | 10.0.20.0/24 | NLB (로드밸런서) | + +### 보안 리스트 (Security List) + +**LB 서브넷** (oke-svclbsubnet): +- Ingress: `0.0.0.0/0` → TCP 80, 443 +- Egress: `10.0.10.0/24` → all (노드 서브넷 전체 허용) + +**노드 서브넷** (oke-nodesubnet): +- Ingress: `10.0.10.0/24` → all (노드 간 통신) +- Ingress: `10.0.0.0/28` → TCP all (API 서버) +- Ingress: `0.0.0.0/0` → TCP 22 (SSH) +- Ingress: `10.0.20.0/24` → TCP 30000-32767 (LB → NodePort) +- Ingress: `0.0.0.0/0` → TCP 30000-32767 (NLB preserve-source 대응) + +> ⚠️ NLB `is-preserve-source: true` 설정으로 클라이언트 원본 IP가 보존됨. +> 따라서 노드 서브넷에 `0.0.0.0/0` → NodePort 인바운드가 반드시 필요. + +--- + +## 4. OCIR (컨테이너 레지스트리) 인증 + +```bash +# 로그인 +docker login icn.ocir.io -u idyhsdamac8c/oracleidentitycloudservice/ -p +``` + +- Registry: `icn.ocir.io/idyhsdamac8c/tasteby/` +- K8s imagePullSecret: `ocir-secret` (namespace: tasteby) + +--- + +## 5. 자주 하는 실수 / 주의사항 + +| 실수 | 원인 | 해결 | +|------|------|------| +| dev에서 static 404 | PM2를 standalone 모드로 바꿈 | `npm run dev`로 원복 | +| prod HTTPS 타임아웃 | NLB 보안 리스트 NodePort 불일치 | egress를 노드 서브넷 all 허용 | +| 인증서 발급 실패 | 포트 80 방화벽 차단 | LB 서브넷 ingress 80 + 노드 서브넷 NodePort 허용 | +| OKE에서 이미지 pull 실패 | CRI-O short name 불가 | `docker.io/library/` 풀네임 사용 | +| NLB 헬스체크 실패 | preserve-source + 노드 보안 리스트 | 0.0.0.0/0 → NodePort 인바운드 추가 | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0bcb9df..014e215 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@react-oauth/google": "^0.13.4", + "@tabler/icons-react": "^3.40.0", "@types/supercluster": "^7.1.3", "@vis.gl/react-google-maps": "^1.7.1", "next": "16.1.6", @@ -1257,6 +1258,32 @@ "tslib": "^2.8.0" } }, + "node_modules/@tabler/icons": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.40.0.tgz", + "integrity": "sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.40.0.tgz", + "integrity": "sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.40.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 257f253..85f845d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@react-oauth/google": "^0.13.4", + "@tabler/icons-react": "^3.40.0", "@types/supercluster": "^7.1.3", "@vis.gl/react-google-maps": "^1.7.1", "next": "16.1.6", diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0e0298f..39439fb 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -13,8 +13,9 @@ import RestaurantDetail from "@/components/RestaurantDetail"; import MyReviewsList from "@/components/MyReviewsList"; import BottomSheet from "@/components/BottomSheet"; import FilterSheet, { FilterOption } from "@/components/FilterSheet"; -import { getCuisineIcon } from "@/lib/cuisine-icons"; +import { getCuisineIcon, getTablerCuisineIcon } from "@/lib/cuisine-icons"; import Icon from "@/components/Icon"; +import * as TablerIcons from "@tabler/icons-react"; function useDragScroll() { const ref = useRef(null); @@ -201,6 +202,7 @@ export default function Home() { const geoApplied = useRef(false); const dd = useDragScroll(); const dm = useDragScroll(); + const dg = useDragScroll(); // genre card drag scroll const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]); const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]); @@ -990,117 +992,254 @@ export default function Home() { )} - {/* Row 2: Filters - always visible, 2 lines */} + {/* Row 2: Filters */}
- {/* Line 1: 음식 장르 + 가격 + 결과수 */} -
- - - {(cuisineFilter || priceFilter) && ( - - )} - {filteredRestaurants.length}개 -
- {/* Line 2: 나라 + 시 + 구 + 내위치 */} -
- - {countryFilter && cities.length > 0 && ( + {/* Home tab: 장르 가로 스크롤 */} + {mobileTab === "home" && ( +
+ {(() => { + const allCards = [ + { label: "전체", value: "", icon: "Bowl" }, + ...CUISINE_TAXONOMY.flatMap((g) => [ + { label: g.category, value: g.category, icon: getTablerCuisineIcon(g.category) }, + ...g.items.map((item) => ({ label: item, value: `${g.category}|${item}`, icon: getTablerCuisineIcon(`${g.category}|${item}`) })), + ]), + ]; + return allCards.map((card) => { + const isCategory = card.value === "" || !card.value.includes("|"); + const selected = card.value === "" + ? !cuisineFilter + : isCategory + ? cuisineFilter === card.value || cuisineFilter.startsWith(card.value + "|") + : cuisineFilter === card.value; + const TablerIcon = (TablerIcons as unknown as Record>)[`Icon${card.icon}`] || TablerIcons.IconBowl; + return ( + + ); + }); + })()} +
+ )} + {/* Home tab: 가격 + 지역 + 내위치 + 개수 */} + {mobileTab === "home" && ( +
- )} - {cityFilter && districts.length > 0 && ( - )} - {countryFilter && ( - - )} - + )} + {cityFilter && districts.length > 0 && ( + + )} + {(cuisineFilter || priceFilter || countryFilter) && ( + + )} + -
+ }} + className={`inline-flex items-center gap-0.5 rounded-full px-3 py-1.5 transition-colors ${ + boundsFilterOn + ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700 text-brand-600 dark:text-brand-400" + : "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400" + }`} + > + + {boundsFilterOn ? "내위치 ON" : "내위치"} + + {filteredRestaurants.length}개 +
+ )} + {/* List tab: 기존 필터 UI */} + {mobileTab === "list" && ( + <> +
+ + + {(cuisineFilter || priceFilter) && ( + + )} + {filteredRestaurants.length}개 +
+
+ + {countryFilter && cities.length > 0 && ( + + )} + {cityFilter && districts.length > 0 && ( + + )} + {countryFilter && ( + + )} + +
+ + )}
diff --git a/frontend/src/lib/cuisine-icons.ts b/frontend/src/lib/cuisine-icons.ts index ace7eac..791bcc2 100644 --- a/frontend/src/lib/cuisine-icons.ts +++ b/frontend/src/lib/cuisine-icons.ts @@ -1,49 +1,154 @@ /** - * Cuisine type → Material Symbols icon name mapping. + * Cuisine type → icon mapping. + * Material Symbols icon name for RestaurantList (existing usage). + * Tabler icon component name for genre card chips (home tab). + * * Works with "대분류|소분류" format (e.g. "한식|국밥/해장국"). */ +// ── Material Symbols (for RestaurantList etc.) ── + const CUISINE_ICON_MAP: Record = { "한식": "rice_bowl", "일식": "set_meal", - "중식": "ramen_dining", + "중식": "skillet", "양식": "dinner_dining", - "아시아": "ramen_dining", + "아시아": "restaurant", "기타": "flatware", }; -// Sub-category overrides for more specific icons const SUB_ICON_RULES: { keyword: string; icon: string }[] = [ - { keyword: "회/횟집", icon: "set_meal" }, - { keyword: "해산물", icon: "set_meal" }, + { keyword: "백반/한정식", icon: "rice_bowl" }, + { keyword: "국밥/해장국", icon: "soup_kitchen" }, + { keyword: "찌개/전골/탕", icon: "outdoor_grill" }, { keyword: "삼겹살/돼지구이", icon: "kebab_dining" }, - { keyword: "소고기/한우구이", icon: "kebab_dining" }, - { keyword: "곱창/막창", icon: "kebab_dining" }, + { keyword: "소고기/한우구이", icon: "local_fire_department" }, + { keyword: "곱창/막창", icon: "local_fire_department" }, { keyword: "닭/오리구이", icon: "takeout_dining" }, - { keyword: "스테이크", icon: "kebab_dining" }, + { keyword: "족발/보쌈", icon: "stockpot" }, + { keyword: "회/횟집", icon: "phishing" }, + { keyword: "해산물", icon: "set_meal" }, + { keyword: "분식", icon: "egg_alt" }, + { keyword: "면", icon: "ramen_dining" }, + { keyword: "죽/죽집", icon: "soup_kitchen" }, + { keyword: "순대/순대국", icon: "soup_kitchen" }, + { keyword: "장어/민물", icon: "phishing" }, + { keyword: "주점/포차", icon: "local_bar" }, + { keyword: "파인다이닝/코스", icon: "auto_awesome" }, + { keyword: "스시/오마카세", icon: "set_meal" }, + { keyword: "라멘", icon: "ramen_dining" }, + { keyword: "돈카츠", icon: "lunch_dining" }, + { keyword: "텐동/튀김", icon: "tapas" }, + { keyword: "이자카야", icon: "sake" }, + { keyword: "야키니쿠", icon: "kebab_dining" }, + { keyword: "카레", icon: "skillet" }, + { keyword: "소바/우동", icon: "ramen_dining" }, + { keyword: "중화요리", icon: "skillet" }, + { keyword: "마라/훠궈", icon: "outdoor_grill" }, + { keyword: "딤섬/만두", icon: "egg_alt" }, + { keyword: "양꼬치", icon: "kebab_dining" }, + { keyword: "파스타/이탈리안", icon: "dinner_dining" }, + { keyword: "스테이크", icon: "restaurant" }, { keyword: "햄버거", icon: "lunch_dining" }, { keyword: "피자", icon: "local_pizza" }, + { keyword: "프렌치", icon: "auto_awesome" }, + { keyword: "바베큐", icon: "outdoor_grill" }, + { keyword: "브런치", icon: "brunch_dining" }, + { keyword: "비건/샐러드", icon: "eco" }, + { keyword: "베트남", icon: "ramen_dining" }, + { keyword: "태국", icon: "restaurant" }, + { keyword: "인도/중동", icon: "skillet" }, + { keyword: "동남아기타", icon: "restaurant" }, + { keyword: "치킨", icon: "takeout_dining" }, { keyword: "카페/디저트", icon: "coffee" }, { keyword: "베이커리", icon: "bakery_dining" }, - { keyword: "치킨", icon: "takeout_dining" }, - { keyword: "주점/포차", icon: "local_bar" }, - { keyword: "이자카야", icon: "sake" }, - { keyword: "라멘", icon: "ramen_dining" }, - { keyword: "국밥/해장국", icon: "soup_kitchen" }, - { keyword: "분식", icon: "ramen_dining" }, + { keyword: "뷔페", icon: "brunch_dining" }, + { keyword: "퓨전", icon: "auto_awesome" }, ]; const DEFAULT_ICON = "flatware"; export function getCuisineIcon(cuisineType: string | null | undefined): string { if (!cuisineType) return DEFAULT_ICON; - - // Check sub-category first for (const rule of SUB_ICON_RULES) { if (cuisineType.includes(rule.keyword)) return rule.icon; } - - // Fall back to main category (prefix before |) const main = cuisineType.split("|")[0]; return CUISINE_ICON_MAP[main] || DEFAULT_ICON; } + +// ── Tabler Icons (for genre card chips) ── +// Returns Tabler icon component name (PascalCase without "Icon" prefix) + +const TABLER_CUISINE_MAP: Record = { + "한식": "BowlChopsticks", + "일식": "Fish", + "중식": "Soup", + "양식": "Pizza", + "아시아": "BowlSpoon", + "기타": "Cookie", +}; + +const TABLER_SUB_RULES: { keyword: string; icon: string }[] = [ + // 한식 + { keyword: "백반/한정식", icon: "BowlChopsticks" }, + { keyword: "국밥/해장국", icon: "Soup" }, + { keyword: "찌개/전골/탕", icon: "Cooker" }, + { keyword: "삼겹살/돼지구이", icon: "Meat" }, + { keyword: "소고기/한우구이", icon: "Grill" }, + { keyword: "곱창/막창", icon: "GrillFork" }, + { keyword: "닭/오리구이", icon: "Meat" }, + { keyword: "족발/보쌈", icon: "Meat" }, + { keyword: "회/횟집", icon: "Fish" }, + { keyword: "해산물", icon: "Fish" }, + { keyword: "분식", icon: "EggFried" }, + { keyword: "면", icon: "BowlChopsticks" }, + { keyword: "죽/죽집", icon: "BowlSpoon" }, + { keyword: "순대/순대국", icon: "Soup" }, + { keyword: "장어/민물", icon: "Fish" }, + { keyword: "주점/포차", icon: "Beer" }, + { keyword: "파인다이닝/코스", icon: "GlassChampagne" }, + // 일식 + { keyword: "스시/오마카세", icon: "Fish" }, + { keyword: "라멘", icon: "Soup" }, + { keyword: "돈카츠", icon: "Meat" }, + { keyword: "텐동/튀김", icon: "EggFried" }, + { keyword: "이자카야", icon: "GlassCocktail" }, + { keyword: "야키니쿠", icon: "Grill" }, + { keyword: "카레", icon: "BowlSpoon" }, + { keyword: "소바/우동", icon: "BowlChopsticks" }, + // 중식 + { keyword: "중화요리", icon: "Soup" }, + { keyword: "마라/훠궈", icon: "Pepper" }, + { keyword: "딤섬/만두", icon: "Egg" }, + { keyword: "양꼬치", icon: "Grill" }, + // 양식 + { keyword: "파스타/이탈리안", icon: "BowlSpoon" }, + { keyword: "스테이크", icon: "Meat" }, + { keyword: "햄버거", icon: "Burger" }, + { keyword: "피자", icon: "Pizza" }, + { keyword: "프렌치", icon: "GlassChampagne" }, + { keyword: "바베큐", icon: "GrillSpatula" }, + { keyword: "브런치", icon: "EggFried" }, + { keyword: "비건/샐러드", icon: "Salad" }, + // 아시아 + { keyword: "베트남", icon: "BowlChopsticks" }, + { keyword: "태국", icon: "Pepper" }, + { keyword: "인도/중동", icon: "BowlSpoon" }, + { keyword: "동남아기타", icon: "BowlSpoon" }, + // 기타 + { keyword: "치킨", icon: "Meat" }, + { keyword: "카페/디저트", icon: "Coffee" }, + { keyword: "베이커리", icon: "Bread" }, + { keyword: "뷔페", icon: "Cheese" }, + { keyword: "퓨전", icon: "Cookie" }, +]; + +export function getTablerCuisineIcon(cuisineType: string | null | undefined): string { + if (!cuisineType) return "Bowl"; + for (const rule of TABLER_SUB_RULES) { + if (cuisineType.includes(rule.keyword)) return rule.icon; + } + const main = cuisineType.split("|")[0]; + return TABLER_CUISINE_MAP[main] || "Bowl"; +}