Add cuisine subcategory filter, fix remap logic, and add OKE deployment manifests

- Add 파인다이닝/코스 cuisine type to 한식/일식/중식/양식 categories
- Change cuisine filter from flat list to grouped optgroup with subcategories
- Fix remap-foods/remap-cuisine: add jdbcType=CLOB, fix CLOB LISTAGG,
  improve retry logic (3 attempts, batch size 5), add error logging
- Add OKE deployment: Dockerfiles, K8s manifests, deploy.sh, deployment guide
- Add Next.js standalone output for Docker builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-09 22:58:09 +09:00
parent 69e1882c2b
commit ff4e8d742d
18 changed files with 853 additions and 41 deletions

19
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# ── Build stage ──
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
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
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"]

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

View File

@@ -13,20 +13,20 @@ import MyReviewsList from "@/components/MyReviewsList";
import BottomSheet from "@/components/BottomSheet";
import { getCuisineIcon } from "@/lib/cuisine-icons";
const CUISINE_GROUPS: { label: string; prefix: string }[] = [
{ label: "한식", prefix: "한식" },
{ label: "일식", prefix: "일식" },
{ label: "중식", prefix: "중식" },
{ label: "양식", prefix: "양식" },
{ label: "아시아", prefix: "아시아" },
{ label: "기타", prefix: "기타" },
const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [
{ category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] },
{ category: "일식", items: ["스시/오마카세", "라멘", "돈카츠", "텐동/튀김", "이자카야", "야키니쿠", "카레", "소바/우동", "파인다이닝/코스"] },
{ category: "중식", items: ["중화요리", "마라/훠궈", "딤섬/만두", "양꼬치", "파인다이닝/코스"] },
{ category: "양식", items: ["파스타/이탈리안", "스테이크", "햄버거", "피자", "프렌치", "바베큐", "브런치", "비건/샐러드", "파인다이닝/코스"] },
{ category: "아시아", items: ["베트남", "태국", "인도/중동", "동남아기타"] },
{ category: "기타", items: ["치킨", "카페/디저트", "베이커리", "뷔페", "퓨전"] },
];
function matchCuisineGroup(cuisineType: string | null, group: string): boolean {
if (!cuisineType) return false;
const g = CUISINE_GROUPS.find((g) => g.label === group);
if (!g) return false;
return cuisineType.startsWith(g.prefix);
function matchCuisineFilter(cuisineType: string | null, filter: string): boolean {
if (!cuisineType || !filter) return false;
// filter can be a category ("한식") or full type ("한식|백반/한정식")
if (filter.includes("|")) return cuisineType === filter;
return cuisineType.startsWith(filter);
}
const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [
@@ -171,7 +171,7 @@ export default function Home() {
const filteredRestaurants = useMemo(() => {
return restaurants.filter((r) => {
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
if (cuisineFilter && !matchCuisineGroup(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 (countryFilter) {
const parsed = parseRegion(r.region);
@@ -458,8 +458,15 @@ export default function Home() {
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
>
<option value="">🍽 </option>
{CUISINE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>🍽 {g.label}</option>
{CUISINE_TAXONOMY.map((g) => (
<optgroup key={g.category} label={`── ${g.category} ──`}>
<option value={g.category}>🍽 {g.category} </option>
{g.items.map((item) => (
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
&nbsp;&nbsp;{item}
</option>
))}
</optgroup>
))}
</select>
<select
@@ -684,8 +691,15 @@ export default function Home() {
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
>
<option value="">🍽 </option>
{CUISINE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>🍽 {g.label}</option>
{CUISINE_TAXONOMY.map((g) => (
<optgroup key={g.category} label={`── ${g.category} ──`}>
<option value={g.category}>🍽 {g.category} </option>
{g.items.map((item) => (
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
&nbsp;&nbsp;{item}
</option>
))}
</optgroup>
))}
</select>
<select