Compare commits
6 Commits
v0.1.9
...
f2861b6b79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2861b6b79 | ||
|
|
dda0da52c4 | ||
|
|
18776b9b4b | ||
|
|
177532e6e7 | ||
|
|
64d58cb553 | ||
|
|
a766a74f20 |
262
docs/deployment-guide.md
Normal file
262
docs/deployment-guide.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# Tasteby 배포 가이드
|
||||||
|
|
||||||
|
## 환경 요약
|
||||||
|
|
||||||
|
| 항목 | Dev (개발) | Prod (운영) |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| URL | dev.tasteby.net | www.tasteby.net |
|
||||||
|
| 호스트 | 로컬 Mac mini | OKE (Oracle Kubernetes Engine) |
|
||||||
|
| 프로세스 관리 | PM2 | Kubernetes Deployment |
|
||||||
|
| 프론트엔드 실행 | `npm run dev` (Next.js dev server) | `node server.js` (standalone 빌드) |
|
||||||
|
| 백엔드 실행 | `./gradlew bootRun` | `java -jar app.jar` (bootJar 빌드) |
|
||||||
|
| Redis | 로컬 Redis 서버 | K8s Pod (redis:7-alpine) |
|
||||||
|
| TLS | Nginx(192.168.0.147) + Certbot | cert-manager + Let's Encrypt |
|
||||||
|
| 리버스 프록시 | Nginx (192.168.0.147 → 192.168.0.208) | Nginx Ingress Controller (K8s) |
|
||||||
|
| 도메인 DNS | dev.tasteby.net → Mac mini IP | www.tasteby.net → OCI NLB 217.142.131.194 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dev 환경 (dev.tasteby.net)
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 → dev.tasteby.net (HTTPS)
|
||||||
|
↓
|
||||||
|
Nginx (192.168.0.147) — Certbot Let's Encrypt TLS
|
||||||
|
├── /api/* → proxy_pass http://192.168.0.208:8000 (tasteby-api)
|
||||||
|
└── /* → proxy_pass http://192.168.0.208:3001 (tasteby-web)
|
||||||
|
↓
|
||||||
|
Mac mini (192.168.0.208) — PM2 프로세스 매니저
|
||||||
|
├── tasteby-api → ./gradlew bootRun (:8000)
|
||||||
|
└── tasteby-web → npm run dev (:3001)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **192.168.0.147**: Nginx 리버스 프록시 서버 (TLS 종료, Certbot 자동 갱신)
|
||||||
|
- **192.168.0.208**: Mac mini (실제 앱 서버, PM2 관리)
|
||||||
|
|
||||||
|
### PM2 프로세스 구성 (ecosystem.config.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "tasteby-api",
|
||||||
|
cwd: "/Users/joungmin/workspaces/tasteby/backend-java",
|
||||||
|
script: "./start.sh", // gradlew bootRun 실행
|
||||||
|
interpreter: "/bin/bash",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tasteby-web",
|
||||||
|
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
|
||||||
|
script: "npm",
|
||||||
|
args: "run dev", // ⚠️ 절대 standalone으로 바꾸지 말 것!
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백엔드 start.sh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
|
||||||
|
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
|
||||||
|
set -a
|
||||||
|
source /Users/joungmin/workspaces/tasteby/backend/.env # 환경변수 로드
|
||||||
|
set +a
|
||||||
|
exec ./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
### 코드 수정 후 반영 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프론트엔드: npm run dev라서 코드 수정 시 자동 Hot Reload (재시작 불필요)
|
||||||
|
|
||||||
|
# 백엔드: 코드 수정 후 재시작 필요
|
||||||
|
pm2 restart tasteby-api
|
||||||
|
|
||||||
|
# 전체 재시작
|
||||||
|
pm2 restart tasteby-api tasteby-web
|
||||||
|
|
||||||
|
# PM2 상태 확인
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
pm2 logs tasteby-api --lines 50
|
||||||
|
pm2 logs tasteby-web --lines 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
|
||||||
|
- `tasteby-web`은 반드시 `npm run dev`로 실행 (dev server)
|
||||||
|
- standalone 모드(`node .next/standalone/server.js`)로 바꾸면 static/public 파일을 못 찾아서 404 발생
|
||||||
|
- standalone은 prod(Docker/K8s) 전용
|
||||||
|
- dev 포트: 프론트 3001, 백엔드 8000 (3000은 Gitea가 사용 중)
|
||||||
|
- 환경변수는 `backend/.env`에서 로드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Prod 환경 (www.tasteby.net)
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 → www.tasteby.net (HTTPS)
|
||||||
|
↓
|
||||||
|
OCI Network Load Balancer (217.142.131.194)
|
||||||
|
↓ 80→NodePort:32530, 443→NodePort:31437
|
||||||
|
Nginx Ingress Controller (K8s)
|
||||||
|
├── /api/* → backend Service (:8000)
|
||||||
|
└── /* → frontend Service (:3001)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 클러스터 정보
|
||||||
|
|
||||||
|
- **OKE 클러스터**: tasteby-cluster-prod
|
||||||
|
- **노드**: ARM64 × 2 (2 CPU / 8GB)
|
||||||
|
- **네임스페이스**: tasteby
|
||||||
|
- **K8s context**: `context-c6ap7ecrdeq`
|
||||||
|
|
||||||
|
### Pod 구성
|
||||||
|
|
||||||
|
| Pod | Image | Port | 리소스 |
|
||||||
|
|-----|-------|------|--------|
|
||||||
|
| backend | `icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG` | 8000 | 500m~1 CPU, 768Mi~1536Mi |
|
||||||
|
| frontend | `icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG` | 3001 | 200m~500m CPU, 256Mi~512Mi |
|
||||||
|
| redis | `docker.io/library/redis:7-alpine` | 6379 | 100m~200m CPU, 128Mi~256Mi |
|
||||||
|
|
||||||
|
### 배포 명령어 (deploy.sh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 전체 배포 (백엔드 + 프론트엔드)
|
||||||
|
./deploy.sh "배포 메시지"
|
||||||
|
|
||||||
|
# 백엔드만 배포
|
||||||
|
./deploy.sh --backend-only "백엔드 수정 사항"
|
||||||
|
|
||||||
|
# 프론트엔드만 배포
|
||||||
|
./deploy.sh --frontend-only "프론트 수정 사항"
|
||||||
|
|
||||||
|
# 드라이런 (실제 배포 없이 확인)
|
||||||
|
./deploy.sh --dry-run "테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
### deploy.sh 동작 순서
|
||||||
|
|
||||||
|
1. **버전 계산**: 최신 git tag에서 patch +1 (v0.1.9 → v0.1.10)
|
||||||
|
2. **Docker 빌드**: Colima로 `linux/arm64` 이미지 빌드 (로컬 Mac에서)
|
||||||
|
- 백엔드: `backend-java/Dockerfile` → multi-stage (JDK build → JRE runtime)
|
||||||
|
- 프론트: `frontend/Dockerfile` → multi-stage (node build → standalone runtime)
|
||||||
|
3. **OCIR Push**: `icn.ocir.io/idyhsdamac8c/tasteby/{backend,frontend}:TAG` + `:latest`
|
||||||
|
4. **K8s 배포**: `kubectl set image` → `kubectl rollout status` (롤링 업데이트)
|
||||||
|
5. **Git tag**: `vX.Y.Z` 태그 생성 후 origin push
|
||||||
|
|
||||||
|
### Docker 빌드 상세
|
||||||
|
|
||||||
|
**백엔드 Dockerfile** (multi-stage):
|
||||||
|
```dockerfile
|
||||||
|
# Build: eclipse-temurin:21-jdk에서 gradlew bootJar
|
||||||
|
# Runtime: eclipse-temurin:21-jre에서 java -jar app.jar
|
||||||
|
# JVM 옵션: -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트엔드 Dockerfile** (multi-stage):
|
||||||
|
```dockerfile
|
||||||
|
# Build: node:22-alpine에서 npm ci + npm run build
|
||||||
|
# Runtime: node:22-alpine에서 standalone 출력물 복사 + node server.js
|
||||||
|
# ⚠️ standalone 모드는 Docker(prod) 전용. .next/static과 public을 직접 복사해야 함
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress 설정
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 주요 annotation
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod # 자동 TLS 인증서
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true" # HTTP → HTTPS 리다이렉트
|
||||||
|
nginx.ingress.kubernetes.io/from-to-www-redirect: "true" # tasteby.net → www 리다이렉트
|
||||||
|
|
||||||
|
# 라우팅
|
||||||
|
www.tasteby.net/api/* → backend:8000
|
||||||
|
www.tasteby.net/* → frontend:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### TLS 인증서 (cert-manager)
|
||||||
|
|
||||||
|
- ClusterIssuer: `letsencrypt-prod`
|
||||||
|
- HTTP-01 challenge 방식 (포트 80 필수)
|
||||||
|
- Secret: `tasteby-tls`
|
||||||
|
- 인증서 상태 확인: `kubectl get certificate -n tasteby`
|
||||||
|
|
||||||
|
### 운영 확인 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pod 상태
|
||||||
|
kubectl get pods -n tasteby
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
kubectl logs -f deployment/backend -n tasteby
|
||||||
|
kubectl logs -f deployment/frontend -n tasteby
|
||||||
|
|
||||||
|
# 인증서 상태
|
||||||
|
kubectl get certificate -n tasteby
|
||||||
|
|
||||||
|
# Ingress 상태
|
||||||
|
kubectl get ingress -n tasteby
|
||||||
|
|
||||||
|
# 롤백 (이전 이미지로)
|
||||||
|
kubectl rollout undo deployment/backend -n tasteby
|
||||||
|
kubectl rollout undo deployment/frontend -n tasteby
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. OCI 네트워크 구성
|
||||||
|
|
||||||
|
### VCN 서브넷
|
||||||
|
|
||||||
|
| 서브넷 | CIDR | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| oke-k8sApiEndpoint-subnet | 10.0.0.0/28 | K8s API 서버 |
|
||||||
|
| oke-nodesubnet | 10.0.10.0/24 | 워커 노드 |
|
||||||
|
| oke-svclbsubnet | 10.0.20.0/24 | NLB (로드밸런서) |
|
||||||
|
|
||||||
|
### 보안 리스트 (Security List)
|
||||||
|
|
||||||
|
**LB 서브넷** (oke-svclbsubnet):
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 80, 443
|
||||||
|
- Egress: `10.0.10.0/24` → all (노드 서브넷 전체 허용)
|
||||||
|
|
||||||
|
**노드 서브넷** (oke-nodesubnet):
|
||||||
|
- Ingress: `10.0.10.0/24` → all (노드 간 통신)
|
||||||
|
- Ingress: `10.0.0.0/28` → TCP all (API 서버)
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 22 (SSH)
|
||||||
|
- Ingress: `10.0.20.0/24` → TCP 30000-32767 (LB → NodePort)
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 30000-32767 (NLB preserve-source 대응)
|
||||||
|
|
||||||
|
> ⚠️ NLB `is-preserve-source: true` 설정으로 클라이언트 원본 IP가 보존됨.
|
||||||
|
> 따라서 노드 서브넷에 `0.0.0.0/0` → NodePort 인바운드가 반드시 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. OCIR (컨테이너 레지스트리) 인증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로그인
|
||||||
|
docker login icn.ocir.io -u idyhsdamac8c/oracleidentitycloudservice/<email> -p <auth-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Registry: `icn.ocir.io/idyhsdamac8c/tasteby/`
|
||||||
|
- K8s imagePullSecret: `ocir-secret` (namespace: tasteby)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 자주 하는 실수 / 주의사항
|
||||||
|
|
||||||
|
| 실수 | 원인 | 해결 |
|
||||||
|
|------|------|------|
|
||||||
|
| dev에서 static 404 | PM2를 standalone 모드로 바꿈 | `npm run dev`로 원복 |
|
||||||
|
| prod HTTPS 타임아웃 | NLB 보안 리스트 NodePort 불일치 | egress를 노드 서브넷 all 허용 |
|
||||||
|
| 인증서 발급 실패 | 포트 80 방화벽 차단 | LB 서브넷 ingress 80 + 노드 서브넷 NodePort 허용 |
|
||||||
|
| OKE에서 이미지 pull 실패 | CRI-O short name 불가 | `docker.io/library/` 풀네임 사용 |
|
||||||
|
| NLB 헬스체크 실패 | preserve-source + 노드 보안 리스트 | 0.0.0.0/0 → NodePort 인바운드 추가 |
|
||||||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"@types/supercluster": "^7.1.3",
|
"@types/supercluster": "^7.1.3",
|
||||||
"@vis.gl/react-google-maps": "^1.7.1",
|
"@vis.gl/react-google-maps": "^1.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -1257,6 +1258,32 @@
|
|||||||
"tslib": "^2.8.0"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"@types/supercluster": "^7.1.3",
|
"@types/supercluster": "^7.1.3",
|
||||||
"@vis.gl/react-google-maps": "^1.7.1",
|
"@vis.gl/react-google-maps": "^1.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
|||||||
@@ -101,3 +101,12 @@ html, body, #__next {
|
|||||||
.safe-area-bottom {
|
.safe-area-bottom {
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Filter sheet slide-up animation */
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import RestaurantList from "@/components/RestaurantList";
|
|||||||
import RestaurantDetail from "@/components/RestaurantDetail";
|
import RestaurantDetail from "@/components/RestaurantDetail";
|
||||||
import MyReviewsList from "@/components/MyReviewsList";
|
import MyReviewsList from "@/components/MyReviewsList";
|
||||||
import BottomSheet from "@/components/BottomSheet";
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
|
||||||
|
import { getCuisineIcon, getTablerCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import * as TablerIcons from "@tabler/icons-react";
|
||||||
|
|
||||||
function useDragScroll() {
|
function useDragScroll() {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -187,6 +189,7 @@ export default function Home() {
|
|||||||
const [countryFilter, setCountryFilter] = useState("");
|
const [countryFilter, setCountryFilter] = useState("");
|
||||||
const [cityFilter, setCityFilter] = useState("");
|
const [cityFilter, setCityFilter] = useState("");
|
||||||
const [districtFilter, setDistrictFilter] = useState("");
|
const [districtFilter, setDistrictFilter] = useState("");
|
||||||
|
const [openSheet, setOpenSheet] = useState<"cuisine" | "price" | "country" | "city" | "district" | null>(null);
|
||||||
const [regionFlyTo, setRegionFlyTo] = useState<FlyTo | null>(null);
|
const [regionFlyTo, setRegionFlyTo] = useState<FlyTo | null>(null);
|
||||||
const [showFavorites, setShowFavorites] = useState(false);
|
const [showFavorites, setShowFavorites] = useState(false);
|
||||||
const [showMyReviews, setShowMyReviews] = useState(false);
|
const [showMyReviews, setShowMyReviews] = useState(false);
|
||||||
@@ -199,6 +202,7 @@ export default function Home() {
|
|||||||
const geoApplied = useRef(false);
|
const geoApplied = useRef(false);
|
||||||
const dd = useDragScroll();
|
const dd = useDragScroll();
|
||||||
const dm = useDragScroll();
|
const dm = useDragScroll();
|
||||||
|
const dg = useDragScroll(); // genre card drag scroll
|
||||||
|
|
||||||
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
||||||
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]);
|
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]);
|
||||||
@@ -235,9 +239,16 @@ export default function Home() {
|
|||||||
if (cityFilter && parsed.city !== cityFilter) return false;
|
if (cityFilter && parsed.city !== cityFilter) return false;
|
||||||
if (districtFilter && parsed.district !== districtFilter) return false;
|
if (districtFilter && parsed.district !== districtFilter) return false;
|
||||||
}
|
}
|
||||||
if (boundsFilterOn && mapBounds) {
|
if (boundsFilterOn) {
|
||||||
if (r.latitude < mapBounds.south || r.latitude > mapBounds.north) return false;
|
if (mapBounds) {
|
||||||
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
|
if (r.latitude < mapBounds.south || r.latitude > mapBounds.north) return false;
|
||||||
|
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
|
||||||
|
} else {
|
||||||
|
// 지도가 없으면 (모바일 리스트 탭 등) userLoc 기준 ~2km 반경
|
||||||
|
const dlat = r.latitude - userLoc.lat;
|
||||||
|
const dlng = r.longitude - userLoc.lng;
|
||||||
|
if (dlat * dlat + dlng * dlng > 0.0013) return false; // ~4km
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
@@ -247,6 +258,34 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||||
|
|
||||||
|
// FilterSheet option builders
|
||||||
|
const cuisineOptions = useMemo<FilterOption[]>(() => {
|
||||||
|
const opts: FilterOption[] = [];
|
||||||
|
for (const g of CUISINE_TAXONOMY) {
|
||||||
|
opts.push({ label: `${g.category} 전체`, value: g.category, group: g.category });
|
||||||
|
for (const item of g.items) {
|
||||||
|
opts.push({ label: item, value: `${g.category}|${item}`, group: g.category });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const priceOptions = useMemo<FilterOption[]>(() =>
|
||||||
|
PRICE_GROUPS.map((g) => ({ label: g.label, value: g.label })),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
const countryOptions = useMemo<FilterOption[]>(() =>
|
||||||
|
countries.map((c) => ({ label: c, value: c })),
|
||||||
|
[countries]);
|
||||||
|
|
||||||
|
const cityOptions = useMemo<FilterOption[]>(() =>
|
||||||
|
cities.map((c) => ({ label: c, value: c })),
|
||||||
|
[cities]);
|
||||||
|
|
||||||
|
const districtOptions = useMemo<FilterOption[]>(() =>
|
||||||
|
districts.map((d) => ({ label: d, value: d })),
|
||||||
|
[districts]);
|
||||||
|
|
||||||
// Set desktop default to map mode on mount + get user location
|
// Set desktop default to map mode on mount + get user location
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.innerWidth >= 768) setViewMode("map");
|
if (window.innerWidth >= 768) setViewMode("map");
|
||||||
@@ -344,7 +383,7 @@ export default function Home() {
|
|||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||||
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 16 });
|
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 17 });
|
||||||
},
|
},
|
||||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
|
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
|
||||||
{ timeout: 5000 },
|
{ timeout: 5000 },
|
||||||
@@ -848,7 +887,7 @@ export default function Home() {
|
|||||||
setDistrictFilter("");
|
setDistrictFilter("");
|
||||||
if (navigator.geolocation) {
|
if (navigator.geolocation) {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
|
||||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||||
{ timeout: 5000 },
|
{ timeout: 5000 },
|
||||||
);
|
);
|
||||||
@@ -953,126 +992,254 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row 2: Filters - always visible, 2 lines */}
|
{/* Row 2: Filters */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
|
{/* Home tab: 장르 가로 스크롤 */}
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
{mobileTab === "home" && (
|
||||||
<select
|
<div ref={dg.ref} onMouseDown={dg.onMouseDown} onMouseMove={dg.onMouseMove} onMouseUp={dg.onMouseUp} onMouseLeave={dg.onMouseLeave} onClickCapture={dg.onClickCapture} style={dg.style} className="flex gap-2 overflow-x-auto scrollbar-hide -mx-1 px-1 pb-1 select-none">
|
||||||
value={cuisineFilter}
|
{(() => {
|
||||||
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
const allCards = [
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
{ label: "전체", value: "", icon: "Bowl" },
|
||||||
cuisineFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
...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}`) })),
|
||||||
<option value="">🍽 장르</option>
|
]),
|
||||||
{CUISINE_TAXONOMY.map((g) => (
|
];
|
||||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
return allCards.map((card) => {
|
||||||
<option value={g.category}>{g.category} 전체</option>
|
const isCategory = card.value === "" || !card.value.includes("|");
|
||||||
{g.items.map((item) => (
|
const selected = card.value === ""
|
||||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
? !cuisineFilter
|
||||||
{item}
|
: isCategory
|
||||||
</option>
|
? cuisineFilter === card.value || cuisineFilter.startsWith(card.value + "|")
|
||||||
))}
|
: cuisineFilter === card.value;
|
||||||
</optgroup>
|
const TablerIcon = (TablerIcons as unknown as Record<string, React.ComponentType<{ size?: number; stroke?: number; className?: string }>>)[`Icon${card.icon}`] || TablerIcons.IconBowl;
|
||||||
))}
|
return (
|
||||||
</select>
|
<button
|
||||||
<select
|
key={card.value || "__all__"}
|
||||||
value={priceFilter}
|
onClick={() => {
|
||||||
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
if (card.value === "") { setCuisineFilter(""); }
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
else if (cuisineFilter === card.value) { setCuisineFilter(""); }
|
||||||
priceFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
else { setCuisineFilter(card.value); setBoundsFilterOn(false); }
|
||||||
}`}
|
}}
|
||||||
>
|
className={`shrink-0 flex flex-col items-center gap-1 rounded-xl px-2.5 py-2 min-w-[56px] transition-all ${
|
||||||
<option value="">💰 가격</option>
|
selected
|
||||||
{PRICE_GROUPS.map((g) => (
|
? "bg-brand-500 text-white shadow-sm"
|
||||||
<option key={g.label} value={g.label}>{g.label}</option>
|
: isCategory
|
||||||
))}
|
? "bg-brand-50 border border-brand-200 text-brand-700"
|
||||||
</select>
|
: "bg-white border border-gray-100 text-gray-500"
|
||||||
{(cuisineFilter || priceFilter) && (
|
}`}
|
||||||
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
>
|
||||||
<Icon name="close" size={14} />
|
<TablerIcon size={22} stroke={1.5} className={selected ? "text-white" : isCategory ? "text-brand-500" : "text-gray-400"} />
|
||||||
</button>
|
<span className={`text-[11px] whitespace-nowrap ${isCategory ? "font-semibold" : "font-medium"}`}>{card.label}</span>
|
||||||
)}
|
</button>
|
||||||
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}개</span>
|
);
|
||||||
</div>
|
});
|
||||||
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
|
})()}
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
</div>
|
||||||
<select
|
)}
|
||||||
value={countryFilter}
|
{/* Home tab: 가격 + 지역 + 내위치 + 개수 */}
|
||||||
onChange={(e) => handleCountryChange(e.target.value)}
|
{mobileTab === "home" && (
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
<div className="flex items-center gap-1.5 text-xs flex-wrap">
|
||||||
countryFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
<button
|
||||||
}`}
|
onClick={() => setOpenSheet("price")}
|
||||||
>
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
<option value="">🌍 나라</option>
|
priceFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
{countries.map((c) => (
|
|
||||||
<option key={c} value={c}>{c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{countryFilter && cities.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={cityFilter}
|
|
||||||
onChange={(e) => handleCityChange(e.target.value)}
|
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
|
||||||
cityFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">시/도</option>
|
<Icon name="payments" size={14} className={`mr-1 ${priceFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||||
{cities.map((c) => (
|
<span className={priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
<option key={c} value={c}>{c}</option>
|
{priceFilter || "가격"}
|
||||||
))}
|
</span>
|
||||||
</select>
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
)}
|
</button>
|
||||||
{cityFilter && districts.length > 0 && (
|
<button
|
||||||
<select
|
onClick={() => setOpenSheet("country")}
|
||||||
value={districtFilter}
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
countryFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
|
||||||
districtFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">구/군</option>
|
<Icon name="public" size={14} className={`mr-1 ${countryFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||||
{districts.map((d) => (
|
<span className={countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
<option key={d} value={d}>{d}</option>
|
{countryFilter || "나라"}
|
||||||
))}
|
</span>
|
||||||
</select>
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
)}
|
|
||||||
{countryFilter && (
|
|
||||||
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
|
|
||||||
<Icon name="close" size={14} />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
{countryFilter && cities.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setOpenSheet("city")}
|
||||||
const next = !boundsFilterOn;
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
setBoundsFilterOn(next);
|
cityFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
if (next) {
|
}`}
|
||||||
setCuisineFilter("");
|
>
|
||||||
setPriceFilter("");
|
<span className={cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
setCountryFilter("");
|
{cityFilter || "시/도"}
|
||||||
setCityFilter("");
|
</span>
|
||||||
setDistrictFilter("");
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
if (navigator.geolocation) {
|
</button>
|
||||||
navigator.geolocation.getCurrentPosition(
|
)}
|
||||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
{cityFilter && districts.length > 0 && (
|
||||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
<button
|
||||||
{ timeout: 5000 },
|
onClick={() => setOpenSheet("district")}
|
||||||
);
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
} else {
|
districtFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
}`}
|
||||||
|
>
|
||||||
|
<span className={districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
|
{districtFilter || "구/군"}
|
||||||
|
</span>
|
||||||
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(cuisineFilter || priceFilter || countryFilter) && (
|
||||||
|
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = !boundsFilterOn;
|
||||||
|
setBoundsFilterOn(next);
|
||||||
|
if (next) {
|
||||||
|
setCuisineFilter("");
|
||||||
|
setPriceFilter("");
|
||||||
|
setCountryFilter("");
|
||||||
|
setCityFilter("");
|
||||||
|
setDistrictFilter("");
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
|
||||||
|
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
className={`inline-flex items-center gap-0.5 rounded-full px-3 py-1.5 transition-colors ${
|
||||||
className={`flex items-center gap-0.5 rounded-lg px-2 py-1 border transition-colors ${
|
boundsFilterOn
|
||||||
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-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
|
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<Icon name="location_on" size={14} />
|
||||||
<Icon name="location_on" size={12} />
|
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
||||||
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
</button>
|
||||||
</button>
|
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{/* List tab: 기존 필터 UI */}
|
||||||
|
{mobileTab === "list" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenSheet("cuisine")}
|
||||||
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
|
cuisineFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name="restaurant" size={14} className={`mr-1 ${cuisineFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||||
|
<span className={cuisineFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
|
{cuisineFilter ? (cuisineFilter.includes("|") ? cuisineFilter.split("|")[1] : cuisineFilter) : "장르"}
|
||||||
|
</span>
|
||||||
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenSheet("price")}
|
||||||
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
|
priceFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name="payments" size={14} className={`mr-1 ${priceFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||||
|
<span className={priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
|
{priceFilter || "가격"}
|
||||||
|
</span>
|
||||||
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
{(cuisineFilter || priceFilter) && (
|
||||||
|
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}개</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenSheet("country")}
|
||||||
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
|
countryFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name="public" size={14} className={`mr-1 ${countryFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||||
|
<span className={countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
|
{countryFilter || "나라"}
|
||||||
|
</span>
|
||||||
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
{countryFilter && cities.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenSheet("city")}
|
||||||
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
|
cityFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
|
{cityFilter || "시/도"}
|
||||||
|
</span>
|
||||||
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{cityFilter && districts.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenSheet("district")}
|
||||||
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
|
districtFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
|
{districtFilter || "구/군"}
|
||||||
|
</span>
|
||||||
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{countryFilter && (
|
||||||
|
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = !boundsFilterOn;
|
||||||
|
setBoundsFilterOn(next);
|
||||||
|
if (next) {
|
||||||
|
setCuisineFilter("");
|
||||||
|
setPriceFilter("");
|
||||||
|
setCountryFilter("");
|
||||||
|
setCityFilter("");
|
||||||
|
setDistrictFilter("");
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
|
||||||
|
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center gap-0.5 rounded-full px-3 py-1.5 transition-colors ${
|
||||||
|
boundsFilterOn
|
||||||
|
? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700 text-brand-600 dark:text-brand-400"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name="location_on" size={14} />
|
||||||
|
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1285,6 +1452,47 @@ export default function Home() {
|
|||||||
SDJ Labs Co., Ltd.
|
SDJ Labs Co., Ltd.
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
{/* Mobile Filter Sheets */}
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "cuisine"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="음식 장르"
|
||||||
|
options={cuisineOptions}
|
||||||
|
value={cuisineFilter}
|
||||||
|
onChange={(v) => { setCuisineFilter(v); if (v) setBoundsFilterOn(false); }}
|
||||||
|
/>
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "price"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="가격대"
|
||||||
|
options={priceOptions}
|
||||||
|
value={priceFilter}
|
||||||
|
onChange={(v) => { setPriceFilter(v); if (v) setBoundsFilterOn(false); }}
|
||||||
|
/>
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "country"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="나라"
|
||||||
|
options={countryOptions}
|
||||||
|
value={countryFilter}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
/>
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "city"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="시/도"
|
||||||
|
options={cityOptions}
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={handleCityChange}
|
||||||
|
/>
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "district"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="구/군"
|
||||||
|
options={districtOptions}
|
||||||
|
value={districtFilter}
|
||||||
|
onChange={handleDistrictChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
112
frontend/src/components/FilterSheet.tsx
Normal file
112
frontend/src/components/FilterSheet.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
options: FilterOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Group options by group field
|
||||||
|
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
|
||||||
|
const key = opt.group || "";
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(opt);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const groups = Object.keys(grouped);
|
||||||
|
|
||||||
|
const handleSelect = (v: string) => {
|
||||||
|
onChange(v);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] bg-black/30 md:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet */}
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
|
||||||
|
>
|
||||||
|
{/* Handle */}
|
||||||
|
<div className="flex justify-center pt-2 pb-1 shrink-0">
|
||||||
|
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
||||||
|
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
|
||||||
|
<Icon name="close" size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div className="flex-1 overflow-y-auto overscroll-contain pb-safe">
|
||||||
|
{/* 전체(초기화) */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect("")}
|
||||||
|
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 ${
|
||||||
|
!value ? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20" : "text-gray-700 dark:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-[15px]">전체</span>
|
||||||
|
{!value && <Icon name="check" size={18} className="text-brand-500" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group}>
|
||||||
|
{group && (
|
||||||
|
<div className="px-4 py-2.5 text-xs font-semibold text-gray-400 dark:text-gray-500 tracking-wider bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
|
{group}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{grouped[group].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 active:bg-gray-100 dark:active:bg-gray-800 ${
|
||||||
|
value === opt.value
|
||||||
|
? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20"
|
||||||
|
: "text-gray-700 dark:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-[15px]">{opt.label}</span>
|
||||||
|
{value === opt.value && <Icon name="check" size={18} className="text-brand-500" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,28 +44,44 @@ export default function RestaurantList({
|
|||||||
: "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
|
: "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
{/* 1줄: 식당명 + 지역 + 별점 (전체 폭) */}
|
||||||
<h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100">
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
|
<h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100 shrink-0">
|
||||||
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
||||||
{r.name}
|
{r.name}
|
||||||
</h4>
|
</h4>
|
||||||
|
{r.region && (
|
||||||
|
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
|
||||||
|
)}
|
||||||
{r.rating && (
|
{r.rating && (
|
||||||
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">
|
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">★ {r.rating}</span>
|
||||||
★ {r.rating}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
|
{/* 2줄: 종류/가격(왼) + 유튜브채널(우) */}
|
||||||
{r.cuisine_type && (
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.cuisine_type}</span>
|
<div className="flex gap-x-2 text-xs flex-1 min-w-0">
|
||||||
)}
|
{r.cuisine_type && (
|
||||||
{r.price_range && (
|
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 shrink-0">{r.cuisine_type}</span>
|
||||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.price_range}</span>
|
)}
|
||||||
|
{r.price_range && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 truncate min-w-0">{r.price_range}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.channels && r.channels.length > 0 && (
|
||||||
|
<div className="shrink-0 flex flex-wrap gap-1 justify-end">
|
||||||
|
{r.channels.map((ch) => (
|
||||||
|
<span
|
||||||
|
key={ch}
|
||||||
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium truncate max-w-[120px]"
|
||||||
|
>
|
||||||
|
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
||||||
|
<span className="truncate">{ch}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{r.region && (
|
{/* 3줄: 태그 (전체 폭) */}
|
||||||
<p className="mt-1 text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</p>
|
|
||||||
)}
|
|
||||||
{r.foods_mentioned && r.foods_mentioned.length > 0 && (
|
{r.foods_mentioned && r.foods_mentioned.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
{r.foods_mentioned.slice(0, 5).map((f, i) => (
|
{r.foods_mentioned.slice(0, 5).map((f, i) => (
|
||||||
@@ -81,19 +97,6 @@ export default function RestaurantList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{r.channels && r.channels.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{r.channels.map((ch) => (
|
|
||||||
<span
|
|
||||||
key={ch}
|
|
||||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
|
|
||||||
>
|
|
||||||
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
|
||||||
{ch}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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. "한식|국밥/해장국").
|
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ── Material Symbols (for RestaurantList etc.) ──
|
||||||
|
|
||||||
const CUISINE_ICON_MAP: Record<string, string> = {
|
const CUISINE_ICON_MAP: Record<string, string> = {
|
||||||
"한식": "rice_bowl",
|
"한식": "rice_bowl",
|
||||||
"일식": "set_meal",
|
"일식": "set_meal",
|
||||||
"중식": "ramen_dining",
|
"중식": "skillet",
|
||||||
"양식": "dinner_dining",
|
"양식": "dinner_dining",
|
||||||
"아시아": "ramen_dining",
|
"아시아": "restaurant",
|
||||||
"기타": "flatware",
|
"기타": "flatware",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sub-category overrides for more specific icons
|
|
||||||
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
||||||
{ keyword: "회/횟집", icon: "set_meal" },
|
{ keyword: "백반/한정식", icon: "rice_bowl" },
|
||||||
{ keyword: "해산물", icon: "set_meal" },
|
{ 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: "kebab_dining" },
|
{ keyword: "곱창/막창", icon: "local_fire_department" },
|
||||||
{ keyword: "닭/오리구이", icon: "takeout_dining" },
|
{ 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: "lunch_dining" },
|
||||||
{ keyword: "피자", icon: "local_pizza" },
|
{ 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: "coffee" },
|
||||||
{ keyword: "베이커리", icon: "bakery_dining" },
|
{ keyword: "베이커리", icon: "bakery_dining" },
|
||||||
{ keyword: "치킨", icon: "takeout_dining" },
|
{ keyword: "뷔페", icon: "brunch_dining" },
|
||||||
{ keyword: "주점/포차", icon: "local_bar" },
|
{ keyword: "퓨전", icon: "auto_awesome" },
|
||||||
{ keyword: "이자카야", icon: "sake" },
|
|
||||||
{ keyword: "라멘", icon: "ramen_dining" },
|
|
||||||
{ keyword: "국밥/해장국", icon: "soup_kitchen" },
|
|
||||||
{ keyword: "분식", icon: "ramen_dining" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_ICON = "flatware";
|
const DEFAULT_ICON = "flatware";
|
||||||
|
|
||||||
export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
||||||
if (!cuisineType) return DEFAULT_ICON;
|
if (!cuisineType) return DEFAULT_ICON;
|
||||||
|
|
||||||
// Check sub-category first
|
|
||||||
for (const rule of SUB_ICON_RULES) {
|
for (const rule of SUB_ICON_RULES) {
|
||||||
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to main category (prefix before |)
|
|
||||||
const main = cuisineType.split("|")[0];
|
const main = cuisineType.split("|")[0];
|
||||||
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
|
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tabler Icons (for genre card chips) ──
|
||||||
|
// Returns Tabler icon component name (PascalCase without "Icon" prefix)
|
||||||
|
|
||||||
|
const TABLER_CUISINE_MAP: Record<string, string> = {
|
||||||
|
"한식": "BowlChopsticks",
|
||||||
|
"일식": "Fish",
|
||||||
|
"중식": "Soup",
|
||||||
|
"양식": "Pizza",
|
||||||
|
"아시아": "BowlSpoon",
|
||||||
|
"기타": "Cookie",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABLER_SUB_RULES: { keyword: string; icon: string }[] = [
|
||||||
|
// 한식
|
||||||
|
{ keyword: "백반/한정식", icon: "BowlChopsticks" },
|
||||||
|
{ keyword: "국밥/해장국", icon: "Soup" },
|
||||||
|
{ keyword: "찌개/전골/탕", icon: "Cooker" },
|
||||||
|
{ keyword: "삼겹살/돼지구이", icon: "Meat" },
|
||||||
|
{ keyword: "소고기/한우구이", icon: "Grill" },
|
||||||
|
{ keyword: "곱창/막창", icon: "GrillFork" },
|
||||||
|
{ keyword: "닭/오리구이", icon: "Meat" },
|
||||||
|
{ keyword: "족발/보쌈", icon: "Meat" },
|
||||||
|
{ keyword: "회/횟집", icon: "Fish" },
|
||||||
|
{ keyword: "해산물", icon: "Fish" },
|
||||||
|
{ keyword: "분식", icon: "EggFried" },
|
||||||
|
{ keyword: "면", icon: "BowlChopsticks" },
|
||||||
|
{ keyword: "죽/죽집", icon: "BowlSpoon" },
|
||||||
|
{ keyword: "순대/순대국", icon: "Soup" },
|
||||||
|
{ keyword: "장어/민물", icon: "Fish" },
|
||||||
|
{ keyword: "주점/포차", icon: "Beer" },
|
||||||
|
{ keyword: "파인다이닝/코스", icon: "GlassChampagne" },
|
||||||
|
// 일식
|
||||||
|
{ keyword: "스시/오마카세", icon: "Fish" },
|
||||||
|
{ keyword: "라멘", icon: "Soup" },
|
||||||
|
{ keyword: "돈카츠", icon: "Meat" },
|
||||||
|
{ keyword: "텐동/튀김", icon: "EggFried" },
|
||||||
|
{ keyword: "이자카야", icon: "GlassCocktail" },
|
||||||
|
{ keyword: "야키니쿠", icon: "Grill" },
|
||||||
|
{ keyword: "카레", icon: "BowlSpoon" },
|
||||||
|
{ keyword: "소바/우동", icon: "BowlChopsticks" },
|
||||||
|
// 중식
|
||||||
|
{ keyword: "중화요리", icon: "Soup" },
|
||||||
|
{ keyword: "마라/훠궈", icon: "Pepper" },
|
||||||
|
{ keyword: "딤섬/만두", icon: "Egg" },
|
||||||
|
{ keyword: "양꼬치", icon: "Grill" },
|
||||||
|
// 양식
|
||||||
|
{ keyword: "파스타/이탈리안", icon: "BowlSpoon" },
|
||||||
|
{ keyword: "스테이크", icon: "Meat" },
|
||||||
|
{ keyword: "햄버거", icon: "Burger" },
|
||||||
|
{ keyword: "피자", icon: "Pizza" },
|
||||||
|
{ keyword: "프렌치", icon: "GlassChampagne" },
|
||||||
|
{ keyword: "바베큐", icon: "GrillSpatula" },
|
||||||
|
{ keyword: "브런치", icon: "EggFried" },
|
||||||
|
{ keyword: "비건/샐러드", icon: "Salad" },
|
||||||
|
// 아시아
|
||||||
|
{ keyword: "베트남", icon: "BowlChopsticks" },
|
||||||
|
{ keyword: "태국", icon: "Pepper" },
|
||||||
|
{ keyword: "인도/중동", icon: "BowlSpoon" },
|
||||||
|
{ keyword: "동남아기타", icon: "BowlSpoon" },
|
||||||
|
// 기타
|
||||||
|
{ keyword: "치킨", icon: "Meat" },
|
||||||
|
{ keyword: "카페/디저트", icon: "Coffee" },
|
||||||
|
{ keyword: "베이커리", icon: "Bread" },
|
||||||
|
{ keyword: "뷔페", icon: "Cheese" },
|
||||||
|
{ keyword: "퓨전", icon: "Cookie" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTablerCuisineIcon(cuisineType: string | null | undefined): string {
|
||||||
|
if (!cuisineType) return "Bowl";
|
||||||
|
for (const rule of TABLER_SUB_RULES) {
|
||||||
|
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
||||||
|
}
|
||||||
|
const main = cuisineType.split("|")[0];
|
||||||
|
return TABLER_CUISINE_MAP[main] || "Bowl";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user